MotorSignal-App/lib/services/group_rides/group_ride_models.dart

745 lines
23 KiB
Dart

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<String, dynamic> toJson() => {
'latitude': latitude,
'longitude': longitude,
'displayName': displayName,
'address': address,
'locationType': locationType.name,
'notes': notes,
if (stopDurationMinutes != null)
'stopDurationMinutes': stopDurationMinutes,
};
factory GroupRideLocation.fromJson(Map<String, dynamic> 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,
underway,
completed,
canceled;
String get label {
switch (this) {
case GroupRideStatus.scheduled:
return 'Scheduled';
case GroupRideStatus.assembling:
return 'Assembling';
case GroupRideStatus.underway:
return 'Underway';
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<String, dynamic> 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<String, dynamic> 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<GroupRideLocation> 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 isUnderway => status == GroupRideStatus.underway;
bool get isLiveEligible =>
status == GroupRideStatus.scheduled ||
status == GroupRideStatus.assembling ||
status == GroupRideStatus.underway;
bool get isFull =>
participantLimit != null && participantCount >= participantLimit!;
Map<String, dynamic> 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<String, dynamic> 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<dynamic>?)
?.map((s) => GroupRideLocation.fromJson(s as Map<String, dynamic>))
.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<String, dynamic>)
: const GroupRideLocation(
latitude: 0,
longitude: 0,
displayName: 'TBD Meetup',
),
destination: json['destination'] != null
? GroupRideLocation.fromJson(
json['destination'] as Map<String, dynamic>)
: 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<GroupRideLocation>? 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;
}
/// Status of rider in live group ride presence.
enum LivePresenceStatus {
riding,
stopped,
needFuel,
regrouping,
delayed,
arrived,
offline;
String get label {
switch (this) {
case LivePresenceStatus.riding:
return 'Riding';
case LivePresenceStatus.stopped:
return 'Stopped';
case LivePresenceStatus.needFuel:
return 'Need Fuel';
case LivePresenceStatus.regrouping:
return 'Regroup';
case LivePresenceStatus.delayed:
return 'Delayed';
case LivePresenceStatus.arrived:
return 'Arrived';
case LivePresenceStatus.offline:
return 'Offline';
}
}
}
/// Ephemeral live group presence for an accepted group ride participant.
///
/// Strictly isolated from public profiles, garage, telemetry bundles, and
/// persistent user records. Whitelisted coordinates and coarse indicators only.
@immutable
class LiveGroupPresence {
final String userId;
final String groupRideId;
final double latitude;
final double longitude;
final double? heading;
final double? speedMph;
final double? accuracyMeters;
final DateTime updatedAt;
final DateTime expiresAt;
final bool sharingEnabled;
final LivePresenceStatus status;
const LiveGroupPresence({
required this.userId,
required this.groupRideId,
required this.latitude,
required this.longitude,
this.heading,
this.speedMph,
this.accuracyMeters,
required this.updatedAt,
required this.expiresAt,
this.sharingEnabled = true,
this.status = LivePresenceStatus.riding,
});
/// Location is considered fresh if updated within the last 60 seconds and sharing is active.
bool get isFresh =>
sharingEnabled && DateTime.now().difference(updatedAt).inSeconds < 60;
/// Location is considered recent if between 60 and 120 seconds old.
bool get isRecent {
final diff = DateTime.now().difference(updatedAt).inSeconds;
return sharingEnabled && diff >= 60 && diff < 120;
}
/// Elapsed seconds since last update.
int get secondsAgo => DateTime.now().difference(updatedAt).inSeconds;
/// Location is considered stale if over 120 seconds old or sharing was disabled.
bool get isStale =>
!sharingEnabled || DateTime.now().difference(updatedAt).inSeconds >= 120;
/// Coordinates within valid physical limits.
bool get isValidCoordinates =>
latitude >= -90.0 &&
latitude <= 90.0 &&
longitude >= -180.0 &&
longitude <= 180.0;
/// Reasonable GPS accuracy (within 100m) if provided.
bool get isAccurate => accuracyMeters == null || accuracyMeters! <= 100.0;
Map<String, dynamic> toJson() => {
'userId': userId,
'groupRideId': groupRideId,
'latitude': latitude,
'longitude': longitude,
if (heading != null) 'heading': heading,
if (speedMph != null) 'speedMph': speedMph,
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
'updatedAt': updatedAt.toIso8601String(),
'expiresAt': expiresAt.toIso8601String(),
'sharingEnabled': sharingEnabled,
'status': status.name,
};
factory LiveGroupPresence.fromJson(Map<String, dynamic> json) {
final updated = json['updatedAt'] != null
? DateTime.tryParse(json['updatedAt'] as String) ?? DateTime.now()
: DateTime.now();
final expires = json['expiresAt'] != null
? DateTime.tryParse(json['expiresAt'] as String) ??
updated.add(const Duration(seconds: 120))
: updated.add(const Duration(seconds: 120));
return LiveGroupPresence(
userId: json['userId'] as String? ?? '',
groupRideId: json['groupRideId'] as String? ?? '',
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
heading: (json['heading'] as num?)?.toDouble(),
speedMph: (json['speedMph'] as num?)?.toDouble(),
accuracyMeters: (json['accuracyMeters'] as num?)?.toDouble(),
updatedAt: updated,
expiresAt: expires,
sharingEnabled: json['sharingEnabled'] as bool? ?? false,
status: LivePresenceStatus.values.firstWhere(
(s) => s.name == (json['status'] as String?),
orElse: () => LivePresenceStatus.offline,
),
);
}
LiveGroupPresence copyWith({
String? userId,
String? groupRideId,
double? latitude,
double? longitude,
double? heading,
double? speedMph,
double? accuracyMeters,
DateTime? updatedAt,
DateTime? expiresAt,
bool? sharingEnabled,
LivePresenceStatus? status,
}) {
return LiveGroupPresence(
userId: userId ?? this.userId,
groupRideId: groupRideId ?? this.groupRideId,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
heading: heading ?? this.heading,
speedMph: speedMph ?? this.speedMph,
accuracyMeters: accuracyMeters ?? this.accuracyMeters,
updatedAt: updatedAt ?? this.updatedAt,
expiresAt: expiresAt ?? this.expiresAt,
sharingEnabled: sharingEnabled ?? this.sharingEnabled,
status: status ?? this.status,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LiveGroupPresence &&
runtimeType == other.runtimeType &&
userId == other.userId &&
groupRideId == other.groupRideId &&
latitude == other.latitude &&
longitude == other.longitude &&
heading == other.heading &&
speedMph == other.speedMph &&
accuracyMeters == other.accuracyMeters &&
sharingEnabled == other.sharingEnabled &&
status == other.status;
@override
int get hashCode =>
userId.hashCode ^
groupRideId.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
heading.hashCode ^
speedMph.hashCode ^
accuracyMeters.hashCode ^
sharingEnabled.hashCode ^
status.hashCode;
}