MotorSignal-App/lib/services/routes/route_models.dart

438 lines
13 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../map/map_provider_interface.dart';
/// Semantic types of route stops and waypoints.
enum RouteStopType {
start,
origin,
meetup,
waypoint,
fuel,
rest,
food,
scenic,
regroup,
destination,
other;
String get label {
switch (this) {
case RouteStopType.start:
case RouteStopType.origin:
return 'Start / Origin';
case RouteStopType.meetup:
return 'Meeting Point';
case RouteStopType.waypoint:
return 'Waypoint';
case RouteStopType.fuel:
return 'Fuel Stop';
case RouteStopType.rest:
return 'Rest Area';
case RouteStopType.food:
return 'Food & Drink';
case RouteStopType.scenic:
return 'Scenic Overlook';
case RouteStopType.regroup:
return 'Regroup Point';
case RouteStopType.destination:
return 'Destination';
case RouteStopType.other:
return 'Stop';
}
}
}
/// A discrete, ordered location stop along a route.
@immutable
class RouteStop {
final String id;
final int order;
final RouteStopType type;
final String displayName;
final double latitude;
final double longitude;
final String address;
final String notes;
final int? plannedStopDurationMinutes;
final String? providerPlaceId;
const RouteStop({
required this.id,
int? order,
int? orderIndex,
RouteStopType? type,
RouteStopType? stopType,
required this.displayName,
required this.latitude,
required this.longitude,
this.address = '',
this.notes = '',
this.plannedStopDurationMinutes,
this.providerPlaceId,
}) : order = order ?? orderIndex ?? 0,
type = type ?? stopType ?? RouteStopType.waypoint;
int get orderIndex => order;
RouteStopType get stopType => type;
AppLatLng get latLng => AppLatLng(latitude, longitude);
RouteStop copyWith({
String? id,
int? order,
int? orderIndex,
RouteStopType? type,
RouteStopType? stopType,
String? displayName,
double? latitude,
double? longitude,
String? address,
String? notes,
int? plannedStopDurationMinutes,
String? providerPlaceId,
}) {
return RouteStop(
id: id ?? this.id,
order: order ?? orderIndex ?? this.order,
type: type ?? stopType ?? this.type,
displayName: displayName ?? this.displayName,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
address: address ?? this.address,
notes: notes ?? this.notes,
plannedStopDurationMinutes:
plannedStopDurationMinutes ?? this.plannedStopDurationMinutes,
providerPlaceId: providerPlaceId ?? this.providerPlaceId,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'order': order,
'orderIndex': order,
'type': type.name,
'stopType': type.name,
'displayName': displayName,
'latitude': latitude,
'longitude': longitude,
if (address.isNotEmpty) 'address': address,
if (notes.isNotEmpty) 'notes': notes,
if (plannedStopDurationMinutes != null)
'plannedStopDurationMinutes': plannedStopDurationMinutes,
if (providerPlaceId != null) 'providerPlaceId': providerPlaceId,
};
factory RouteStop.fromJson(Map<String, dynamic> json) {
final rawOrder = json['orderIndex'] ?? json['order'];
final rawType = json['stopType'] ?? json['type'];
return RouteStop(
id: json['id'] as String? ?? '',
order: (rawOrder as num?)?.toInt() ?? 0,
type: RouteStopType.values.firstWhere(
(t) => t.name == (rawType as String?),
orElse: () => RouteStopType.waypoint,
),
displayName: json['displayName'] as String? ?? 'Stop',
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
address: json['address'] as String? ?? '',
notes: json['notes'] as String? ?? '',
plannedStopDurationMinutes:
(json['plannedStopDurationMinutes'] as num?)?.toInt(),
providerPlaceId: json['providerPlaceId'] as String?,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RouteStop &&
runtimeType == other.runtimeType &&
id == other.id &&
order == other.order &&
type == other.type &&
displayName == other.displayName &&
latitude == other.latitude &&
longitude == other.longitude &&
address == other.address &&
notes == other.notes &&
plannedStopDurationMinutes == other.plannedStopDurationMinutes &&
providerPlaceId == other.providerPlaceId;
@override
int get hashCode =>
id.hashCode ^
order.hashCode ^
type.hashCode ^
displayName.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
address.hashCode ^
notes.hashCode ^
plannedStopDurationMinutes.hashCode ^
providerPlaceId.hashCode;
}
/// Visibility of a route.
enum RouteVisibility {
private,
groupOnly,
public;
String get label {
switch (this) {
case RouteVisibility.private:
return 'Private';
case RouteVisibility.groupOnly:
return 'Group Ride Only';
case RouteVisibility.public:
return 'Public';
}
}
}
/// Provenance of the route.
enum RouteSource {
manual,
planner,
gpxImport,
groupRideConversion;
String get label {
switch (this) {
case RouteSource.manual:
case RouteSource.planner:
return 'Manual Planner';
case RouteSource.gpxImport:
return 'GPX Import';
case RouteSource.groupRideConversion:
return 'Group Ride Adaptation';
}
}
}
/// Reusable MotorSignal planned route model.
@immutable
class MotorSignalRoute {
final String id;
final String ownerUid;
final String ownerHandle;
final String title;
final String description;
final RouteVisibility visibility;
final List<RouteStop> stops;
final double straightLineDistanceMeters;
final double? estimatedDistanceMeters;
final int? estimatedDurationSeconds;
final RouteSource source;
final int version;
final String? attachedGroupRideId;
final DateTime createdAt;
final DateTime updatedAt;
MotorSignalRoute({
required this.id,
required this.ownerUid,
this.ownerHandle = '',
required this.title,
this.description = '',
this.visibility = RouteVisibility.private,
this.stops = const [],
double? straightLineDistanceMeters,
this.estimatedDistanceMeters,
this.estimatedDurationSeconds,
this.source = RouteSource.manual,
this.version = 1,
this.attachedGroupRideId,
required this.createdAt,
required this.updatedAt,
}) : straightLineDistanceMeters = straightLineDistanceMeters ??
calculateTotalDistanceMeters(stops);
/// Total straight-line distance in meters.
double get totalDistanceMeters => straightLineDistanceMeters;
/// Total straight-line distance in miles.
double get straightLineDistanceMiles =>
straightLineDistanceMeters * 0.000621371;
double get distanceMiles => straightLineDistanceMiles;
/// Distance string in miles.
String get formattedDistanceMiles =>
'${straightLineDistanceMiles.toStringAsFixed(1)} mi';
/// Distance string in kilometers.
String get formattedDistanceKm =>
'${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km';
/// Truthful distance string according to calculation method.
String get formattedDistance {
if (estimatedDistanceMeters != null) {
final roadMiles = estimatedDistanceMeters! * 0.000621371;
return '${roadMiles.toStringAsFixed(1)} mi (Road Est.)';
}
return '${straightLineDistanceMiles.toStringAsFixed(1)} mi (Straight-line)';
}
/// Truthful label describing the nature of the distance calculation.
String get distanceLabel => estimatedDistanceMeters != null
? 'Estimated road distance'
: 'Straight-line distance';
/// Returns stops strictly ordered by index.
List<RouteStop> get orderedStops {
final sorted = List<RouteStop>.from(stops);
sorted.sort((a, b) => a.order.compareTo(b.order));
return sorted;
}
/// Polyline points connecting ordered stops (straight-line preview).
List<AppLatLng> get previewPoints {
return orderedStops.map((s) => s.latLng).toList();
}
/// Calculates geodesic straight-line distance along ordered stops in meters using Haversine formula.
static double calculateTotalDistanceMeters(List<RouteStop> stops) {
if (stops.length < 2) return 0.0;
final sorted = List<RouteStop>.from(stops);
sorted.sort((a, b) => a.order.compareTo(b.order));
double total = 0.0;
for (int i = 0; i < sorted.length - 1; i++) {
total += _haversineMeters(
sorted[i].latitude,
sorted[i].longitude,
sorted[i + 1].latitude,
sorted[i + 1].longitude,
);
}
return total;
}
static double _haversineMeters(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const double r = 6371000.0; // Earth radius in meters
final double phi1 = lat1 * math.pi / 180.0;
final double phi2 = lat2 * math.pi / 180.0;
final double deltaPhi = (lat2 - lat1) * math.pi / 180.0;
final double deltaLambda = (lon2 - lon1) * math.pi / 180.0;
final double a = math.sin(deltaPhi / 2.0) * math.sin(deltaPhi / 2.0) +
math.cos(phi1) *
math.cos(phi2) *
math.sin(deltaLambda / 2.0) *
math.sin(deltaLambda / 2.0);
final double c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a));
return r * c;
}
MotorSignalRoute copyWith({
String? id,
String? ownerUid,
String? ownerHandle,
String? title,
String? description,
RouteVisibility? visibility,
List<RouteStop>? stops,
double? straightLineDistanceMeters,
double? estimatedDistanceMeters,
int? estimatedDurationSeconds,
RouteSource? source,
int? version,
String? attachedGroupRideId,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return MotorSignalRoute(
id: id ?? this.id,
ownerUid: ownerUid ?? this.ownerUid,
ownerHandle: ownerHandle ?? this.ownerHandle,
title: title ?? this.title,
description: description ?? this.description,
visibility: visibility ?? this.visibility,
stops: stops ?? this.stops,
straightLineDistanceMeters:
straightLineDistanceMeters ?? this.straightLineDistanceMeters,
estimatedDistanceMeters:
estimatedDistanceMeters ?? this.estimatedDistanceMeters,
estimatedDurationSeconds:
estimatedDurationSeconds ?? this.estimatedDurationSeconds,
source: source ?? this.source,
version: version ?? this.version,
attachedGroupRideId: attachedGroupRideId ?? this.attachedGroupRideId,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'ownerUid': ownerUid,
'ownerHandle': ownerHandle,
'title': title,
'description': description,
'visibility': visibility.name,
'stops': stops.map((s) => s.toJson()).toList(),
'straightLineDistanceMeters': straightLineDistanceMeters,
if (estimatedDistanceMeters != null)
'estimatedDistanceMeters': estimatedDistanceMeters,
if (estimatedDurationSeconds != null)
'estimatedDurationSeconds': estimatedDurationSeconds,
'source': source.name,
'version': version,
if (attachedGroupRideId != null)
'attachedGroupRideId': attachedGroupRideId,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
factory MotorSignalRoute.fromJson(Map<String, dynamic> json) {
final rawStops = json['stops'] as List<dynamic>? ?? [];
final parsedStops = rawStops
.whereType<Map<String, dynamic>>()
.map((s) => RouteStop.fromJson(s))
.toList();
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 MotorSignalRoute(
id: json['id'] as String? ?? '',
ownerUid: json['ownerUid'] as String? ?? '',
ownerHandle: json['ownerHandle'] as String? ?? '',
title: json['title'] as String? ?? 'Untitled Route',
description: json['description'] as String? ?? '',
visibility: RouteVisibility.values.firstWhere(
(v) => v.name == (json['visibility'] as String?),
orElse: () => RouteVisibility.private,
),
stops: parsedStops,
straightLineDistanceMeters:
(json['straightLineDistanceMeters'] as num?)?.toDouble() ??
calculateTotalDistanceMeters(parsedStops),
estimatedDistanceMeters:
(json['estimatedDistanceMeters'] as num?)?.toDouble(),
estimatedDurationSeconds:
(json['estimatedDurationSeconds'] as num?)?.toInt(),
source: RouteSource.values.firstWhere(
(s) => s.name == (json['source'] as String?),
orElse: () => RouteSource.manual,
),
version: (json['version'] as num?)?.toInt() ?? 1,
attachedGroupRideId: json['attachedGroupRideId'] as String?,
createdAt: created,
updatedAt: updated,
);
}
}