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 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 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 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 get orderedStops { final sorted = List.from(stops); sorted.sort((a, b) => a.order.compareTo(b.order)); return sorted; } /// Polyline points connecting ordered stops (straight-line preview). List 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 stops) { if (stops.length < 2) return 0.0; final sorted = List.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? 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 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 json) { final rawStops = json['stops'] as List? ?? []; final parsedStops = rawStops .whereType>() .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 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 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, ); } }