import 'package:flutter/foundation.dart'; /// Type of group ride location / stop. enum GroupRideLocationType { meetup, fuel, rest, food, scenic, waypoint, destination, other; String get label { switch (this) { case GroupRideLocationType.meetup: return 'Meeting Point'; case GroupRideLocationType.fuel: return 'Fuel Stop'; case GroupRideLocationType.rest: return 'Rest Stop'; case GroupRideLocationType.food: return 'Food & Drink'; case GroupRideLocationType.scenic: return 'Scenic Overlook'; case GroupRideLocationType.waypoint: return 'Waypoint'; case GroupRideLocationType.destination: return 'Destination'; case GroupRideLocationType.other: return 'Stop'; } } } /// Explicit shared coordinate and description for group meetups, stops, and destinations. /// Never derived from or tied to private home addresses or live GPS. @immutable class GroupRideLocation { final double latitude; final double longitude; final String displayName; final String address; final GroupRideLocationType locationType; final String notes; final int? stopDurationMinutes; const GroupRideLocation({ required this.latitude, required this.longitude, required this.displayName, this.address = '', this.locationType = GroupRideLocationType.waypoint, this.notes = '', this.stopDurationMinutes, }); Map toJson() => { 'latitude': latitude, 'longitude': longitude, 'displayName': displayName, 'address': address, 'locationType': locationType.name, 'notes': notes, if (stopDurationMinutes != null) 'stopDurationMinutes': stopDurationMinutes, }; factory GroupRideLocation.fromJson(Map json) { return GroupRideLocation( latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, displayName: json['displayName'] as String? ?? '', address: json['address'] as String? ?? '', locationType: GroupRideLocationType.values.firstWhere( (t) => t.name == (json['locationType'] as String?), orElse: () => GroupRideLocationType.waypoint, ), notes: json['notes'] as String? ?? '', stopDurationMinutes: json['stopDurationMinutes'] as int?, ); } GroupRideLocation copyWith({ double? latitude, double? longitude, String? displayName, String? address, GroupRideLocationType? locationType, String? notes, int? stopDurationMinutes, }) { return GroupRideLocation( latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, displayName: displayName ?? this.displayName, address: address ?? this.address, locationType: locationType ?? this.locationType, notes: notes ?? this.notes, stopDurationMinutes: stopDurationMinutes ?? this.stopDurationMinutes, ); } @override bool operator ==(Object other) => identical(this, other) || other is GroupRideLocation && runtimeType == other.runtimeType && latitude == other.latitude && longitude == other.longitude && displayName == other.displayName && address == other.address && locationType == other.locationType && notes == other.notes && stopDurationMinutes == other.stopDurationMinutes; @override int get hashCode => latitude.hashCode ^ longitude.hashCode ^ displayName.hashCode ^ address.hashCode ^ locationType.hashCode ^ notes.hashCode ^ stopDurationMinutes.hashCode; } /// Visibility control for discovering and viewing a Group Ride. enum GroupRideVisibility { private, friendsOnly, public; String get label { switch (this) { case GroupRideVisibility.private: return 'Private (Invite Only)'; case GroupRideVisibility.friendsOnly: return 'Friends Only'; case GroupRideVisibility.public: return 'Public (Discoverable)'; } } } /// Policy for joining a Group Ride. enum GroupRideJoinPolicy { inviteOnly, requestToJoin, open; String get label { switch (this) { case GroupRideJoinPolicy.inviteOnly: return 'Invite Only'; case GroupRideJoinPolicy.requestToJoin: return 'Request to Join'; case GroupRideJoinPolicy.open: return 'Open to Join'; } } } /// Status lifecycle of a Group Ride. enum GroupRideStatus { scheduled, assembling, completed, canceled; String get label { switch (this) { case GroupRideStatus.scheduled: return 'Scheduled'; case GroupRideStatus.assembling: return 'Assembling'; case GroupRideStatus.completed: return 'Completed'; case GroupRideStatus.canceled: return 'Canceled'; } } } /// Role of a participant in a Group Ride. enum GroupRideRole { organizer, participant, lead, sweep; String get label { switch (this) { case GroupRideRole.organizer: return 'Organizer'; case GroupRideRole.participant: return 'Rider'; case GroupRideRole.lead: return 'Ride Lead'; case GroupRideRole.sweep: return 'Sweep'; } } } /// Status of an individual participant in a Group Ride. enum ParticipantStatus { invited, requested, accepted, declined, left, removed; bool get isActiveMember => this == ParticipantStatus.accepted; } /// Individual participant membership in a Group Ride. @immutable class GroupRideParticipant { final String userId; final GroupRideRole role; final ParticipantStatus status; final String riderHandle; final String riderDisplayName; final String? riderAvatarPreset; final String? riderAvatarUrl; final String? invitedBy; final DateTime createdAt; final DateTime updatedAt; const GroupRideParticipant({ required this.userId, required this.role, required this.status, required this.riderHandle, required this.riderDisplayName, this.riderAvatarPreset, this.riderAvatarUrl, this.invitedBy, required this.createdAt, required this.updatedAt, }); String get displayHandle => '@$riderHandle'; Map toJson() => { 'userId': userId, 'role': role.name, 'status': status.name, 'riderHandle': riderHandle, 'riderDisplayName': riderDisplayName, if (riderAvatarPreset != null) 'riderAvatarPreset': riderAvatarPreset, if (riderAvatarUrl != null) 'riderAvatarUrl': riderAvatarUrl, if (invitedBy != null) 'invitedBy': invitedBy, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), }; factory GroupRideParticipant.fromJson(Map json) { final created = json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() : DateTime.now(); final updated = json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt'] as String) ?? created : created; return GroupRideParticipant( userId: json['userId'] as String? ?? '', role: GroupRideRole.values.firstWhere( (r) => r.name == (json['role'] as String?), orElse: () => GroupRideRole.participant, ), status: ParticipantStatus.values.firstWhere( (s) => s.name == (json['status'] as String?), orElse: () => ParticipantStatus.invited, ), riderHandle: json['riderHandle'] as String? ?? '', riderDisplayName: json['riderDisplayName'] as String? ?? '', riderAvatarPreset: json['riderAvatarPreset'] as String?, riderAvatarUrl: json['riderAvatarUrl'] as String?, invitedBy: json['invitedBy'] as String?, createdAt: created, updatedAt: updated, ); } GroupRideParticipant copyWith({ String? userId, GroupRideRole? role, ParticipantStatus? status, String? riderHandle, String? riderDisplayName, String? riderAvatarPreset, String? riderAvatarUrl, String? invitedBy, DateTime? createdAt, DateTime? updatedAt, }) { return GroupRideParticipant( userId: userId ?? this.userId, role: role ?? this.role, status: status ?? this.status, riderHandle: riderHandle ?? this.riderHandle, riderDisplayName: riderDisplayName ?? this.riderDisplayName, riderAvatarPreset: riderAvatarPreset ?? this.riderAvatarPreset, riderAvatarUrl: riderAvatarUrl ?? this.riderAvatarUrl, invitedBy: invitedBy ?? this.invitedBy, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); } @override bool operator ==(Object other) => identical(this, other) || other is GroupRideParticipant && runtimeType == other.runtimeType && userId == other.userId && role == other.role && status == other.status && riderHandle == other.riderHandle && riderDisplayName == other.riderDisplayName && riderAvatarPreset == other.riderAvatarPreset && riderAvatarUrl == other.riderAvatarUrl && invitedBy == other.invitedBy; @override int get hashCode => userId.hashCode ^ role.hashCode ^ status.hashCode ^ riderHandle.hashCode ^ riderDisplayName.hashCode ^ riderAvatarPreset.hashCode ^ riderAvatarUrl.hashCode ^ invitedBy.hashCode; } /// Core Group Ride model. @immutable class GroupRide { final String id; final String organizerUid; final String organizerHandle; final String organizerDisplayName; final String title; final String description; final GroupRideVisibility visibility; final GroupRideJoinPolicy joinPolicy; final GroupRideStatus status; final DateTime scheduledStartAt; // Meetup / arrival time final DateTime departureAt; // Kickstands up (>= scheduledStartAt) final int? participantLimit; final int participantCount; // Authoritative active member counter final GroupRideLocation meetingPoint; final GroupRideLocation? destination; final List stops; // Ordered waypoints final DateTime createdAt; final DateTime updatedAt; final DateTime? canceledAt; final String? canceledReason; const GroupRide({ required this.id, required this.organizerUid, required this.organizerHandle, required this.organizerDisplayName, required this.title, this.description = '', this.visibility = GroupRideVisibility.friendsOnly, this.joinPolicy = GroupRideJoinPolicy.inviteOnly, this.status = GroupRideStatus.scheduled, required this.scheduledStartAt, required this.departureAt, this.participantLimit, this.participantCount = 1, required this.meetingPoint, this.destination, this.stops = const [], required this.createdAt, required this.updatedAt, this.canceledAt, this.canceledReason, }); bool get isCanceled => status == GroupRideStatus.canceled; bool get isCompleted => status == GroupRideStatus.completed; bool get isFull => participantLimit != null && participantCount >= participantLimit!; Map toJson() => { 'id': id, 'organizerUid': organizerUid, 'organizerHandle': organizerHandle, 'organizerDisplayName': organizerDisplayName, 'title': title, 'description': description, 'visibility': visibility.name, 'joinPolicy': joinPolicy.name, 'status': status.name, 'scheduledStartAt': scheduledStartAt.toIso8601String(), 'departureAt': departureAt.toIso8601String(), if (participantLimit != null) 'participantLimit': participantLimit, 'participantCount': participantCount, 'meetingPoint': meetingPoint.toJson(), if (destination != null) 'destination': destination!.toJson(), 'stops': stops.map((s) => s.toJson()).toList(), 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(), if (canceledReason != null) 'canceledReason': canceledReason, }; factory GroupRide.fromJson(Map json) { final created = json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now() : DateTime.now(); final updated = json['updatedAt'] != null ? DateTime.tryParse(json['updatedAt'] as String) ?? created : created; final scheduled = json['scheduledStartAt'] != null ? DateTime.tryParse(json['scheduledStartAt'] as String) ?? created : created; final departure = json['departureAt'] != null ? DateTime.tryParse(json['departureAt'] as String) ?? scheduled : scheduled; final canceled = json['canceledAt'] != null ? DateTime.tryParse(json['canceledAt'] as String) : null; final stopsList = (json['stops'] as List?) ?.map((s) => GroupRideLocation.fromJson(s as Map)) .toList() ?? const []; return GroupRide( id: json['id'] as String? ?? '', organizerUid: json['organizerUid'] as String? ?? '', organizerHandle: json['organizerHandle'] as String? ?? '', organizerDisplayName: json['organizerDisplayName'] as String? ?? '', title: json['title'] as String? ?? '', description: json['description'] as String? ?? '', visibility: GroupRideVisibility.values.firstWhere( (v) => v.name == (json['visibility'] as String?), orElse: () => GroupRideVisibility.friendsOnly, ), joinPolicy: GroupRideJoinPolicy.values.firstWhere( (p) => p.name == (json['joinPolicy'] as String?), orElse: () => GroupRideJoinPolicy.inviteOnly, ), status: GroupRideStatus.values.firstWhere( (s) => s.name == (json['status'] as String?), orElse: () => GroupRideStatus.scheduled, ), scheduledStartAt: scheduled, departureAt: departure, participantLimit: json['participantLimit'] as int?, participantCount: json['participantCount'] as int? ?? 1, meetingPoint: json['meetingPoint'] != null ? GroupRideLocation.fromJson( json['meetingPoint'] as Map) : const GroupRideLocation( latitude: 0, longitude: 0, displayName: 'TBD Meetup', ), destination: json['destination'] != null ? GroupRideLocation.fromJson( json['destination'] as Map) : null, stops: stopsList, createdAt: created, updatedAt: updated, canceledAt: canceled, canceledReason: json['canceledReason'] as String?, ); } GroupRide copyWith({ String? id, String? organizerUid, String? organizerHandle, String? organizerDisplayName, String? title, String? description, GroupRideVisibility? visibility, GroupRideJoinPolicy? joinPolicy, GroupRideStatus? status, DateTime? scheduledStartAt, DateTime? departureAt, int? participantLimit, int? participantCount, GroupRideLocation? meetingPoint, GroupRideLocation? destination, List? stops, DateTime? createdAt, DateTime? updatedAt, DateTime? canceledAt, String? canceledReason, }) { return GroupRide( id: id ?? this.id, organizerUid: organizerUid ?? this.organizerUid, organizerHandle: organizerHandle ?? this.organizerHandle, organizerDisplayName: organizerDisplayName ?? this.organizerDisplayName, title: title ?? this.title, description: description ?? this.description, visibility: visibility ?? this.visibility, joinPolicy: joinPolicy ?? this.joinPolicy, status: status ?? this.status, scheduledStartAt: scheduledStartAt ?? this.scheduledStartAt, departureAt: departureAt ?? this.departureAt, participantLimit: participantLimit ?? this.participantLimit, participantCount: participantCount ?? this.participantCount, meetingPoint: meetingPoint ?? this.meetingPoint, destination: destination ?? this.destination, stops: stops ?? this.stops, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, canceledAt: canceledAt ?? this.canceledAt, canceledReason: canceledReason ?? this.canceledReason, ); } @override bool operator ==(Object other) => identical(this, other) || other is GroupRide && runtimeType == other.runtimeType && id == other.id && organizerUid == other.organizerUid && title == other.title && status == other.status && scheduledStartAt == other.scheduledStartAt && departureAt == other.departureAt && participantCount == other.participantCount && participantLimit == other.participantLimit; @override int get hashCode => id.hashCode ^ organizerUid.hashCode ^ title.hashCode ^ status.hashCode ^ scheduledStartAt.hashCode ^ departureAt.hashCode ^ participantCount.hashCode ^ participantLimit.hashCode; }