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

663 lines
20 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show IconData, Icons;
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,
);
}
}
/// Semantic maneuver types for turn-by-turn navigation.
enum ManeuverType {
depart,
turnRight,
turnLeft,
turnSlightRight,
turnSlightLeft,
turnSharpRight,
turnSharpLeft,
keepRight,
keepLeft,
uTurn,
continueStraight,
enterRoundabout,
exitRoundabout,
ramp,
arrive,
unknown;
IconData get icon {
switch (this) {
case ManeuverType.depart:
return Icons.navigation;
case ManeuverType.turnRight:
case ManeuverType.turnSharpRight:
return Icons.turn_right;
case ManeuverType.turnLeft:
case ManeuverType.turnSharpLeft:
return Icons.turn_left;
case ManeuverType.turnSlightRight:
case ManeuverType.keepRight:
return Icons.turn_slight_right;
case ManeuverType.turnSlightLeft:
case ManeuverType.keepLeft:
return Icons.turn_slight_left;
case ManeuverType.uTurn:
return Icons.u_turn_left;
case ManeuverType.continueStraight:
return Icons.straight;
case ManeuverType.enterRoundabout:
case ManeuverType.exitRoundabout:
return Icons.roundabout_right;
case ManeuverType.ramp:
return Icons.ramp_right;
case ManeuverType.arrive:
return Icons.flag;
case ManeuverType.unknown:
return Icons.navigation;
}
}
String get directionShort {
switch (this) {
case ManeuverType.turnRight:
return 'RIGHT';
case ManeuverType.turnLeft:
return 'LEFT';
case ManeuverType.turnSlightRight:
return 'SLIGHT RIGHT';
case ManeuverType.turnSlightLeft:
return 'SLIGHT LEFT';
case ManeuverType.turnSharpRight:
return 'HARD RIGHT';
case ManeuverType.turnSharpLeft:
return 'HARD LEFT';
case ManeuverType.uTurn:
return 'U-TURN';
case ManeuverType.keepRight:
return 'KEEP RIGHT';
case ManeuverType.keepLeft:
return 'KEEP LEFT';
case ManeuverType.continueStraight:
return 'STRAIGHT';
case ManeuverType.depart:
return 'HEAD OUT';
case ManeuverType.arrive:
return 'ARRIVE';
default:
return 'CONTINUE';
}
}
bool get isLeft =>
this == ManeuverType.turnLeft ||
this == ManeuverType.turnSharpLeft ||
this == ManeuverType.turnSlightLeft ||
this == ManeuverType.keepLeft;
bool get isRight =>
this == ManeuverType.turnRight ||
this == ManeuverType.turnSharpRight ||
this == ManeuverType.turnSlightRight ||
this == ManeuverType.keepRight;
}
/// A single turn-by-turn navigation maneuver step.
@immutable
class RouteManeuver {
final int index;
final String instruction;
final ManeuverType type;
final double distanceMeters;
final int durationSeconds;
final String? streetName;
final AppLatLng location;
final int? geometryOffset;
const RouteManeuver({
required this.index,
required this.instruction,
required this.type,
required this.distanceMeters,
required this.durationSeconds,
this.streetName,
required this.location,
this.geometryOffset,
});
double get distanceMiles => distanceMeters * 0.000621371;
String get formattedDistance {
if (distanceMiles < 0.1) {
final feet = (distanceMeters * 3.28084).round();
return '$feet ft';
}
return '${distanceMiles.toStringAsFixed(1)} mi';
}
Map<String, dynamic> toJson() => {
'index': index,
'instruction': instruction,
'type': type.name,
'distanceMeters': distanceMeters,
'durationSeconds': durationSeconds,
if (streetName != null) 'streetName': streetName,
'latitude': location.latitude,
'longitude': location.longitude,
if (geometryOffset != null) 'geometryOffset': geometryOffset,
};
factory RouteManeuver.fromJson(Map<String, dynamic> json) {
return RouteManeuver(
index: (json['index'] as num?)?.toInt() ?? 0,
instruction: json['instruction'] as String? ?? '',
type: ManeuverType.values.firstWhere(
(t) => t.name == (json['type'] as String?),
orElse: () => ManeuverType.unknown,
),
distanceMeters: (json['distanceMeters'] as num?)?.toDouble() ?? 0.0,
durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
streetName: json['streetName'] as String?,
location: AppLatLng(
(json['latitude'] as num?)?.toDouble() ?? 0.0,
(json['longitude'] as num?)?.toDouble() ?? 0.0,
),
geometryOffset: (json['geometryOffset'] as num?)?.toInt(),
);
}
}
/// Routing optimization preferences supported by MotorSignal and AWS Routes V2.
enum RoutingPreference {
fastest,
shortest,
twisty,
scenic;
String get label {
switch (this) {
case RoutingPreference.fastest:
return 'Fastest';
case RoutingPreference.shortest:
return 'Shortest';
case RoutingPreference.twisty:
return 'Twisty (Curvature)';
case RoutingPreference.scenic:
return 'Scenic (No Highways)';
}
}
String get subtitle {
switch (this) {
case RoutingPreference.fastest:
return 'Optimized for minimum travel time';
case RoutingPreference.shortest:
return 'Shortest total distance';
case RoutingPreference.twisty:
return 'Maximizes curves and elevation changes';
case RoutingPreference.scenic:
return 'Avoids limited-access highways and tolls';
}
}
}
/// Routing options provided to route calculation requests.
@immutable
class RoutingOptions {
final RoutingPreference preference;
final bool avoidHighways;
final bool avoidTolls;
final bool avoidFerries;
const RoutingOptions({
this.preference = RoutingPreference.fastest,
this.avoidHighways = false,
this.avoidTolls = false,
this.avoidFerries = false,
});
RoutingOptions copyWith({
RoutingPreference? preference,
bool? avoidHighways,
bool? avoidTolls,
bool? avoidFerries,
}) {
return RoutingOptions(
preference: preference ?? this.preference,
avoidHighways: avoidHighways ?? this.avoidHighways,
avoidTolls: avoidTolls ?? this.avoidTolls,
avoidFerries: avoidFerries ?? this.avoidFerries,
);
}
}