From 23e63350b0dd0aa12520696f51628ec7993a7456 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 4 Sep 2026 12:28:21 -0400 Subject: [PATCH] feat: add route planning and sharing --- .../emulator-tests/firestore.rules.test.js | 231 ++++ firestore.rules | 53 + lib/core/router.dart | 20 + .../group_rides/create_group_ride_screen.dart | 248 +++- .../group_rides/group_ride_detail_screen.dart | 299 +++++ .../group_rides/live_group_ride_screen.dart | 131 ++- lib/features/routes/route_detail_screen.dart | 597 ++++++++++ lib/features/routes/route_planner_screen.dart | 1020 +++++++++++++++++ .../entitlements/entitlement_providers.dart | 5 + .../group_rides/group_ride_models.dart | 31 +- .../group_rides/group_ride_service.dart | 8 + lib/services/routes/navigation_handoff.dart | 282 +++++ lib/services/routes/route_models.dart | 437 +++++++ lib/services/routes/route_provider.dart | 146 +++ lib/services/routes/route_providers.dart | 355 ++++++ lib/services/routes/route_service.dart | 360 ++++++ test/route_models_test.dart | 198 ++++ test/route_planner_screen_test.dart | 113 ++ test/route_service_test.dart | 235 ++++ 19 files changed, 4738 insertions(+), 31 deletions(-) create mode 100644 lib/features/routes/route_detail_screen.dart create mode 100644 lib/features/routes/route_planner_screen.dart create mode 100644 lib/services/routes/navigation_handoff.dart create mode 100644 lib/services/routes/route_models.dart create mode 100644 lib/services/routes/route_provider.dart create mode 100644 lib/services/routes/route_providers.dart create mode 100644 lib/services/routes/route_service.dart create mode 100644 test/route_models_test.dart create mode 100644 test/route_planner_screen_test.dart create mode 100644 test/route_service_test.dart diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index c1c2eb1..ea6cab2 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -962,5 +962,236 @@ describe('Social Architecture Security Rules', () => { })); }); }); + + describe('MotorSignal route planning and sharing rules', () => { + test('owner can create, read, update, and delete their own route', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const routeRef = doc(alice, 'routes/route_alice_1'); + + // 1. Create valid route: SUCCEEDS + await assertSucceeds(setDoc(routeRef, { + id: 'route_alice_1', + ownerUid: 'alice', + title: 'Skyline Boulevard Loop', + description: 'Twisties through the redwoods', + visibility: 'private', + stops: [ + { + id: 'stop-1', + latitude: 37.4, + longitude: -122.2, + displayName: 'Alice\'s Restaurant', + stopType: 'origin', + orderIndex: 0, + }, + { + id: 'stop-2', + latitude: 37.3, + longitude: -122.1, + displayName: 'Page Mill Vista', + stopType: 'destination', + orderIndex: 1, + }, + ], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + })); + + // 2. Read own route: SUCCEEDS + await assertSucceeds(getDoc(routeRef)); + + // 3. Update own route: SUCCEEDS + await assertSucceeds(updateDoc(routeRef, { + title: 'Skyline Boulevard Loop Extended', + updatedAt: '2026-09-04T12:30:00Z', + })); + + // 4. Delete own route: SUCCEEDS + await assertSucceeds(deleteDoc(routeRef)); + }); + + test('validates route constraints: title length and max stops', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + + // Title too short (<3 chars): FAILS + await assertFails(setDoc(doc(alice, 'routes/route_short_title'), { + id: 'route_short_title', + ownerUid: 'alice', + title: 'AB', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + })); + + // Stops exceed 25 cap: FAILS + const excessiveStops = []; + for (let i = 0; i < 26; i++) { + excessiveStops.push({ + id: `stop-${i}`, + latitude: 37.0 + i * 0.01, + longitude: -122.0, + displayName: `Stop ${i}`, + stopType: 'waypoint', + orderIndex: i, + }); + } + await assertFails(setDoc(doc(alice, 'routes/route_too_many_stops'), { + id: 'route_too_many_stops', + ownerUid: 'alice', + title: 'Way Too Many Stops Route', + stops: excessiveStops, + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + })); + }); + + test('private route cannot be read or modified by non-owner', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'routes/route_private_alice'), { + id: 'route_private_alice', + ownerUid: 'alice', + title: 'Secret Canyon Route', + visibility: 'private', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + }); + + const bob = testEnv.authenticatedContext('bob').firestore(); + const targetRoute = doc(bob, 'routes/route_private_alice'); + + // Bob cannot read private route + await assertFails(getDoc(targetRoute)); + // Bob cannot update private route + await assertFails(updateDoc(targetRoute, { title: 'Hacked Title' })); + // Bob cannot delete private route + await assertFails(deleteDoc(targetRoute)); + }); + + test('public route can be read by any signed-in user, but only modified by owner', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'routes/route_public_alice'), { + id: 'route_public_alice', + ownerUid: 'alice', + title: 'Public Coastal Highway 1', + visibility: 'public', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + }); + + const bob = testEnv.authenticatedContext('bob').firestore(); + const targetRoute = doc(bob, 'routes/route_public_alice'); + + // Bob can read public route + await assertSucceeds(getDoc(targetRoute)); + // Bob cannot update public route + await assertFails(updateDoc(targetRoute, { title: 'Bob Was Here' })); + }); + + test('groupOnly route can be read by accepted group members, but not by outsiders', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_for_route'), { + id: 'ride_for_route', + organizerUid: 'alice', + title: 'Sunday Group Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 2, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + await setDoc(doc(db, 'group_rides/ride_for_route/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + riderHandle: 'bob', + riderDisplayName: 'Bob', + }); + await setDoc(doc(db, 'routes/route_group_only'), { + id: 'route_group_only', + ownerUid: 'alice', + title: 'Group Official Route', + visibility: 'groupOnly', + attachedGroupRideId: 'ride_for_route', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + }); + + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + // Accepted member Bob CAN read groupOnly route + await assertSucceeds(getDoc(doc(bob, 'routes/route_group_only'))); + + // Non-member Charlie CANNOT read groupOnly route + await assertFails(getDoc(doc(charlie, 'routes/route_group_only'))); + }); + + test('group ride route attachment authority: only organizer can attach or detach route', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_attach_authority'), { + id: 'ride_attach_authority', + organizerUid: 'alice', + title: 'Route Authority Group Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 2, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + await setDoc(doc(db, 'group_rides/ride_attach_authority/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + riderHandle: 'bob', + riderDisplayName: 'Bob', + }); + }); + + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const rideDocRef = (db) => doc(db, 'group_rides/ride_attach_authority'); + + // 1. Participant Bob attempts to attach a route: MUST FAIL + await assertFails(updateDoc(rideDocRef(bob), { + routeId: 'route_bob_custom', + routeTitle: 'Bob\'s Route', + routeDistanceMeters: 45000, + routeStopCount: 4, + })); + + // 2. Organizer Alice attaches a route: SUCCEEDS + await assertSucceeds(updateDoc(rideDocRef(alice), { + routeId: 'route_official_alice', + routeTitle: 'Alice\'s Official Route', + routeDistanceMeters: 52000, + routeStopCount: 5, + })); + + // 3. Participant Bob attempts to detach or change route: MUST FAIL + await assertFails(updateDoc(rideDocRef(bob), { + routeId: null, + })); + + // 4. Organizer Alice detaches route: SUCCEEDS + await assertSucceeds(updateDoc(rideDocRef(alice), { + routeId: null, + routeTitle: null, + routeDistanceMeters: null, + routeStopCount: null, + })); + }); + }); }); diff --git a/firestore.rules b/firestore.rules index f78f416..c513971 100644 --- a/firestore.rules +++ b/firestore.rules @@ -360,8 +360,61 @@ service cloud.firestore { } } + // --- ROUTES --- + // User-created motorcycle routes (standalone or attached to group rides). + match /routes/{routeId} { + function isRouteOwner() { + return signedIn() && resource.data.ownerUid == request.auth.uid; + } + + function canReadRoute() { + return signedIn() && ( + isRouteOwner() || + ( + !areMutuallyBlocked(request.auth.uid, resource.data.ownerUid) && ( + resource.data.visibility == 'public' || + ( + resource.data.visibility == 'groupOnly' && + resource.data.attachedGroupRideId != null && + exists(/databases/$(database)/documents/group_rides/$(resource.data.attachedGroupRideId)/participants/$(request.auth.uid)) + ) + ) + ) + ); + } + + function validRouteCreate() { + return signedIn() + && request.resource.data.id == routeId + && request.resource.data.ownerUid == request.auth.uid + && request.resource.data.title is string + && request.resource.data.title.size() >= 3 + && request.resource.data.title.size() <= 80 + && request.resource.data.stops is list + && request.resource.data.stops.size() <= 25 + && request.resource.data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']); + } + + function validRouteUpdate() { + return isRouteOwner() + && request.resource.data.id == resource.data.id + && request.resource.data.ownerUid == resource.data.ownerUid + && request.resource.data.title is string + && request.resource.data.title.size() >= 3 + && request.resource.data.title.size() <= 80 + && request.resource.data.stops is list + && request.resource.data.stops.size() <= 25; + } + + allow read: if canReadRoute(); + allow create: if validRouteCreate(); + allow update: if validRouteUpdate(); + allow delete: if isRouteOwner(); + } + match /{document=**} { allow read, write: if false; } } } + diff --git a/lib/core/router.dart b/lib/core/router.dart index b1e0e47..83f9af6 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -23,6 +23,8 @@ import '../features/group_rides/group_rides_hub_screen.dart'; import '../features/group_rides/create_group_ride_screen.dart'; import '../features/group_rides/group_ride_detail_screen.dart'; import '../features/group_rides/live_group_ride_screen.dart'; +import '../features/routes/route_planner_screen.dart'; +import '../features/routes/route_detail_screen.dart'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -195,5 +197,23 @@ final appRouter = GoRouter( ), ], ), + GoRoute( + path: '/routes/planner', + builder: (context, state) { + final initialRouteId = state.uri.queryParameters['routeId']; + final attachToGroupRideId = state.uri.queryParameters['groupRideId']; + return RoutePlannerScreen( + initialRouteId: initialRouteId, + attachToGroupRideId: attachToGroupRideId, + ); + }, + ), + GoRoute( + path: '/routes/:id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return RouteDetailScreen(routeId: id); + }, + ), ], ); diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart index e2c9155..7576964 100644 --- a/lib/features/group_rides/create_group_ride_screen.dart +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -4,6 +4,9 @@ import 'package:go_router/go_router.dart'; import '../../services/group_rides/group_ride_models.dart'; import '../../services/group_rides/group_ride_providers.dart'; import '../../services/social/social_providers.dart'; +import '../../services/auth/auth_providers.dart'; +import '../../services/routes/route_models.dart'; +import '../../services/routes/route_providers.dart'; class CreateGroupRideScreen extends ConsumerStatefulWidget { const CreateGroupRideScreen({super.key}); @@ -27,15 +30,21 @@ class _CreateGroupRideScreenState extends ConsumerState { final _meetupNameController = TextEditingController(); final _meetupAddressController = TextEditingController(); final _meetupNotesController = TextEditingController(); - final double _meetupLat = 34.5378; - final double _meetupLng = -83.9849; + double _meetupLat = 34.5378; + double _meetupLng = -83.9849; // Destination bool _hasDestination = true; final _destNameController = TextEditingController(); final _destAddressController = TextEditingController(); - final double _destLat = 34.8826; - final double _destLng = -83.9281; + double _destLat = 34.8826; + double _destLng = -83.9281; + + // Attached Route info + String? _selectedRouteId; + String? _selectedRouteTitle; + double? _selectedRouteDistanceMeters; + int? _selectedRouteStopCount; // Intermediate stops final List _stops = []; @@ -119,6 +128,10 @@ class _CreateGroupRideScreenState extends ConsumerState { meetingPoint: meetingPoint, destination: destination, stops: _stops, + routeId: _selectedRouteId, + routeTitle: _selectedRouteTitle, + routeDistanceMeters: _selectedRouteDistanceMeters, + routeStopCount: _selectedRouteStopCount, ); if (mounted) { @@ -135,6 +148,156 @@ class _CreateGroupRideScreenState extends ConsumerState { } } + void _applySavedRoute(MotorSignalRoute route) { + setState(() { + _selectedRouteId = route.id; + _selectedRouteTitle = route.title; + _selectedRouteDistanceMeters = route.totalDistanceMeters; + _selectedRouteStopCount = route.stops.length; + + if (_titleController.text.trim().isEmpty) { + _titleController.text = route.title; + } + if (_descriptionController.text.trim().isEmpty && route.description.isNotEmpty) { + _descriptionController.text = route.description; + } + + if (route.stops.isNotEmpty) { + final origin = route.stops.first; + _meetupNameController.text = origin.displayName; + _meetupLat = origin.latitude; + _meetupLng = origin.longitude; + if (origin.notes.isNotEmpty) { + _meetupNotesController.text = origin.notes; + } + + if (route.stops.length > 1) { + final dest = route.stops.last; + _hasDestination = true; + _destNameController.text = dest.displayName; + _destLat = dest.latitude; + _destLng = dest.longitude; + if (dest.notes.isNotEmpty) { + _destAddressController.text = dest.notes; + } + + _stops.clear(); + if (route.stops.length > 2) { + for (int i = 1; i < route.stops.length - 1; i++) { + final s = route.stops[i]; + _stops.add( + GroupRideLocation( + latitude: s.latitude, + longitude: s.longitude, + displayName: s.displayName, + notes: s.notes, + locationType: GroupRideLocationType.waypoint, + ), + ); + } + } + } + } + }); + } + + void _showSelectRouteSheet() { + final user = ref.read(currentUserProvider); + if (user == null) return; + + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF13151A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) { + return Consumer( + builder: (context, ref, _) { + final routesAsync = ref.watch(userRoutesProvider(user.uid)); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'IMPORT SAVED ROUTE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + const Text( + 'Pre-fills title, meetup location, intermediate waypoints, and destination.', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + const SizedBox(height: 12), + routesAsync.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + ), + ), + error: (err, _) => Text( + 'Failed to load routes: $err', + style: const TextStyle(color: Colors.redAccent), + ), + data: (routes) { + if (routes.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'No saved routes found.', + style: TextStyle(color: Colors.white54), + ), + ), + ); + } + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: routes.length, + itemBuilder: (ctx, idx) { + final route = routes[idx]; + return ListTile( + leading: const Icon(Icons.alt_route, color: Color(0xFF00E5FF)), + title: Text( + route.title, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${route.stops.length} stops • ${route.formattedDistanceMiles}', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + onTap: () { + Navigator.pop(sheetCtx); + _applySavedRoute(route); + }, + ); + }, + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + void _addStopDialog() { final nameCtrl = TextEditingController(); final notesCtrl = TextEditingController(); @@ -239,6 +402,11 @@ class _CreateGroupRideScreenState extends ConsumerState { ), ), actions: [ + IconButton( + icon: const Icon(Icons.alt_route, color: Color(0xFF00E5FF)), + tooltip: 'Import Route', + onPressed: _showSelectRouteSheet, + ), TextButton( onPressed: _isSaving ? null : _submit, child: _isSaving @@ -477,6 +645,78 @@ class _CreateGroupRideScreenState extends ConsumerState { ], const SizedBox(height: 16), + // Route Pre-fill / Attachment + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF161922), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Row( + children: [ + const Icon(Icons.alt_route, color: Color(0xFF00E5FF), size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _selectedRouteTitle != null + ? 'Route: $_selectedRouteTitle' + : 'Plan with Saved Route', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + _selectedRouteTitle != null + ? '$_selectedRouteStopCount stops • ${(_selectedRouteDistanceMeters != null ? (_selectedRouteDistanceMeters! / 1609.34).toStringAsFixed(1) : 0)} mi' + : 'Optional: import stops & coordinates', + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + ), + if (_selectedRouteTitle != null) + IconButton( + icon: const Icon(Icons.close, color: Colors.white54, size: 18), + tooltip: 'Clear route', + onPressed: () { + setState(() { + _selectedRouteId = null; + _selectedRouteTitle = null; + _selectedRouteDistanceMeters = null; + _selectedRouteStopCount = null; + }); + }, + ) + else + OutlinedButton( + onPressed: _showSelectRouteSheet, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF00E5FF)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'IMPORT', + style: TextStyle( + color: Color(0xFF00E5FF), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index 7628e11..73dd92e 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -6,6 +6,7 @@ import '../../services/auth/auth_providers.dart'; import '../../services/group_rides/group_ride_models.dart'; import '../../services/group_rides/group_ride_providers.dart'; import '../../services/social/social_providers.dart'; +import '../../services/routes/route_providers.dart'; class GroupRideDetailScreen extends ConsumerStatefulWidget { final String rideId; @@ -238,6 +239,10 @@ class _GroupRideDetailScreenState extends ConsumerState { _buildLocationsCard(ride), const SizedBox(height: 16), + // Route Card + _buildRouteCard(context, ride, isOrganizer, currentUid), + const SizedBox(height: 16), + // Intermediate stops if (ride.stops.isNotEmpty) ...[ _buildStopsCard(ride), @@ -837,6 +842,300 @@ class _GroupRideDetailScreenState extends ConsumerState { ); } + Widget _buildRouteCard( + BuildContext context, + GroupRide ride, + bool isOrganizer, + String? currentUid, + ) { + final hasRoute = ride.routeId != null; + + return Card( + color: const Color(0xFF161922), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.alt_route, color: Color(0xFF00E5FF), size: 18), + SizedBox(width: 8), + Text( + 'ROUTE & WAYPOINTS', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + ), + ], + ), + if (hasRoute) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${ride.routeStopCount ?? 0} STOPS', + style: const TextStyle( + color: Color(0xFF00E5FF), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + if (hasRoute) ...[ + Text( + ride.routeTitle ?? 'Attached Route', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + if (ride.routeDistanceMeters != null) ...[ + Text( + '${(ride.routeDistanceMeters! / 1609.34).toStringAsFixed(1)} mi straight-line preview', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + const SizedBox(height: 12), + ], + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: () => context.push('/routes/${ride.routeId}'), + icon: const Icon(Icons.map, size: 16), + label: const Text('VIEW ROUTE / PLANNER'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ + OutlinedButton.icon( + onPressed: () => _showAttachRouteSheet(context, ride), + icon: const Icon(Icons.swap_horiz, size: 16, color: Color(0xFF00E5FF)), + label: const Text('CHANGE', style: TextStyle(color: Color(0xFF00E5FF))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF2A2E3D)), + textStyle: const TextStyle(fontSize: 12), + ), + ), + OutlinedButton.icon( + onPressed: () async { + try { + await ref.read(routeServiceProvider).detachRouteFromGroupRide(ride.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Route detached from group ride.')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to detach route: $e')), + ); + } + } + }, + icon: const Icon(Icons.link_off, size: 16, color: Color(0xFFFF5252)), + label: const Text('DETACH', style: TextStyle(color: Color(0xFFFF5252))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFFF5252)), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ], + ), + ] else ...[ + const Text( + 'No custom route attached yet.', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + const SizedBox(height: 4), + const Text( + 'Attach a multi-stop route with straight-line map preview and navigation handoff.', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: () => context.push('/routes/planner?groupRideId=${ride.id}'), + icon: const Icon(Icons.add_road, size: 16), + label: const Text('PLAN NEW ROUTE'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + OutlinedButton.icon( + onPressed: () => _showAttachRouteSheet(context, ride), + icon: const Icon(Icons.link, size: 16, color: Color(0xFF00E5FF)), + label: const Text('ATTACH SAVED ROUTE', style: TextStyle(color: Color(0xFF00E5FF))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF2A2E3D)), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ], + ], + ], + ), + ), + ); + } + + void _showAttachRouteSheet(BuildContext context, GroupRide ride) { + final user = ref.read(currentUserProvider); + if (user == null) return; + + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF13151A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) { + return Consumer( + builder: (context, ref, _) { + final routesAsync = ref.watch(userRoutesProvider(user.uid)); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'ATTACH SAVED ROUTE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 12), + routesAsync.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + ), + ), + error: (err, _) => Text( + 'Failed to load routes: $err', + style: const TextStyle(color: Colors.redAccent), + ), + data: (routes) { + if (routes.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Column( + children: [ + const Text( + 'You have no saved routes yet.', + style: TextStyle(color: Colors.white54), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () { + Navigator.pop(sheetCtx); + context.push('/routes/planner?groupRideId=${ride.id}'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + child: const Text('CREATE A ROUTE'), + ), + ], + ), + ), + ); + } + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.4, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: routes.length, + itemBuilder: (ctx, idx) { + final route = routes[idx]; + return ListTile( + leading: const Icon(Icons.alt_route, color: Color(0xFF00E5FF)), + title: Text( + route.title, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${route.stops.length} stops • ${route.formattedDistanceMiles}', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + trailing: const Icon(Icons.chevron_right, color: Colors.white30), + onTap: () async { + Navigator.pop(sheetCtx); + try { + await ref.read(routeServiceProvider).attachRouteToGroupRide( + groupRideId: ride.id, + route: route, + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Attached "${route.title}" to group ride.'), + backgroundColor: const Color(0xFF00E676), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to attach route: $e')), + ); + } + } + }, + ); + }, + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + Widget _buildStopsCard(GroupRide ride) { return Card( color: const Color(0xFF161922), diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart index 2180980..c9c33ae 100644 --- a/lib/features/group_rides/live_group_ride_screen.dart +++ b/lib/features/group_rides/live_group_ride_screen.dart @@ -8,6 +8,9 @@ import '../../services/group_rides/group_ride_providers.dart'; import '../../services/group_rides/live_broadcast_controller.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/unified_map_view.dart'; +import '../../services/routes/route_models.dart'; +import '../../services/routes/route_providers.dart'; +import '../../services/routes/navigation_handoff.dart'; class LiveGroupRideScreen extends ConsumerStatefulWidget { final String rideId; @@ -174,13 +177,34 @@ class _LiveGroupRideScreenState extends ConsumerState meetup: ride.meetingPoint, ); + // Watch attached route if present + final attachedRoute = ride.routeId != null + ? ref.watch(routeByIdProvider(ride.routeId!)).valueOrNull + : null; + + final polylines = []; + if (attachedRoute != null && attachedRoute.stops.length >= 2) { + polylines.add( + AppPolyline( + id: 'attached_route_path', + points: attachedRoute.previewPoints + .map((p) => AppLatLng(p.latitude, p.longitude)) + .toList(), + color: const Color(0xFF00E5FF).withValues(alpha: 0.85), + width: 3.5, + ), + ); + } + // Build markers final markers = _buildMarkers( + context: context, ride: ride, participants: acceptedParticipants, presences: presences, currentUid: currentUid, leadUid: leadParticipant?.userId, + attachedRoute: attachedRoute, ); return Scaffold( @@ -202,6 +226,7 @@ class _LiveGroupRideScreenState extends ConsumerState center: center, zoom: _mapZoom, markers: markers, + polylines: polylines, onPositionChanged: (pos) { _customMapCenter = pos; }, @@ -1027,11 +1052,13 @@ class _LiveGroupRideScreenState extends ConsumerState } List _buildMarkers({ + required BuildContext context, required GroupRide ride, required List participants, required List presences, required String? currentUid, required String? leadUid, + MotorSignalRoute? attachedRoute, }) { final markers = []; @@ -1054,37 +1081,91 @@ class _LiveGroupRideScreenState extends ConsumerState ), child: const Icon(Icons.flag, color: Colors.black, size: 16), ), + onTap: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: ride.meetingPoint.latitude, + longitude: ride.meetingPoint.longitude, + displayName: ride.meetingPoint.displayName, + ); + }, ), ); - // 2. Intermediate Stop Markers - for (int i = 0; i < ride.stops.length; i++) { - final stop = ride.stops[i]; - markers.add( - AppMapMarker( - id: 'stop_$i', - position: AppLatLng(stop.latitude, stop.longitude), - label: stop.displayName.isNotEmpty ? stop.displayName : 'Stop ${i + 1}', - iconWidget: Container( - padding: const EdgeInsets.all(5), - decoration: const BoxDecoration( - color: Colors.blueAccent, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)), - ], - ), - child: Text( - '${i + 1}', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 10, + // 2. Intermediate Stop Markers (from ride.stops or attachedRoute.stops) + if (attachedRoute != null && attachedRoute.stops.isNotEmpty) { + for (final stop in attachedRoute.stops) { + markers.add( + AppMapMarker( + id: 'route_stop_${stop.id}', + position: AppLatLng(stop.latitude, stop.longitude), + label: stop.displayName, + iconWidget: Container( + padding: const EdgeInsets.all(5), + decoration: const BoxDecoration( + color: Color(0xFF00E5FF), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: Text( + '${stop.orderIndex + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 10, + ), ), ), + onTap: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, ), - ), - ); + ); + } + } else { + for (int i = 0; i < ride.stops.length; i++) { + final stop = ride.stops[i]; + markers.add( + AppMapMarker( + id: 'stop_$i', + position: AppLatLng(stop.latitude, stop.longitude), + label: stop.displayName.isNotEmpty ? stop.displayName : 'Stop ${i + 1}', + iconWidget: Container( + padding: const EdgeInsets.all(5), + decoration: const BoxDecoration( + color: Colors.blueAccent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: Text( + '${i + 1}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ), + onTap: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + ), + ); + } } // 3. Destination Marker diff --git a/lib/features/routes/route_detail_screen.dart b/lib/features/routes/route_detail_screen.dart new file mode 100644 index 0000000..dc1ca60 --- /dev/null +++ b/lib/features/routes/route_detail_screen.dart @@ -0,0 +1,597 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../services/auth/auth_providers.dart'; +import '../../services/map/map_provider_interface.dart'; +import '../../services/map/unified_map_view.dart'; +import '../../services/routes/navigation_handoff.dart'; +import '../../services/routes/route_models.dart'; +import '../../services/routes/route_providers.dart'; + +class RouteDetailScreen extends ConsumerStatefulWidget { + final String routeId; + + const RouteDetailScreen({super.key, required this.routeId}); + + @override + ConsumerState createState() => _RouteDetailScreenState(); +} + +class _RouteDetailScreenState extends ConsumerState { + bool _isDeleting = false; + + Future _confirmDelete(MotorSignalRoute route) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF13151A), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFF2A2E3D)), + ), + title: const Text('Delete Route', style: TextStyle(color: Colors.white)), + content: Text( + 'Are you sure you want to delete "${route.title}"? This cannot be undone.', + style: const TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('CANCEL', style: TextStyle(color: Colors.white54)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF5252), + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('DELETE'), + ), + ], + ), + ); + + if (confirm != true || !mounted) return; + + setState(() => _isDeleting = true); + try { + final service = ref.read(routeServiceProvider); + await service.deleteRoute(widget.routeId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Route deleted.')), + ); + context.pop(); + } + } catch (e) { + if (mounted) { + setState(() => _isDeleting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to delete route: $e')), + ); + } + } + } + + IconData _getIconForStopType(RouteStopType type) { + switch (type) { + case RouteStopType.start: + case RouteStopType.origin: + return Icons.play_circle_fill; + case RouteStopType.meetup: + return Icons.people; + case RouteStopType.waypoint: + return Icons.place; + case RouteStopType.fuel: + return Icons.local_gas_station; + case RouteStopType.rest: + return Icons.hotel; + case RouteStopType.food: + return Icons.restaurant; + case RouteStopType.scenic: + return Icons.photo_camera; + case RouteStopType.regroup: + return Icons.group; + case RouteStopType.destination: + return Icons.flag; + case RouteStopType.other: + return Icons.location_on; + } + } + + @override + Widget build(BuildContext context) { + final routeAsync = ref.watch(routeByIdProvider(widget.routeId)); + final currentUser = ref.watch(currentUserProvider); + + return Scaffold( + backgroundColor: const Color(0xFF0D0E11), + appBar: AppBar( + backgroundColor: const Color(0xFF13151A), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.pop(), + ), + title: const Text( + 'ROUTE DETAILS', + style: TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + fontSize: 16, + ), + ), + ), + body: routeAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + ), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + size: 48, color: Color(0xFFFF5252)), + const SizedBox(height: 12), + Text( + 'Could not load route: $err', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ), + ), + data: (route) { + if (route == null) { + return const Center( + child: Text( + 'Route not found or has been deleted.', + style: TextStyle(color: Colors.white54), + ), + ); + } + + final isOwner = currentUser?.uid == route.ownerUid; + + // Map markers + final markers = route.stops.map((stop) { + final isFirst = stop.orderIndex == 0; + final isLast = + stop.orderIndex == route.stops.length - 1 && route.stops.length > 1; + + return AppMapMarker( + id: stop.id, + position: AppLatLng(stop.latitude, stop.longitude), + label: stop.displayName, + iconWidget: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isFirst + ? const Color(0xFF00E676) + : isLast + ? const Color(0xFFFF5252) + : const Color(0xFF00E5FF), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black54, blurRadius: 4, spreadRadius: 1), + ], + ), + child: Text( + '${stop.orderIndex + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + onTap: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + ); + }).toList(); + + // Polylines + final polylines = route.stops.length >= 2 + ? [ + AppPolyline( + id: 'route_straight_line', + points: route.previewPoints + .map((p) => AppLatLng(p.latitude, p.longitude)) + .toList(), + color: const Color(0xFF00E5FF), + width: 3.5, + ), + ] + : []; + + final mapCenter = route.stops.isNotEmpty + ? AppLatLng( + route.stops.first.latitude, route.stops.first.longitude) + : const AppLatLng(37.7749, -122.4194); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Map Preview + SizedBox( + height: 240, + width: double.infinity, + child: Stack( + children: [ + UnifiedMapView( + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + Positioned( + top: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF13151A).withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Text( + route.visibility.label, + style: const TextStyle( + color: Color(0xFF00E5FF), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + + // Title & Info + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + route.title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + if (route.description.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + route.description, + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + const SizedBox(height: 12), + + // Distance and Stops Badges + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF13151A), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'STRAIGHT-LINE DISTANCE', + style: TextStyle( + color: Colors.white38, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 2), + Text( + '${route.formattedDistanceMiles} (${route.formattedDistanceKm})', + style: const TextStyle( + color: Color(0xFF00E5FF), + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Container( + width: 1, + height: 32, + color: const Color(0xFF2A2E3D), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'WAYPOINTS', + style: TextStyle( + color: Colors.white38, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 2), + Text( + '${route.stops.length} STOPS', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 8), + + // Honest Disclaimer + const Text( + 'MotorSignal provides straight-line route planning and handoff. Turn-by-turn navigation opens in Google Maps or Waze.', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + const SizedBox(height: 16), + + // Owner Actions: Edit & Delete + if (isOwner) ...[ + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + context.push('/routes/planner?routeId=${route.id}'); + }, + icon: const Icon(Icons.edit, + size: 16, color: Color(0xFF00E5FF)), + label: const Text('EDIT ROUTE', + style: TextStyle(color: Color(0xFF00E5FF))), + style: OutlinedButton.styleFrom( + side: const BorderSide( + color: Color(0xFF00E5FF)), + padding: const EdgeInsets.symmetric( + vertical: 10), + ), + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _isDeleting + ? null + : () => _confirmDelete(route), + icon: const Icon(Icons.delete_outline, + size: 16, color: Color(0xFFFF5252)), + label: const Text('DELETE', + style: TextStyle(color: Color(0xFFFF5252))), + style: OutlinedButton.styleFrom( + side: const BorderSide( + color: Color(0xFFFF5252)), + padding: const EdgeInsets.symmetric( + vertical: 10, horizontal: 16), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + + // Attached Group Ride button if any + if (route.attachedGroupRideId != null) ...[ + OutlinedButton.icon( + onPressed: () { + context.push( + '/group-rides/${route.attachedGroupRideId}'); + }, + icon: const Icon(Icons.group, + size: 18, color: Color(0xFF00E5FF)), + label: const Text('VIEW ATTACHED GROUP RIDE', + style: TextStyle(color: Color(0xFF00E5FF))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF2A2E3D)), + minimumSize: const Size(double.infinity, 44), + ), + ), + const SizedBox(height: 16), + ], + + // Stops List Header + const Text( + 'STOPS & WAYPOINTS', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ), + const SizedBox(height: 10), + + // Stops List + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: route.stops.length, + itemBuilder: (context, index) { + final stop = route.stops[index]; + final isFirst = index == 0; + final isLast = index == route.stops.length - 1 && + route.stops.length > 1; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF13151A), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? const Color(0xFF00E676) + : isLast + ? const Color(0xFFFF5252) + : const Color(0xFF00E5FF), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_getIconForStopType(stop.stopType), + size: 14, + color: const Color(0xFF00E5FF)), + const SizedBox(width: 4), + Flexible( + child: Text( + stop.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + ), + if (stop.notes.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + stop.notes, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + ElevatedButton.icon( + onPressed: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + icon: const Icon(Icons.navigation, size: 14), + label: const Text('NAV'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 24), + + // Primary action: Navigate to First Stop + if (route.stops.isNotEmpty) + ElevatedButton.icon( + onPressed: () { + final firstStop = route.stops.first; + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: firstStop.latitude, + longitude: firstStop.longitude, + displayName: 'Start: ${firstStop.displayName}', + ); + }, + icon: const Icon(Icons.directions, size: 20), + label: const Text( + 'NAVIGATE TO ROUTE START', + style: TextStyle( + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart new file mode 100644 index 0000000..5a05730 --- /dev/null +++ b/lib/features/routes/route_planner_screen.dart @@ -0,0 +1,1020 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:go_router/go_router.dart'; +import '../../services/auth/auth_providers.dart'; +import '../../services/entitlements/entitlement_providers.dart'; +import '../../services/map/map_provider_interface.dart'; +import '../../services/map/unified_map_view.dart'; +import '../../services/routes/navigation_handoff.dart'; +import '../../services/routes/route_models.dart'; +import '../../services/routes/route_providers.dart'; +import '../../services/routes/route_service.dart'; + +class RoutePlannerScreen extends ConsumerStatefulWidget { + final String? initialRouteId; + final String? attachToGroupRideId; + + const RoutePlannerScreen({ + super.key, + this.initialRouteId, + this.attachToGroupRideId, + }); + + @override + ConsumerState createState() => _RoutePlannerScreenState(); +} + +class _RoutePlannerScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + RouteVisibility _visibility = RouteVisibility.private; + bool _initialized = false; + bool _isSaving = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadInitialData(); + }); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + Future _loadInitialData() async { + if (widget.initialRouteId != null) { + final service = ref.read(routeServiceProvider); + final route = await service.getRoute(widget.initialRouteId!); + if (route != null && mounted) { + ref.read(routePlannerNotifierProvider.notifier).loadRoute(route); + _titleController.text = route.title; + _descController.text = route.description; + setState(() { + _visibility = route.visibility; + _initialized = true; + }); + return; + } + } else { + ref.read(routePlannerNotifierProvider.notifier).reset(); + if (widget.attachToGroupRideId != null) { + setState(() { + _visibility = RouteVisibility.groupOnly; + }); + } + } + setState(() { + _initialized = true; + }); + } + + Future _addCurrentLocationStop() async { + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 10), + ), + ); + if (!mounted) return; + _showAddOrEditStopDialog( + initialLat: position.latitude, + initialLng: position.longitude, + defaultName: 'My Current Location', + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not get GPS location: $e'), + backgroundColor: Colors.redAccent, + ), + ); + } + } + + void _showAddOrEditStopDialog({ + RouteStop? existingStop, + double? initialLat, + double? initialLng, + String? defaultName, + }) { + final state = ref.read(routePlannerNotifierProvider); + final isPro = ref.read(isProSubscriberProvider); + final maxAllowed = isPro ? RouteService.maxTechnicalStops : RouteService.freeStopCap; + + if (existingStop == null && state.stops.length >= maxAllowed) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + isPro + ? 'Maximum of $maxAllowed stops reached.' + : 'Free tier limit reached ($maxAllowed stops). Upgrade to Pro for up to 25 stops.', + ), + action: !isPro + ? SnackBarAction( + label: 'UPGRADE', + textColor: const Color(0xFF00E5FF), + onPressed: () => context.push('/settings/pro'), + ) + : null, + backgroundColor: const Color(0xFF1B1E26), + ), + ); + return; + } + + final nameCtrl = TextEditingController( + text: existingStop?.displayName ?? defaultName ?? '', + ); + final latCtrl = TextEditingController( + text: existingStop != null + ? existingStop.latitude.toStringAsFixed(6) + : (initialLat?.toStringAsFixed(6) ?? ''), + ); + final lngCtrl = TextEditingController( + text: existingStop != null + ? existingStop.longitude.toStringAsFixed(6) + : (initialLng?.toStringAsFixed(6) ?? ''), + ); + final notesCtrl = TextEditingController(text: existingStop?.notes ?? ''); + RouteStopType selectedType = existingStop?.stopType ?? + (state.stops.isEmpty + ? RouteStopType.origin + : RouteStopType.waypoint); + + showDialog( + context: context, + builder: (dialogCtx) => StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + backgroundColor: const Color(0xFF13151A), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFF2A2E3D)), + ), + title: Text( + existingStop != null ? 'Edit Stop' : 'Add Route Stop', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameCtrl, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Stop Name *', + labelStyle: TextStyle(color: Colors.white60), + hintText: 'e.g. Skyline Summit, Fuel Stop', + hintStyle: TextStyle(color: Colors.white24), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF2A2E3D)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF00E5FF)), + ), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedType, + isExpanded: true, + dropdownColor: const Color(0xFF1B1E26), + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Stop Type', + labelStyle: TextStyle(color: Colors.white60), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF2A2E3D)), + ), + ), + items: RouteStopType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Row( + children: [ + Icon(_getIconForStopType(type), + color: const Color(0xFF00E5FF), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + type.label, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setDialogState(() => selectedType = val); + } + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: latCtrl, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: const InputDecoration( + labelText: 'Latitude *', + labelStyle: TextStyle(color: Colors.white60), + hintText: '37.421998', + hintStyle: TextStyle(color: Colors.white24), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: lngCtrl, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: const InputDecoration( + labelText: 'Longitude *', + labelStyle: TextStyle(color: Colors.white60), + hintText: '-122.084000', + hintStyle: TextStyle(color: Colors.white24), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: notesCtrl, + style: const TextStyle(color: Colors.white), + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Notes (Optional)', + labelStyle: TextStyle(color: Colors.white60), + hintText: 'Regroup point, scenic view, cautions...', + hintStyle: TextStyle(color: Colors.white24), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx), + child: const Text('CANCEL', style: TextStyle(color: Colors.white54)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + onPressed: () { + final name = nameCtrl.text.trim(); + final lat = double.tryParse(latCtrl.text.trim()); + final lng = double.tryParse(lngCtrl.text.trim()); + + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please provide a stop name.')), + ); + return; + } + if (lat == null || lat < -90.0 || lat > 90.0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invalid latitude (-90 to 90).')), + ); + return; + } + if (lng == null || lng < -180.0 || lng > 180.0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invalid longitude (-180 to 180).')), + ); + return; + } + + final notifier = ref.read(routePlannerNotifierProvider.notifier); + if (existingStop != null) { + notifier.updateStop( + existingStop.copyWith( + displayName: name, + latitude: lat, + longitude: lng, + stopType: selectedType, + notes: notesCtrl.text.trim(), + ), + ); + } else { + notifier.addStop( + displayName: name, + latitude: lat, + longitude: lng, + stopType: selectedType, + notes: notesCtrl.text.trim(), + ); + } + Navigator.pop(dialogCtx); + }, + child: Text(existingStop != null ? 'SAVE' : 'ADD'), + ), + ], + ); + }, + ), + ); + } + + Future _saveRoute() async { + final title = _titleController.text.trim(); + if (title.length < 3 || title.length > 80) { + setState(() { + _errorMessage = 'Route title must be between 3 and 80 characters.'; + }); + return; + } + + final user = ref.read(currentUserProvider); + if (user == null) { + setState(() { + _errorMessage = 'You must be signed in to save a route.'; + }); + return; + } + + final isPro = ref.read(isProSubscriberProvider); + final state = ref.read(routePlannerNotifierProvider); + final maxAllowed = isPro ? RouteService.maxTechnicalStops : RouteService.freeStopCap; + + if (state.stops.length > maxAllowed) { + setState(() { + _errorMessage = isPro + ? 'Cannot exceed $maxAllowed stops.' + : 'Free tier limit is $maxAllowed stops. Upgrade to Pro for more.'; + }); + return; + } + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + final notifier = ref.read(routePlannerNotifierProvider.notifier); + final savedRoute = await notifier.saveRoute( + title: title, + description: _descController.text.trim(), + ownerUid: user.uid, + visibility: _visibility, + attachedGroupRideId: widget.attachToGroupRideId, + ); + + // If attached to a group ride, update the group ride with route reference + if (widget.attachToGroupRideId != null) { + final service = ref.read(routeServiceProvider); + await service.attachRouteToGroupRide( + groupRideId: widget.attachToGroupRideId!, + route: savedRoute, + ); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Route "${savedRoute.title}" saved successfully!'), + backgroundColor: const Color(0xFF00E676), + ), + ); + context.pop(savedRoute); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Failed to save route: $e'; + _isSaving = false; + }); + } + } + } + + IconData _getIconForStopType(RouteStopType type) { + switch (type) { + case RouteStopType.start: + case RouteStopType.origin: + return Icons.play_circle_fill; + case RouteStopType.meetup: + return Icons.people; + case RouteStopType.waypoint: + return Icons.place; + case RouteStopType.fuel: + return Icons.local_gas_station; + case RouteStopType.rest: + return Icons.hotel; + case RouteStopType.food: + return Icons.restaurant; + case RouteStopType.scenic: + return Icons.photo_camera; + case RouteStopType.regroup: + return Icons.group; + case RouteStopType.destination: + return Icons.flag; + case RouteStopType.other: + return Icons.location_on; + } + } + + @override + Widget build(BuildContext context) { + if (!_initialized) { + return const Scaffold( + backgroundColor: Color(0xFF0D0E11), + body: Center(child: CircularProgressIndicator(color: Color(0xFF00E5FF))), + ); + } + + final state = ref.watch(routePlannerNotifierProvider); + final isPro = ref.watch(isProSubscriberProvider); + final maxAllowed = isPro ? RouteService.maxTechnicalStops : RouteService.freeStopCap; + final isAtLimit = state.stops.length >= maxAllowed; + + // Center map on first stop or default + final mapCenter = state.stops.isNotEmpty + ? AppLatLng(state.stops.first.latitude, state.stops.first.longitude) + : const AppLatLng(37.7749, -122.4194); + + // Build markers + final markers = state.stops.map((stop) { + final isFirst = stop.orderIndex == 0; + final isLast = stop.orderIndex == state.stops.length - 1 && state.stops.length > 1; + + return AppMapMarker( + id: stop.id, + position: AppLatLng(stop.latitude, stop.longitude), + label: stop.displayName, + iconWidget: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isFirst + ? const Color(0xFF00E676) + : isLast + ? const Color(0xFFFF5252) + : const Color(0xFF00E5FF), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow(color: Colors.black54, blurRadius: 4, spreadRadius: 1), + ], + ), + child: Text( + '${stop.orderIndex + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + onTap: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + ); + }).toList(); + + // Build polylines + final polylines = state.stops.length >= 2 + ? [ + AppPolyline( + id: 'straight_line_preview', + points: state.previewPoints + .map((p) => AppLatLng(p.latitude, p.longitude)) + .toList(), + color: const Color(0xFF00E5FF), + width: 3.5, + ), + ] + : []; + + return Scaffold( + backgroundColor: const Color(0xFF0D0E11), + appBar: AppBar( + backgroundColor: const Color(0xFF13151A), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.pop(), + ), + title: Text( + widget.initialRouteId != null ? 'EDIT ROUTE' : 'ROUTE PLANNER', + style: const TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + fontSize: 16, + ), + ), + actions: [ + if (_isSaving) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF00E5FF), + ), + ), + ), + ) + else + TextButton.icon( + onPressed: _saveRoute, + icon: const Icon(Icons.save, color: Color(0xFF00E5FF), size: 18), + label: const Text( + 'SAVE', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + body: LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 360; + + return Column( + children: [ + // Error banner if any + if (_errorMessage != null) + Container( + width: double.infinity, + color: const Color(0xFFFF5252).withValues(alpha: 0.15), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const Icon(Icons.error_outline, + color: Color(0xFFFF5252), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle( + color: Color(0xFFFF5252), + fontSize: 12, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white54, size: 16), + onPressed: () => setState(() => _errorMessage = null), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + + // Map Preview Section (220dp high) + SizedBox( + height: 220, + width: double.infinity, + child: Stack( + children: [ + UnifiedMapView( + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + // Distance & Stops HUD overlay + Positioned( + top: 8, + left: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF13151A).withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + state.stops.length >= 2 + ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' + : 'Add 2+ stops for distance', + style: const TextStyle( + color: Color(0xFF00E5FF), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isAtLimit + ? const Color(0xFFFFB300).withValues(alpha: 0.2) + : const Color(0xFF00E5FF).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${state.stops.length}/$maxAllowed STOPS', + style: TextStyle( + color: isAtLimit + ? const Color(0xFFFFB300) + : const Color(0xFF00E5FF), + fontSize: 10, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ), + ), + // Straight-line disclaimer note + Positioned( + bottom: 4, + left: 8, + right: 8, + child: Text( + 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 9, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 4), + ], + ), + ), + ), + ], + ), + ), + + // Route Info Inputs (collapsible / compact) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Column( + children: [ + TextField( + controller: _titleController, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + decoration: const InputDecoration( + hintText: 'Route Title (e.g. Skyline Ridge Loop) *', + hintStyle: TextStyle(color: Colors.white30, fontSize: 14), + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8), + border: InputBorder.none, + ), + ), + Wrap( + spacing: 8, + runSpacing: 4, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + PopupMenuButton( + initialValue: _visibility, + onSelected: (v) => setState(() => _visibility = v), + color: const Color(0xFF1B1E26), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.visibility_outlined, + size: 14, color: Colors.white54), + const SizedBox(width: 4), + Text( + _visibility.label, + style: const TextStyle( + color: Color(0xFF00E5FF), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const Icon(Icons.arrow_drop_down, + size: 16, color: Color(0xFF00E5FF)), + ], + ), + ), + itemBuilder: (context) => RouteVisibility.values.map((v) { + return PopupMenuItem( + value: v, + child: Text( + v.label, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + ), + ), + ); + }).toList(), + ), + if (!isPro && isAtLimit) + TextButton( + onPressed: () => context.push('/settings/pro'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Upgrade for 25 Stops', + style: TextStyle( + color: Color(0xFFFFB300), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ), + + const Divider(color: Color(0xFF2A2E3D), height: 1), + + // Stop List Controls (Add Stop / Add GPS / Reverse) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton.icon( + onPressed: () => _showAddOrEditStopDialog(), + icon: const Icon(Icons.add_location_alt, size: 16), + label: Text(isNarrow ? 'ADD' : 'ADD STOP'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _addCurrentLocationStop, + icon: const Icon(Icons.my_location, + size: 16, color: Color(0xFF00E5FF)), + label: Text(isNarrow ? 'GPS' : 'CURRENT GPS', + style: const TextStyle(color: Color(0xFF00E5FF))), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF2A2E3D)), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ), + if (state.stops.length >= 2) + TextButton.icon( + onPressed: () => + ref.read(routePlannerNotifierProvider.notifier).reverseStops(), + icon: const Icon(Icons.swap_vert, + size: 16, color: Colors.white70), + label: const Text('REVERSE', + style: TextStyle(color: Colors.white70, fontSize: 11)), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ), + + // Reorderable Stop List + Expanded( + child: state.stops.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.alt_route, + size: 48, + color: Colors.white.withValues(alpha: 0.15)), + const SizedBox(height: 12), + const Text( + 'No stops added yet.', + style: TextStyle( + color: Colors.white54, fontSize: 14), + ), + const SizedBox(height: 6), + const Text( + 'Tap "Add Stop" or "Current GPS" to begin planning.', + style: TextStyle( + color: Colors.white30, fontSize: 12), + ), + ], + ), + ) + : ReorderableListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + itemCount: state.stops.length, + // ignore: deprecated_member_use + onReorder: (oldIdx, newIdx) { + ref + .read(routePlannerNotifierProvider.notifier) + .reorderStops(oldIdx, newIdx); + }, + itemBuilder: (context, index) { + final stop = state.stops[index]; + final isFirst = index == 0; + final isLast = + index == state.stops.length - 1 && state.stops.length > 1; + + return Container( + key: ValueKey(stop.id), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF13151A), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Row( + children: [ + // Order badge + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: isFirst + ? const Color(0xFF00E676) + : isLast + ? const Color(0xFFFF5252) + : const Color(0xFF00E5FF), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 10), + + // Stop Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getIconForStopType(stop.stopType), + size: 14, + color: const Color(0xFF00E5FF), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + stop.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${stop.latitude.toStringAsFixed(4)}, ${stop.longitude.toStringAsFixed(4)}', + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), + ), + if (stop.notes.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + stop.notes, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + + // Actions: Handoff navigation, Edit, Delete + IconButton( + icon: const Icon(Icons.navigation, + color: Color(0xFF00E5FF), size: 18), + tooltip: 'Open in Maps', + onPressed: () { + NavigationHandoffService.showHandoffSheet( + context: context, + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32), + ), + IconButton( + icon: const Icon(Icons.edit, + color: Colors.white60, size: 18), + tooltip: 'Edit stop', + onPressed: () => _showAddOrEditStopDialog( + existingStop: stop, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32), + ), + IconButton( + icon: const Icon(Icons.delete_outline, + color: Color(0xFFFF5252), size: 18), + tooltip: 'Remove stop', + onPressed: () { + ref + .read(routePlannerNotifierProvider.notifier) + .removeStop(stop.id); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32), + ), + const SizedBox(width: 4), + const Icon(Icons.drag_handle, + color: Colors.white30, size: 20), + ], + ), + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/services/entitlements/entitlement_providers.dart b/lib/services/entitlements/entitlement_providers.dart index fc62ca6..ce16e30 100644 --- a/lib/services/entitlements/entitlement_providers.dart +++ b/lib/services/entitlements/entitlement_providers.dart @@ -39,3 +39,8 @@ final featureAccessProvider = Provider((ref) { final state = ref.watch(entitlementProvider); return FeatureAccessService(state); }); + +/// Convenience provider returning whether user is currently a Pro subscriber. +final isProSubscriberProvider = Provider((ref) { + return ref.watch(entitlementProvider).isPro; +}); diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index fceb164..6b09ed3 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -359,6 +359,10 @@ class GroupRide { final GroupRideLocation meetingPoint; final GroupRideLocation? destination; final List stops; // Ordered waypoints + final String? routeId; + final String? routeTitle; + final double? routeDistanceMeters; + final int? routeStopCount; final DateTime createdAt; final DateTime updatedAt; final DateTime? canceledAt; @@ -381,6 +385,10 @@ class GroupRide { required this.meetingPoint, this.destination, this.stops = const [], + this.routeId, + this.routeTitle, + this.routeDistanceMeters, + this.routeStopCount, required this.createdAt, required this.updatedAt, this.canceledAt, @@ -414,6 +422,11 @@ class GroupRide { 'meetingPoint': meetingPoint.toJson(), if (destination != null) 'destination': destination!.toJson(), 'stops': stops.map((s) => s.toJson()).toList(), + if (routeId != null) 'routeId': routeId, + if (routeTitle != null) 'routeTitle': routeTitle, + if (routeDistanceMeters != null) + 'routeDistanceMeters': routeDistanceMeters, + if (routeStopCount != null) 'routeStopCount': routeStopCount, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(), @@ -478,6 +491,10 @@ class GroupRide { json['destination'] as Map) : null, stops: stopsList, + routeId: json['routeId'] as String?, + routeTitle: json['routeTitle'] as String?, + routeDistanceMeters: (json['routeDistanceMeters'] as num?)?.toDouble(), + routeStopCount: json['routeStopCount'] as int?, createdAt: created, updatedAt: updated, canceledAt: canceled, @@ -502,6 +519,10 @@ class GroupRide { GroupRideLocation? meetingPoint, GroupRideLocation? destination, List? stops, + String? routeId, + String? routeTitle, + double? routeDistanceMeters, + int? routeStopCount, DateTime? createdAt, DateTime? updatedAt, DateTime? canceledAt, @@ -524,6 +545,10 @@ class GroupRide { meetingPoint: meetingPoint ?? this.meetingPoint, destination: destination ?? this.destination, stops: stops ?? this.stops, + routeId: routeId ?? this.routeId, + routeTitle: routeTitle ?? this.routeTitle, + routeDistanceMeters: routeDistanceMeters ?? this.routeDistanceMeters, + routeStopCount: routeStopCount ?? this.routeStopCount, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, canceledAt: canceledAt ?? this.canceledAt, @@ -543,7 +568,8 @@ class GroupRide { scheduledStartAt == other.scheduledStartAt && departureAt == other.departureAt && participantCount == other.participantCount && - participantLimit == other.participantLimit; + participantLimit == other.participantLimit && + routeId == other.routeId; @override int get hashCode => @@ -554,7 +580,8 @@ class GroupRide { scheduledStartAt.hashCode ^ departureAt.hashCode ^ participantCount.hashCode ^ - participantLimit.hashCode; + participantLimit.hashCode ^ + routeId.hashCode; } /// Status of rider in live group ride presence. diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index c3e1bc4..4090a45 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -35,6 +35,10 @@ class GroupRideService { GroupRideLocation? destination, List stops = const [], List initialInviteUids = const [], + String? routeId, + String? routeTitle, + double? routeDistanceMeters, + int? routeStopCount, }) async { final cleanTitle = title.trim(); if (cleanTitle.length < 3) { @@ -70,6 +74,10 @@ class GroupRideService { meetingPoint: meetingPoint, destination: destination, stops: stops, + routeId: routeId, + routeTitle: routeTitle, + routeDistanceMeters: routeDistanceMeters, + routeStopCount: routeStopCount, createdAt: now, updatedAt: now, ); diff --git a/lib/services/routes/navigation_handoff.dart b/lib/services/routes/navigation_handoff.dart new file mode 100644 index 0000000..faf7e09 --- /dev/null +++ b/lib/services/routes/navigation_handoff.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'route_models.dart'; + +/// Target navigation application for handoff. +enum NavigationApp { + googleMaps, + waze, + platformDefault; + + String get label { + switch (this) { + case NavigationApp.googleMaps: + return 'Google Maps'; + case NavigationApp.waze: + return 'Waze'; + case NavigationApp.platformDefault: + return 'System Map'; + } + } +} + +/// Service handling truthful handoff to external navigation applications. +/// MotorSignal delegates road-following turn-by-turn navigation to trusted +/// navigation apps without claiming internal turn-by-turn guidance. +class NavigationHandoffService { + const NavigationHandoffService(); + + /// Builds a standard Google Maps turn-by-turn navigation URI. + static Uri buildGoogleMapsUri({ + required double latitude, + required double longitude, + String? displayName, + }) { + final dest = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}'; + return Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$dest'); + } + + /// Builds a standard Waze navigation URI. + static Uri buildWazeUri({ + required double latitude, + required double longitude, + }) { + final ll = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}'; + return Uri.parse('https://waze.com/ul?ll=$ll&navigate=yes'); + } + + /// Builds a standard RFC 5870 system geo: navigation URI. + static Uri buildSystemGeoUri({ + required double latitude, + required double longitude, + required String displayName, + }) { + final coords = '${latitude.toStringAsFixed(6)},${longitude.toStringAsFixed(6)}'; + return Uri.parse('geo:$coords?q=$coords($displayName)'); + } + + /// Displays an interactive navigation handoff bottom sheet with direct links + /// to Google Maps, Waze, and native system mapping. + static void showHandoffSheet({ + required BuildContext context, + required double latitude, + required double longitude, + required String displayName, + }) { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1B1E26), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.navigation, color: Color(0xFF00E5FF), size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'NAVIGATION HANDOFF', + style: TextStyle( + fontSize: 11, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + color: Color(0xFF00E5FF), + ), + ), + const SizedBox(height: 2), + Text( + displayName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.map, color: Colors.white), + ), + title: const Text('Google Maps', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), + subtitle: const Text('Open turn-by-turn in Google Maps', style: TextStyle(color: Colors.white54, fontSize: 12)), + trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18), + onTap: () async { + Navigator.pop(sheetContext); + final uri = buildGoogleMapsUri( + latitude: latitude, + longitude: longitude, + displayName: displayName, + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + }, + ), + const Divider(color: Colors.white12, height: 1), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.directions_car, color: Color(0xFF00E5FF)), + ), + title: const Text('Waze', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), + subtitle: const Text('Open road directions in Waze', style: TextStyle(color: Colors.white54, fontSize: 12)), + trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18), + onTap: () async { + Navigator.pop(sheetContext); + final uri = buildWazeUri( + latitude: latitude, + longitude: longitude, + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + }, + ), + const Divider(color: Colors.white12, height: 1), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.place, color: Colors.white70), + ), + title: const Text('System Map', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600)), + subtitle: const Text('Open in default platform mapping app', style: TextStyle(color: Colors.white54, fontSize: 12)), + trailing: const Icon(Icons.open_in_new, color: Colors.white38, size: 18), + onTap: () async { + Navigator.pop(sheetContext); + final uri = buildSystemGeoUri( + latitude: latitude, + longitude: longitude, + displayName: displayName, + ); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + } + + /// Launches external navigation to a single target [stop]. + Future launchNavigationToStop({ + required RouteStop stop, + NavigationApp app = NavigationApp.googleMaps, + }) async { + Uri uri; + switch (app) { + case NavigationApp.googleMaps: + uri = buildGoogleMapsUri( + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + break; + + case NavigationApp.waze: + uri = buildWazeUri( + latitude: stop.latitude, + longitude: stop.longitude, + ); + break; + + case NavigationApp.platformDefault: + uri = buildSystemGeoUri( + latitude: stop.latitude, + longitude: stop.longitude, + displayName: stop.displayName, + ); + break; + } + + try { + if (await canLaunchUrl(uri)) { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } + final fallbackUri = Uri.parse( + 'https://www.google.com/maps/search/?api=1&query=${stop.latitude},${stop.longitude}', + ); + if (await canLaunchUrl(fallbackUri)) { + return await launchUrl(fallbackUri, mode: LaunchMode.externalApplication); + } + return false; + } catch (e) { + debugPrint('[NavigationHandoffService] Failed to launch navigation: $e'); + return false; + } + } + + /// Launches multi-stop road route navigation in external maps (up to provider limit). + Future launchFullRouteNavigation({ + required MotorSignalRoute route, + NavigationApp app = NavigationApp.googleMaps, + }) async { + final stops = route.orderedStops; + if (stops.isEmpty) return false; + if (stops.length == 1) { + return launchNavigationToStop(stop: stops.first, app: app); + } + + final origin = '${stops.first.latitude},${stops.first.longitude}'; + final dest = '${stops.last.latitude},${stops.last.longitude}'; + + if (app == NavigationApp.googleMaps) { + final intermediateStops = stops.sublist(1, stops.length - 1); + final cappedIntermediates = intermediateStops.take(9); + final waypointsStr = cappedIntermediates + .map((s) => '${s.latitude},${s.longitude}') + .join('|'); + + final waypointsParam = + waypointsStr.isNotEmpty ? '&waypoints=$waypointsStr' : ''; + final uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&origin=$origin&destination=$dest$waypointsParam', + ); + + try { + if (await canLaunchUrl(uri)) { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + debugPrint('[NavigationHandoffService] Failed to launch multi-stop route: $e'); + } + } + + return launchNavigationToStop(stop: stops.last, app: app); + } +} diff --git a/lib/services/routes/route_models.dart b/lib/services/routes/route_models.dart new file mode 100644 index 0000000..b54d51a --- /dev/null +++ b/lib/services/routes/route_models.dart @@ -0,0 +1,437 @@ +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 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, + ); + } +} diff --git a/lib/services/routes/route_provider.dart b/lib/services/routes/route_provider.dart new file mode 100644 index 0000000..6759363 --- /dev/null +++ b/lib/services/routes/route_provider.dart @@ -0,0 +1,146 @@ +import '../map/map_provider_interface.dart'; +import 'route_models.dart'; + +/// Result of a place/location search query. +class PlaceSearchResult { + final String name; + final String address; + final double latitude; + final double longitude; + final String? placeId; + + const PlaceSearchResult({ + required this.name, + this.address = '', + required this.latitude, + required this.longitude, + this.placeId, + }); + + AppLatLng get latLng => AppLatLng(latitude, longitude); +} + +/// Result of calculating a route along ordered stops. +class RouteCalculationResult { + final double straightLineDistanceMeters; + final double? estimatedDistanceMeters; + final int? estimatedDurationSeconds; + final List geometry; + final bool isStraightLine; + final String providerName; + + const RouteCalculationResult({ + required this.straightLineDistanceMeters, + this.estimatedDistanceMeters, + this.estimatedDurationSeconds, + required this.geometry, + required this.isStraightLine, + required this.providerName, + }); + + /// Alias for geometry points. + List get points => geometry; + + /// Total straight-line distance in meters. + double get totalDistanceMeters => straightLineDistanceMeters; + + /// Alias for isStraightLine. + bool get isHonestStraightLine => isStraightLine; + + /// Total distance in miles. + double get distanceMiles { + final meters = estimatedDistanceMeters ?? straightLineDistanceMeters; + return meters * 0.000621371; + } + + /// Truthful distance description. + String get distanceLabel { + if (estimatedDistanceMeters != null) { + return '${distanceMiles.toStringAsFixed(1)} mi (Road Est.)'; + } + return '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)'; + } +} + +/// Abstract provider interface for calculating routes and searching places. +abstract class RouteProvider { + String get name; + + /// Calculates route geometry and metrics between ordered stops. + Future calculateRoute(List stops); + + /// Searches for places by textual query. + Future> searchPlaces( + String query, { + AppLatLng? nearLocation, + }); +} + +/// Default geodesic straight-line preview implementation. +/// +/// Truthfully connects stops with direct segments and calculates geodesic +/// distance without fabricating road paths or speculative turn ETAs. +class StraightLineRouteProvider implements RouteProvider { + const StraightLineRouteProvider(); + + @override + String get name => 'Straight-line Preview'; + + @override + Future calculateRoute(List stops) async { + final ordered = List.from(stops); + ordered.sort((a, b) => a.order.compareTo(b.order)); + + final distanceMeters = MotorSignalRoute.calculateTotalDistanceMeters(ordered); + final points = ordered.map((s) => s.latLng).toList(); + + return RouteCalculationResult( + straightLineDistanceMeters: distanceMeters, + estimatedDistanceMeters: null, + estimatedDurationSeconds: null, + geometry: points, + isStraightLine: true, + providerName: name, + ); + } + + @override + Future> searchPlaces( + String query, { + AppLatLng? nearLocation, + }) async { + final clean = query.trim(); + if (clean.isEmpty) return const []; + + // Support direct coordinate input (e.g. "34.5378, -83.9849") + final parts = clean.split(RegExp(r'[,\s]+')); + if (parts.length == 2) { + final lat = double.tryParse(parts[0]); + final lng = double.tryParse(parts[1]); + if (lat != null && lng != null && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) { + return [ + PlaceSearchResult( + name: 'Coordinate Pin', + address: '${lat.toStringAsFixed(5)}, ${lng.toStringAsFixed(5)}', + latitude: lat, + longitude: lng, + ), + ]; + } + } + + // Default fallback: return query name centered at reference location if provided + if (nearLocation != null) { + return [ + PlaceSearchResult( + name: clean, + address: 'Near Current Map Location', + latitude: nearLocation.latitude, + longitude: nearLocation.longitude, + ), + ]; + } + + return const []; + } +} diff --git a/lib/services/routes/route_providers.dart b/lib/services/routes/route_providers.dart new file mode 100644 index 0000000..f527d51 --- /dev/null +++ b/lib/services/routes/route_providers.dart @@ -0,0 +1,355 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/auth_providers.dart'; +import '../entitlements/entitlement_providers.dart'; +import '../map/map_provider_interface.dart'; +import 'navigation_handoff.dart'; +import 'route_models.dart'; +import 'route_provider.dart'; +import 'route_service.dart'; + +/// Provider for RouteService. +final routeServiceProvider = Provider((ref) { + return RouteService(); +}); + +/// Active RouteProvider implementation (defaults to honest straight-line preview). +final activeRouteCalculationProvider = Provider((ref) { + return const StraightLineRouteProvider(); +}); + +/// Provider for NavigationHandoffService. +final navigationHandoffServiceProvider = + Provider((ref) { + return const NavigationHandoffService(); +}); + +/// Realtime stream provider for a single route by ID. +final routeByIdProvider = + StreamProvider.family((ref, routeId) { + final service = ref.watch(routeServiceProvider); + return service.routeStream(routeId); +}); + +/// Realtime stream provider for routes owned by a user. +final userRoutesProvider = + StreamProvider.family, String>((ref, uid) { + final service = ref.watch(routeServiceProvider); + return service.userRoutesStream(uid); +}); + +/// Alias for routeByIdProvider. +final routeStreamProvider = routeByIdProvider; + +/// Realtime stream of routes created by current user. +final myRoutesStreamProvider = + StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.uid.isEmpty) { + return Stream.value(const []); + } + final service = ref.watch(routeServiceProvider); + return service.userRoutesStream(user.uid); +}); + +/// State of an active route planning session. +class RoutePlannerState { + final String? routeId; + final String title; + final String description; + final RouteVisibility visibility; + final List stops; + final double straightLineDistanceMeters; + final bool isSaving; + final String? errorMessage; + final bool isDirty; + + const RoutePlannerState({ + this.routeId, + this.title = '', + this.description = '', + this.visibility = RouteVisibility.private, + this.stops = const [], + this.straightLineDistanceMeters = 0.0, + this.isSaving = false, + this.errorMessage, + this.isDirty = false, + }); + + double get distanceMiles => straightLineDistanceMeters * 0.000621371; + + String get formattedDistance => + '${distanceMiles.toStringAsFixed(1)} mi (Straight-line)'; + + String get formattedDistanceMiles => '${distanceMiles.toStringAsFixed(1)} mi'; + + String get formattedDistanceKm => + '${(straightLineDistanceMeters / 1000).toStringAsFixed(1)} km'; + + List get previewPoints => stops.map((s) => s.latLng).toList(); + + RoutePlannerState copyWith({ + String? routeId, + String? title, + String? description, + RouteVisibility? visibility, + List? stops, + double? straightLineDistanceMeters, + bool? isSaving, + String? errorMessage, + bool? isDirty, + }) { + return RoutePlannerState( + routeId: routeId ?? this.routeId, + title: title ?? this.title, + description: description ?? this.description, + visibility: visibility ?? this.visibility, + stops: stops ?? this.stops, + straightLineDistanceMeters: + straightLineDistanceMeters ?? this.straightLineDistanceMeters, + isSaving: isSaving ?? this.isSaving, + errorMessage: errorMessage, + isDirty: isDirty ?? this.isDirty, + ); + } +} + +/// Controller managing in-memory route editing state. +class RoutePlannerNotifier extends StateNotifier { + final Ref ref; + + RoutePlannerNotifier(this.ref, [MotorSignalRoute? initialRoute]) + : super( + initialRoute != null + ? RoutePlannerState( + routeId: initialRoute.id, + title: initialRoute.title, + description: initialRoute.description, + visibility: initialRoute.visibility, + stops: initialRoute.orderedStops, + straightLineDistanceMeters: + initialRoute.straightLineDistanceMeters, + ) + : const RoutePlannerState(), + ); + + void loadRoute(MotorSignalRoute route) { + state = RoutePlannerState( + routeId: route.id, + title: route.title, + description: route.description, + visibility: route.visibility, + stops: route.orderedStops, + straightLineDistanceMeters: route.straightLineDistanceMeters, + isDirty: false, + ); + } + + void reset() { + state = const RoutePlannerState(); + } + + void setTitle(String title) { + state = state.copyWith(title: title, isDirty: true); + } + + void setDescription(String desc) { + state = state.copyWith(description: desc, isDirty: true); + } + + void setVisibility(RouteVisibility visibility) { + state = state.copyWith(visibility: visibility, isDirty: true); + } + + void addStop({ + RouteStop? stop, + String? displayName, + double? latitude, + double? longitude, + RouteStopType? stopType, + String? notes, + }) { + final updatedStops = List.from(state.stops); + final effectiveOrder = updatedStops.length; + + final newStop = stop != null + ? stop.copyWith(order: effectiveOrder, orderIndex: effectiveOrder) + : RouteStop( + id: 'stop_${DateTime.now().millisecondsSinceEpoch}_$effectiveOrder', + order: effectiveOrder, + orderIndex: effectiveOrder, + type: stopType ?? RouteStopType.waypoint, + stopType: stopType ?? RouteStopType.waypoint, + displayName: displayName ?? 'Stop $effectiveOrder', + latitude: latitude ?? 0.0, + longitude: longitude ?? 0.0, + notes: notes ?? '', + ); + + updatedStops.add(newStop); + + final distance = + MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); + state = state.copyWith( + stops: updatedStops, + straightLineDistanceMeters: distance, + isDirty: true, + errorMessage: null, + ); + } + + void updateStop(dynamic target, [RouteStop? maybeStop]) { + final updatedStops = List.from(state.stops); + if (target is RouteStop) { + final idx = updatedStops.indexWhere((s) => s.id == target.id); + if (idx == -1) return; + updatedStops[idx] = target.copyWith(order: idx, orderIndex: idx); + } else if (target is int && maybeStop != null) { + if (target < 0 || target >= updatedStops.length) return; + updatedStops[target] = maybeStop.copyWith(order: target, orderIndex: target); + } else { + return; + } + + final distance = + MotorSignalRoute.calculateTotalDistanceMeters(updatedStops); + state = state.copyWith( + stops: updatedStops, + straightLineDistanceMeters: distance, + isDirty: true, + errorMessage: null, + ); + } + + void removeStop(dynamic target) { + final updatedStops = List.from(state.stops); + if (target is int) { + if (target < 0 || target >= updatedStops.length) return; + updatedStops.removeAt(target); + } else if (target is String) { + updatedStops.removeWhere((s) => s.id == target); + } else { + return; + } + + // Re-index remaining stops + final reindexed = []; + for (int i = 0; i < updatedStops.length; i++) { + reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i)); + } + + final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed); + state = state.copyWith( + stops: reindexed, + straightLineDistanceMeters: distance, + isDirty: true, + errorMessage: null, + ); + } + + void reorderStops(int oldIndex, int newIndex) { + if (oldIndex < 0 || oldIndex >= state.stops.length) return; + if (newIndex < 0 || newIndex > state.stops.length) return; + + final updatedStops = List.from(state.stops); + if (newIndex > oldIndex) newIndex -= 1; + final item = updatedStops.removeAt(oldIndex); + updatedStops.insert(newIndex, item); + + final reindexed = []; + for (int i = 0; i < updatedStops.length; i++) { + reindexed.add(updatedStops[i].copyWith(order: i, orderIndex: i)); + } + + final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed); + state = state.copyWith( + stops: reindexed, + straightLineDistanceMeters: distance, + isDirty: true, + errorMessage: null, + ); + } + + void reverseStops() { + if (state.stops.length < 2) return; + final reversed = state.stops.reversed.toList(); + final reindexed = []; + for (int i = 0; i < reversed.length; i++) { + reindexed.add(reversed[i].copyWith(order: i, orderIndex: i)); + } + + final distance = MotorSignalRoute.calculateTotalDistanceMeters(reindexed); + state = state.copyWith( + stops: reindexed, + straightLineDistanceMeters: distance, + isDirty: true, + errorMessage: null, + ); + } + + Future saveRoute({ + String? title, + String? description, + String? ownerUid, + RouteVisibility? visibility, + String? attachedGroupRideId, + }) async { + final effectiveTitle = (title ?? state.title).trim(); + if (effectiveTitle.length < 3) { + throw ArgumentError('Route title must be at least 3 characters.'); + } + + if (state.stops.isEmpty) { + throw ArgumentError('Please add at least one stop to the route.'); + } + + final user = ref.read(currentUserProvider); + final effectiveUid = ownerUid ?? user?.uid ?? ''; + if (effectiveUid.isEmpty) { + throw StateError('Sign-in required to save routes.'); + } + + final isPro = ref.read(isProSubscriberProvider); + final service = ref.read(routeServiceProvider); + + state = state.copyWith(isSaving: true, errorMessage: null); + + try { + final saved = await service.saveRoute( + id: state.routeId, + title: effectiveTitle, + description: (description ?? state.description).trim(), + ownerUid: effectiveUid, + ownerHandle: user?.email?.split('@').first ?? 'rider', + visibility: visibility ?? state.visibility, + stops: state.stops, + isPro: isPro, + attachedGroupRideId: attachedGroupRideId, + ); + + state = state.copyWith( + routeId: saved.id, + isSaving: false, + isDirty: false, + ); + return saved; + } catch (e) { + state = state.copyWith( + isSaving: false, + errorMessage: 'Failed to save route: $e', + ); + rethrow; + } + } +} + +/// Global notifier provider for route planner state. +final routePlannerNotifierProvider = + StateNotifierProvider((ref) { + return RoutePlannerNotifier(ref); +}); + +/// Backward-compatible family alias. +final routePlannerProvider = StateNotifierProvider.autoDispose + .family( + (ref, initialRoute) => RoutePlannerNotifier(ref, initialRoute), +); diff --git a/lib/services/routes/route_service.dart b/lib/services/routes/route_service.dart new file mode 100644 index 0000000..06d7f0a --- /dev/null +++ b/lib/services/routes/route_service.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'route_models.dart'; + +/// Service managing route creation, persistence, reordering, retrieval, and attachment. +class RouteService { + final FirebaseFirestore? _firestore; + + RouteService({FirebaseFirestore? firestore}) + : _firestore = firestore ?? _resolveFirestore(); + + static FirebaseFirestore? _resolveFirestore() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} + return null; + } + + bool get isAvailable => _firestore != null; + + /// Technical safety bound for maximum stops in a single route. + static const int maxTechnicalStops = 25; + + /// Free tier recommended stop cap. + static const int freeStopCap = 8; + + /// Validates route stops invariants. + static void validateStops(List stops, {bool isPro = false}) { + if (stops.length > maxTechnicalStops) { + throw ArgumentError( + 'Route cannot exceed $maxTechnicalStops stops.', + ); + } + if (!isPro && stops.length > freeStopCap) { + throw ArgumentError( + 'Free tier routes cannot exceed $freeStopCap stops. Upgrade to Pro for up to $maxTechnicalStops stops.', + ); + } + + final seenIds = {}; + for (int i = 0; i < stops.length; i++) { + final s = stops[i]; + if (s.id.isEmpty) { + throw ArgumentError('Route stop at index $i is missing an ID.'); + } + if (seenIds.contains(s.id)) { + throw ArgumentError('Duplicate route stop ID: ${s.id}'); + } + seenIds.add(s.id); + + if (s.latitude.isNaN || s.longitude.isNaN) { + throw ArgumentError('Route stop contains invalid coordinates (NaN).'); + } + if (s.latitude < -90.0 || s.latitude > 90.0) { + throw ArgumentError( + 'Route stop contains invalid coordinates: latitude ${s.latitude} out of bounds.', + ); + } + if (s.longitude < -180.0 || s.longitude > 180.0) { + throw ArgumentError( + 'Route stop contains invalid coordinates: longitude ${s.longitude} out of bounds.', + ); + } + if (s.displayName.trim().isEmpty) { + throw ArgumentError('Stop name cannot be empty.'); + } + } + } + + /// Saves or creates a route (offline-safe when Firestore is unconfigured). + Future saveRoute({ + String? id, + required String title, + String description = '', + required String ownerUid, + String ownerHandle = '', + RouteVisibility visibility = RouteVisibility.private, + required List stops, + bool isPro = false, + String? attachedGroupRideId, + }) async { + final cleanTitle = title.trim(); + if (cleanTitle.length < 3 || cleanTitle.length > 80) { + throw ArgumentError('Route title must be between 3 and 80 characters.'); + } + + validateStops(stops, isPro: isPro); + + final now = DateTime.now(); + final routeId = id ?? + 'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}'; + + // Re-index stops 0..N-1 + final sortedStops = List.from(stops); + sortedStops.sort((a, b) => a.order.compareTo(b.order)); + final normalizedStops = []; + for (int i = 0; i < sortedStops.length; i++) { + normalizedStops.add(sortedStops[i].copyWith(order: i, orderIndex: i)); + } + + final totalMeters = + MotorSignalRoute.calculateTotalDistanceMeters(normalizedStops); + + final route = MotorSignalRoute( + id: routeId, + ownerUid: ownerUid, + ownerHandle: ownerHandle, + title: cleanTitle, + description: description.trim(), + visibility: visibility, + stops: normalizedStops, + straightLineDistanceMeters: totalMeters, + source: RouteSource.planner, + version: 1, + attachedGroupRideId: attachedGroupRideId, + createdAt: now, + updatedAt: now, + ); + + final firestore = _firestore; + if (firestore != null) { + await firestore.collection('routes').doc(routeId).set(route.toJson()); + } + + return route; + } + + /// Legacy alias for saveRoute. + Future createRoute({ + required String ownerUid, + required String ownerHandle, + required String title, + String description = '', + RouteVisibility visibility = RouteVisibility.private, + required List stops, + bool isPro = false, + String? attachedGroupRideId, + }) async { + return saveRoute( + ownerUid: ownerUid, + ownerHandle: ownerHandle, + title: title, + description: description, + visibility: visibility, + stops: stops, + isPro: isPro, + attachedGroupRideId: attachedGroupRideId, + ); + } + + /// Retrieves a route by ID. + Future getRoute(String routeId) async { + final firestore = _firestore; + if (firestore == null) return null; + + final doc = await firestore.collection('routes').doc(routeId).get(); + if (!doc.exists || doc.data() == null) return null; + + return MotorSignalRoute.fromJson(doc.data()!); + } + + /// Streams real-time updates for a single route. + Stream routeStream(String routeId) { + final firestore = _firestore; + if (firestore == null) return Stream.value(null); + + return firestore.collection('routes').doc(routeId).snapshots().map((doc) { + if (!doc.exists || doc.data() == null) return null; + return MotorSignalRoute.fromJson(doc.data()!); + }); + } + + /// Streams saved routes owned by [ownerUid]. + Stream> userRoutesStream(String ownerUid) { + final firestore = _firestore; + if (firestore == null || ownerUid.isEmpty) return Stream.value(const []); + + return firestore + .collection('routes') + .where('ownerUid', isEqualTo: ownerUid) + .snapshots() + .map((snapshot) { + final routes = snapshot.docs + .map((doc) => MotorSignalRoute.fromJson(doc.data())) + .toList(); + routes.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return routes; + }); + } + + /// Updates an existing route. + Future updateRoute({ + required MotorSignalRoute route, + required String callerUid, + bool isPro = false, + }) async { + if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) { + throw StateError('Only the route owner can update this route.'); + } + + return saveRoute( + id: route.id, + title: route.title, + description: route.description, + ownerUid: callerUid, + ownerHandle: route.ownerHandle, + visibility: route.visibility, + stops: route.stops, + isPro: isPro, + attachedGroupRideId: route.attachedGroupRideId, + ); + } + + /// Deletes a route safely. + /// + /// If the route is attached to a Group Ride, it cleanly clears the + /// attachment reference without destructive cascades. + Future deleteRoute( + dynamic routeIdOrNamed, { + String? routeId, + String? callerUid, + }) async { + final effectiveRouteId = + routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? ''); + if (effectiveRouteId.isEmpty) return; + + final firestore = _firestore; + if (firestore == null) return; + + final docRef = firestore.collection('routes').doc(effectiveRouteId); + final snapshot = await docRef.get(); + if (!snapshot.exists || snapshot.data() == null) return; + + final data = snapshot.data()!; + if (callerUid != null && data['ownerUid'] != callerUid) { + throw StateError('Only the route owner can delete this route.'); + } + + final attachedRideId = data['attachedGroupRideId'] as String?; + if (attachedRideId != null && attachedRideId.isNotEmpty) { + final rideRef = firestore.collection('group_rides').doc(attachedRideId); + final rideSnap = await rideRef.get(); + if (rideSnap.exists) { + await rideRef.update({ + 'routeId': FieldValue.delete(), + 'routeTitle': FieldValue.delete(), + 'routeDistanceMeters': FieldValue.delete(), + 'routeStopCount': FieldValue.delete(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + } + } + + await docRef.delete(); + } + + /// Attaches a saved route to a Group Ride. + /// + /// Caller must be the Group Ride organizer. + Future attachRouteToGroupRide({ + String? routeId, + MotorSignalRoute? route, + required String groupRideId, + String? callerUid, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final targetRouteId = route?.id ?? routeId; + if (targetRouteId == null || targetRouteId.isEmpty) { + throw ArgumentError('Route or routeId must be provided.'); + } + + final rideRef = firestore.collection('group_rides').doc(groupRideId); + final rideSnap = await rideRef.get(); + if (!rideSnap.exists || rideSnap.data() == null) { + throw ArgumentError('Group Ride not found.'); + } + + final rideData = rideSnap.data()!; + if (callerUid != null && + rideData['organizerUid'] != null && + rideData['organizerUid'] != callerUid) { + throw StateError('Only the Group Ride organizer can attach a route.'); + } + + final effectiveRoute = route ?? await getRoute(targetRouteId); + if (effectiveRoute == null) { + throw ArgumentError('Route not found.'); + } + + final routeRef = firestore.collection('routes').doc(targetRouteId); + final now = DateTime.now(); + final batch = firestore.batch(); + + // 1. Update Group Ride metadata + batch.update(rideRef, { + 'routeId': effectiveRoute.id, + 'routeTitle': effectiveRoute.title, + 'routeDistanceMeters': effectiveRoute.straightLineDistanceMeters, + 'routeStopCount': effectiveRoute.stops.length, + 'updatedAt': now.toIso8601String(), + }); + + // 2. Update Route document attachment + batch.update(routeRef, { + 'attachedGroupRideId': groupRideId, + 'visibility': RouteVisibility.groupOnly.name, + 'updatedAt': now.toIso8601String(), + }); + + await batch.commit(); + } + + /// Detaches an attached route from a Group Ride. + Future detachRouteFromGroupRide( + String groupRideId, { + String? callerUid, + }) async { + final firestore = _firestore; + if (firestore == null) return; + + final rideRef = firestore.collection('group_rides').doc(groupRideId); + final rideSnap = await rideRef.get(); + if (!rideSnap.exists || rideSnap.data() == null) return; + + final rideData = rideSnap.data()!; + if (callerUid != null && + rideData['organizerUid'] != null && + rideData['organizerUid'] != callerUid) { + throw StateError('Only the organizer can detach a route.'); + } + + final routeId = rideData['routeId'] as String?; + final now = DateTime.now(); + + final batch = firestore.batch(); + batch.update(rideRef, { + 'routeId': FieldValue.delete(), + 'routeTitle': FieldValue.delete(), + 'routeDistanceMeters': FieldValue.delete(), + 'routeStopCount': FieldValue.delete(), + 'updatedAt': now.toIso8601String(), + }); + + if (routeId != null && routeId.isNotEmpty) { + final routeRef = firestore.collection('routes').doc(routeId); + final routeSnap = await routeRef.get(); + if (routeSnap.exists) { + batch.update(routeRef, { + 'attachedGroupRideId': FieldValue.delete(), + 'updatedAt': now.toIso8601String(), + }); + } + } + + await batch.commit(); + } +} diff --git a/test/route_models_test.dart b/test/route_models_test.dart new file mode 100644 index 0000000..1737df0 --- /dev/null +++ b/test/route_models_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/routes/route_models.dart'; + +void main() { + group('RouteStop & RouteStopType tests', () { + test('RouteStop serialization round-trip', () { + const stop = RouteStop( + id: 'stop-1', + latitude: 37.7749, + longitude: -122.4194, + displayName: 'San Francisco Hub', + stopType: RouteStopType.origin, + orderIndex: 0, + notes: 'Meet at 9 AM', + ); + + final json = stop.toJson(); + final fromJson = RouteStop.fromJson(json); + + expect(fromJson.id, equals('stop-1')); + expect(fromJson.latitude, equals(37.7749)); + expect(fromJson.longitude, equals(-122.4194)); + expect(fromJson.displayName, equals('San Francisco Hub')); + expect(fromJson.stopType, equals(RouteStopType.origin)); + expect(fromJson.orderIndex, equals(0)); + expect(fromJson.notes, equals('Meet at 9 AM')); + expect(fromJson, equals(stop)); + }); + + test('RouteStop copyWith preserves and modifies fields', () { + const stop = RouteStop( + id: 'stop-1', + latitude: 37.7749, + longitude: -122.4194, + displayName: 'SF', + stopType: RouteStopType.origin, + orderIndex: 0, + ); + + final updated = stop.copyWith( + displayName: 'SF City Hall', + orderIndex: 1, + stopType: RouteStopType.waypoint, + ); + + expect(updated.id, equals('stop-1')); + expect(updated.displayName, equals('SF City Hall')); + expect(updated.orderIndex, equals(1)); + expect(updated.stopType, equals(RouteStopType.waypoint)); + expect(updated.latitude, equals(37.7749)); + }); + + test('RouteStopType labels', () { + expect(RouteStopType.origin.label, equals('Start / Origin')); + expect(RouteStopType.waypoint.label, equals('Waypoint')); + expect(RouteStopType.fuel.label, equals('Fuel Stop')); + expect(RouteStopType.rest.label, equals('Rest Area')); + expect(RouteStopType.food.label, equals('Food & Drink')); + expect(RouteStopType.scenic.label, equals('Scenic Overlook')); + expect(RouteStopType.regroup.label, equals('Regroup Point')); + expect(RouteStopType.destination.label, equals('Destination')); + }); + }); + + group('MotorSignalRoute domain model tests', () { + final now = DateTime.utc(2026, 9, 4, 12, 0, 0); + + test('calculates straight-line distance correctly via Haversine', () { + final route = MotorSignalRoute( + id: 'route-sf-sj', + ownerUid: 'alice', + title: 'SF to San Jose Run', + createdAt: now, + updatedAt: now, + stops: const [ + RouteStop( + id: 'stop-0', + latitude: 37.7749, + longitude: -122.4194, + displayName: 'SF Start', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + RouteStop( + id: 'stop-1', + latitude: 37.4419, + longitude: -122.1430, + displayName: 'Palo Alto', + stopType: RouteStopType.waypoint, + orderIndex: 1, + ), + RouteStop( + id: 'stop-2', + latitude: 37.3382, + longitude: -121.8863, + displayName: 'San Jose Finish', + stopType: RouteStopType.destination, + orderIndex: 2, + ), + ], + ); + + expect(route.stops.length, equals(3)); + expect(route.previewPoints.length, equals(3)); + expect(route.totalDistanceMeters, greaterThan(65000)); + expect(route.totalDistanceMeters, lessThan(85000)); + expect(route.distanceMiles, greaterThan(40)); + expect(route.distanceMiles, lessThan(55)); + expect(route.formattedDistanceMiles, contains('mi')); + expect(route.formattedDistanceKm, contains('km')); + }); + + test('empty or single stop route has 0 distance', () { + final emptyRoute = MotorSignalRoute( + id: 'route-empty', + ownerUid: 'alice', + title: 'Empty Route', + createdAt: now, + updatedAt: now, + stops: const [], + ); + expect(emptyRoute.totalDistanceMeters, equals(0.0)); + expect(emptyRoute.distanceMiles, equals(0.0)); + + final singleStopRoute = MotorSignalRoute( + id: 'route-single', + ownerUid: 'alice', + title: 'Single Stop Route', + createdAt: now, + updatedAt: now, + stops: const [ + RouteStop( + id: 'stop-0', + latitude: 37.7749, + longitude: -122.4194, + displayName: 'Solo', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + ], + ); + expect(singleStopRoute.totalDistanceMeters, equals(0.0)); + expect(singleStopRoute.distanceMiles, equals(0.0)); + }); + + test('MotorSignalRoute serialization round-trip', () { + final route = MotorSignalRoute( + id: 'route-1', + ownerUid: 'alice', + title: 'Skyline Ridge', + description: 'Twisty canyon ride', + visibility: RouteVisibility.groupOnly, + source: RouteSource.planner, + attachedGroupRideId: 'group-ride-123', + createdAt: now, + updatedAt: now, + stops: const [ + RouteStop( + id: 'stop-1', + latitude: 37.4, + longitude: -122.2, + displayName: 'Alice\'s', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + RouteStop( + id: 'stop-2', + latitude: 37.2, + longitude: -122.1, + displayName: 'Vista Point', + stopType: RouteStopType.destination, + orderIndex: 1, + ), + ], + ); + + final json = route.toJson(); + final fromJson = MotorSignalRoute.fromJson(json); + + expect(fromJson.id, equals('route-1')); + expect(fromJson.ownerUid, equals('alice')); + expect(fromJson.title, equals('Skyline Ridge')); + expect(fromJson.description, equals('Twisty canyon ride')); + expect(fromJson.visibility, equals(RouteVisibility.groupOnly)); + expect(fromJson.source, equals(RouteSource.planner)); + expect(fromJson.attachedGroupRideId, equals('group-ride-123')); + expect(fromJson.stops.length, equals(2)); + expect(fromJson.stops[0].displayName, equals('Alice\'s')); + expect(fromJson.stops[1].displayName, equals('Vista Point')); + }); + + test('RouteVisibility labels', () { + expect(RouteVisibility.private.label, equals('Private')); + expect(RouteVisibility.groupOnly.label, equals('Group Ride Only')); + expect(RouteVisibility.public.label, equals('Public')); + }); + }); +} diff --git a/test/route_planner_screen_test.dart b/test/route_planner_screen_test.dart new file mode 100644 index 0000000..ef8837f --- /dev/null +++ b/test/route_planner_screen_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/routes/route_planner_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; + +void main() { + testWidgets('RoutePlannerScreen renders title, empty state, and stop controls', (tester) async { + await tester.binding.setSurfaceSize(const Size(360, 640)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser( + uid: 'alice', + isAnonymous: false, + )), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify AppBar title + expect(find.text('ROUTE PLANNER'), findsOneWidget); + expect(find.text('SAVE'), findsOneWidget); + + // Verify Empty state message + expect(find.text('No stops added yet.'), findsOneWidget); + expect(find.text('ADD STOP'), findsOneWidget); + expect(find.text('CURRENT GPS'), findsOneWidget); + }); + + testWidgets('RoutePlannerScreen handles adding a stop', (tester) async { + await tester.binding.setSurfaceSize(const Size(360, 640)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser( + uid: 'alice', + isAnonymous: false, + )), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap ADD STOP + await tester.tap(find.text('ADD STOP')); + await tester.pumpAndSettle(); + + // Dialog appears + expect(find.text('Add Route Stop'), findsOneWidget); + + // Enter name, lat, lng + final dialogTextFields = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ); + await tester.enterText(dialogTextFields.at(0), 'Alice\'s Restaurant'); + await tester.enterText(dialogTextFields.at(1), '37.3861'); + await tester.enterText(dialogTextFields.at(2), '-122.2641'); + + // Tap ADD button in dialog + await tester.tap(find.text('ADD')); + await tester.pumpAndSettle(); + + // Verify stop is added in list + expect(find.text('Alice\'s Restaurant'), findsOneWidget); + expect(find.text('1/8 STOPS'), findsOneWidget); + }); + + testWidgets('RoutePlannerScreen responsive layout on narrow 320dp screen', (tester) async { + // 320dp narrow device test + await tester.binding.setSurfaceSize(const Size(320, 568)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser( + uid: 'alice', + isAnonymous: false, + )), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // On narrow screen, compact labels 'ADD' and 'GPS' are shown + expect(find.text('ADD'), findsOneWidget); + expect(find.text('GPS'), findsOneWidget); + + // No RenderFlex overflow + expect(tester.takeException(), isNull); + }); +} diff --git a/test/route_service_test.dart b/test/route_service_test.dart new file mode 100644 index 0000000..bec5df5 --- /dev/null +++ b/test/route_service_test.dart @@ -0,0 +1,235 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/routes/navigation_handoff.dart'; +import 'package:motorcycle_dash/services/routes/route_models.dart'; +import 'package:motorcycle_dash/services/routes/route_provider.dart'; +import 'package:motorcycle_dash/services/routes/route_service.dart'; + +void main() { + group('RouteService validation tests', () { + final service = RouteService(firestore: null); + + test('validates route title length (minimum 3 characters)', () async { + expect( + () => service.saveRoute( + title: 'Hi', + ownerUid: 'alice', + stops: [], + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('between 3 and 80 characters'), + )), + ); + }); + + test('validates route title length (maximum 80 characters)', () async { + final longTitle = 'A' * 81; + expect( + () => service.saveRoute( + title: longTitle, + ownerUid: 'alice', + stops: [], + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('between 3 and 80 characters'), + )), + ); + }); + + test('validates maximum stops technical cap (25 stops)', () async { + final stops = List.generate( + 26, + (i) => RouteStop( + id: 'stop-$i', + latitude: 37.0 + i * 0.01, + longitude: -122.0, + displayName: 'Stop $i', + stopType: RouteStopType.waypoint, + orderIndex: i, + ), + ); + + expect( + () => service.saveRoute( + title: 'Too Many Stops Route', + ownerUid: 'alice', + stops: stops, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('cannot exceed 25 stops'), + )), + ); + }); + + test('validates stop coordinates bounds', () async { + final invalidLatStop = [ + const RouteStop( + id: 'stop-bad-lat', + latitude: 95.0, + longitude: -122.0, + displayName: 'Bad Lat', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + ]; + + expect( + () => service.saveRoute( + title: 'Invalid Coordinates Route', + ownerUid: 'alice', + stops: invalidLatStop, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('invalid coordinates'), + )), + ); + + final invalidLngStop = [ + const RouteStop( + id: 'stop-bad-lng', + latitude: 37.0, + longitude: -185.0, + displayName: 'Bad Lng', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + ]; + + expect( + () => service.saveRoute( + title: 'Invalid Coordinates Route', + ownerUid: 'alice', + stops: invalidLngStop, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('invalid coordinates'), + )), + ); + }); + + test('offline fallback: saveRoute generates valid route object and reindexes stops', () async { + final inputStops = [ + const RouteStop( + id: 'stop-b', + latitude: 37.4, + longitude: -122.2, + displayName: 'Midpoint', + stopType: RouteStopType.waypoint, + orderIndex: 99, + ), + const RouteStop( + id: 'stop-a', + latitude: 37.7, + longitude: -122.4, + displayName: 'Start', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + ]; + + final saved = await service.saveRoute( + title: 'Skyline Ridge', + description: 'Twisty Sunday Run', + ownerUid: 'alice', + visibility: RouteVisibility.groupOnly, + stops: inputStops, + ); + + expect(saved.id, startsWith('route_')); + expect(saved.title, equals('Skyline Ridge')); + expect(saved.ownerUid, equals('alice')); + expect(saved.visibility, equals(RouteVisibility.groupOnly)); + expect(saved.stops.length, equals(2)); + expect(saved.stops[0].orderIndex, equals(0)); + expect(saved.stops[1].orderIndex, equals(1)); + }); + }); + + group('StraightLineRouteProvider tests', () { + const provider = StraightLineRouteProvider(); + + test('builds straight-line polyline and calculates distance', () async { + const stops = [ + RouteStop( + id: 's1', + latitude: 37.7749, + longitude: -122.4194, + displayName: 'SF', + stopType: RouteStopType.origin, + orderIndex: 0, + ), + RouteStop( + id: 's2', + latitude: 37.3382, + longitude: -121.8863, + displayName: 'San Jose', + stopType: RouteStopType.destination, + orderIndex: 1, + ), + ]; + + final result = await provider.calculateRoute(stops); + expect(result.points.length, equals(2)); + expect(result.points[0].latitude, equals(37.7749)); + expect(result.points[1].latitude, equals(37.3382)); + expect(result.totalDistanceMeters, greaterThan(65000)); + expect(result.isHonestStraightLine, isTrue); + }); + + test('empty stops produces zero points and zero distance', () async { + final result = await provider.calculateRoute(const []); + expect(result.points.isEmpty, isTrue); + expect(result.totalDistanceMeters, equals(0.0)); + }); + }); + + group('NavigationHandoffService URI tests', () { + test('builds valid Google Maps navigation URI', () { + final uri = NavigationHandoffService.buildGoogleMapsUri( + latitude: 37.7749, + longitude: -122.4194, + displayName: 'San Francisco Hub', + ); + + expect(uri.scheme, equals('https')); + expect(uri.host, equals('www.google.com')); + expect(uri.path, equals('/maps/dir/')); + expect(uri.queryParameters['api'], equals('1')); + expect(uri.queryParameters['destination'], equals('37.774900,-122.419400')); + }); + + test('builds valid Waze navigation URI', () { + final uri = NavigationHandoffService.buildWazeUri( + latitude: 37.7749, + longitude: -122.4194, + ); + + expect(uri.scheme, equals('https')); + expect(uri.host, equals('waze.com')); + expect(uri.path, equals('/ul')); + expect(uri.queryParameters['ll'], equals('37.774900,-122.419400')); + expect(uri.queryParameters['navigate'], equals('yes')); + }); + + test('builds valid system geo URI', () { + final uri = NavigationHandoffService.buildSystemGeoUri( + latitude: 37.7749, + longitude: -122.4194, + displayName: 'SF Landmark', + ); + + expect(uri.scheme, equals('geo')); + expect(uri.path, equals('37.774900,-122.419400')); + expect(uri.queryParameters['q'], equals('37.774900,-122.419400(SF Landmark)')); + }); + }); +}