From 3d957fbf63001bf73f0a8516341b6faa869da190 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 23:42:59 -0400 Subject: [PATCH] feat: add live group ride experience --- .../emulator-tests/firestore.rules.test.js | 125 ++ firestore.rules | 5 +- lib/core/router.dart | 10 + lib/features/dashboard/dashboard_screen.dart | 64 +- .../group_rides/group_ride_detail_screen.dart | 26 + .../group_rides/live_group_ride_screen.dart | 1187 +++++++++++++++++ .../group_rides/group_ride_models.dart | 14 +- .../group_rides/group_ride_providers.dart | 26 + .../group_rides/group_ride_service.dart | 2 +- .../live_broadcast_controller.dart | 398 ++++++ test/live_broadcast_controller_test.dart | 349 +++++ test/live_group_ride_screen_widget_test.dart | 333 +++++ 12 files changed, 2534 insertions(+), 5 deletions(-) create mode 100644 lib/features/group_rides/live_group_ride_screen.dart create mode 100644 lib/services/group_rides/live_broadcast_controller.dart create mode 100644 test/live_broadcast_controller_test.dart create mode 100644 test/live_group_ride_screen_widget_test.dart diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 8e002f7..d2ea2d7 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -668,6 +668,131 @@ describe('Social Architecture Security Rules', () => { // 13. Bob can delete his own presence record await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'))); }); + + test('live presence lifecycle: removal, voluntary leave, completion, and mid-session block', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + const validBobPresence = { + userId: 'bob', + groupRideId: 'ride_lifecycle', + latitude: 37.7833, + longitude: -122.4167, + heading: 90.0, + speedMph: 45.0, + accuracyMeters: 5.0, + updatedAt: '2026-09-10T09:40:00Z', + expiresAt: '2026-09-10T09:42:00Z', + sharingEnabled: true, + status: 'riding', + }; + + const validAlicePresence = { + userId: 'alice', + groupRideId: 'ride_lifecycle', + latitude: 37.7830, + longitude: -122.4160, + heading: 90.0, + speedMph: 45.0, + accuracyMeters: 5.0, + updatedAt: '2026-09-10T09:40:00Z', + expiresAt: '2026-09-10T09:42:00Z', + sharingEnabled: true, + status: 'riding', + }; + + const resetLiveState = async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_lifecycle'), { + id: 'ride_lifecycle', + organizerUid: 'alice', + title: 'Lifecycle Test Run', + status: 'underway', + 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_lifecycle/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_lifecycle/live_presence/bob'), validBobPresence); + await setDoc(doc(db, 'group_rides/ride_lifecycle/live_presence/alice'), validAlicePresence); + await setDoc(doc(db, 'users/alice'), { id: 'alice' }); + await setDoc(doc(db, 'users/bob'), { id: 'bob' }); + await deleteDoc(doc(db, 'users/alice/blocks/bob')); + await deleteDoc(doc(db, 'users/bob/blocks/alice')); + }); + }; + + // Scenario A: Participant Removal (Organizer removes Rider B) + await resetLiveState(); + // Alice reads Bob initially -> succeeds + await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); + // Organizer removes Bob by deleting participant record + await assertSucceeds(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'))); + // Now Bob CANNOT write to live presence + await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { + ...validBobPresence, + speedMph: 55.0, + })); + // Alice CANNOT read Bob's old presence + await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); + + // Scenario B: Voluntary Leave (Bob leaves the ride) + await resetLiveState(); + // Bob deletes his participant record (voluntary leave) + await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'))); + // Bob CANNOT write to live presence + await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { + ...validBobPresence, + speedMph: 50.0, + })); + // Alice CANNOT read Bob's presence + await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); + + // Scenario C: Completed Ride (Ride marked completed) + await resetLiveState(); + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await updateDoc(doc(db, 'group_rides/ride_lifecycle'), { status: 'completed' }); + }); + // Bob CANNOT write to live presence + await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { + ...validBobPresence, + speedMph: 0.0, + })); + // Alice CANNOT write to live presence + await assertFails(setDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/alice'), { + ...validAlicePresence, + speedMph: 0.0, + })); + // Alice CANNOT read Bob's live presence + await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); + // Bob CANNOT read Alice's live presence + await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); + + // Scenario D: Mid-Session Block (Alice blocks Bob during active ride) + await resetLiveState(); + // Both can read initially + await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); + await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); + // Alice blocks Bob + await assertSucceeds(setDoc(doc(alice, 'users/alice/blocks/bob'), { blockedUid: 'bob' })); + // Alice CANNOT read Bob's presence + await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); + // Bob CANNOT read Alice's presence + await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); + }); }); }); diff --git a/firestore.rules b/firestore.rules index a67de5f..e061d50 100644 --- a/firestore.rules +++ b/firestore.rules @@ -318,10 +318,11 @@ service cloud.firestore { } // READ: - // Caller must be signed in, be an accepted participant of this ride, ride must be live-eligible, - // and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target). + // Caller must be signed in, be an accepted participant of this ride, target must be accepted participant, + // ride must be live-eligible, and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target). allow read: if signedIn() && isAcceptedParticipant(request.auth.uid) + && isAcceptedParticipant(presenceUid) && rideIsLiveEligible() && ( request.auth.uid == presenceUid || diff --git a/lib/core/router.dart b/lib/core/router.dart index f6f428d..b1e0e47 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -22,6 +22,7 @@ import '../features/social/friends_screen.dart'; 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'; // ignore: unused_element final _rootNavigatorKey = GlobalKey(); @@ -182,6 +183,15 @@ final appRouter = GoRouter( final id = state.pathParameters['id']!; return GroupRideDetailScreen(rideId: id); }, + routes: [ + GoRoute( + path: 'live', + builder: (context, state) { + final id = state.pathParameters['id']!; + return LiveGroupRideScreen(rideId: id); + }, + ), + ], ), ], ), diff --git a/lib/features/dashboard/dashboard_screen.dart b/lib/features/dashboard/dashboard_screen.dart index 7f8bed2..59a09af 100644 --- a/lib/features/dashboard/dashboard_screen.dart +++ b/lib/features/dashboard/dashboard_screen.dart @@ -12,8 +12,10 @@ import 'widgets/evolution_landscape_dashboard.dart'; import '../../core/theme/evolution_theme_config.dart'; import '../../services/alerts/road_alert_models.dart'; import '../../services/alerts/road_alert_service.dart'; - +import 'package:go_router/go_router.dart'; import '../../services/location_service.dart'; +import '../../services/group_rides/group_ride_models.dart'; +import '../../services/group_rides/group_ride_providers.dart'; class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); @@ -30,6 +32,7 @@ class DashboardScreen extends ConsumerWidget { final activeAlerts = alertsAsync.value ?? []; final closestAlert = activeAlerts.isNotEmpty ? activeAlerts.first : null; + final activeGroupRide = ref.watch(activeUserGroupRideProvider); TelemetrySample? telemetry = telemetryAsync.value; double currentSpeedMph = telemetry?.speed ?? 0.0; @@ -53,6 +56,7 @@ class DashboardScreen extends ConsumerWidget { children: [ if (!hasLocPermission) _buildPermissionBanner(context, ref), if (closestAlert != null) _buildAlertBanner(context, closestAlert, ref), + if (activeGroupRide != null) _buildGroupRideBanner(context, activeGroupRide), Expanded( child: LayoutBuilder( builder: (context, constraints) { @@ -155,6 +159,64 @@ class DashboardScreen extends ConsumerWidget { ); } + Widget _buildGroupRideBanner(BuildContext context, GroupRide ride) { + return InkWell( + onTap: () => context.push('/group-rides/${ride.id}/live'), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.15), + border: const Border( + bottom: BorderSide(color: Color(0xFF00E5FF), width: 1.5), + ), + ), + child: Row( + children: [ + const Icon(Icons.two_wheeler, color: Color(0xFF00E5FF), size: 20), + const SizedBox(width: 10), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + 'GROUP RIDE ACTIVE: ${ride.title.toUpperCase()}', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 0.8, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF00E5FF).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${ride.participantCount} RIDERS', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Color(0xFF00E5FF), size: 18), + ], + ), + ), + ); + } + Widget _buildPortraitLayout(BuildContext context, double speed, double heading, double lean, double accelG, double lateralG, RideSession session, WidgetRef ref, TelemetrySample? telemetry) { return Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index af16dd8..7628e11 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -1516,6 +1516,32 @@ class _GroupRideDetailScreenState extends ConsumerState { ), ], ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.map, size: 18), + label: const Text( + 'OPEN LIVE GROUP RIDE MAP', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + letterSpacing: 0.5, + ), + ), + onPressed: () { + context.push('/group-rides/${ride.id}/live'); + }, + ), + ), const Divider(height: 24, color: Colors.white12), livePresenceAsync.when( data: (presences) { diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart new file mode 100644 index 0000000..bb01469 --- /dev/null +++ b/lib/features/group_rides/live_group_ride_screen.dart @@ -0,0 +1,1187 @@ +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/group_rides/group_ride_models.dart'; +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'; + +class LiveGroupRideScreen extends ConsumerStatefulWidget { + final String rideId; + + const LiveGroupRideScreen({super.key, required this.rideId}); + + @override + ConsumerState createState() => + _LiveGroupRideScreenState(); +} + +class _LiveGroupRideScreenState extends ConsumerState { + AppLatLng? _customMapCenter; + double _mapZoom = 15.0; + + @override + Widget build(BuildContext context) { + final rideAsync = ref.watch(groupRideStreamProvider(widget.rideId)); + final participantsAsync = + ref.watch(groupRideParticipantsProvider(widget.rideId)); + final presencesAsync = + ref.watch(groupRideLivePresenceProvider(widget.rideId)); + final broadcastState = + ref.watch(liveBroadcastControllerProvider(widget.rideId)); + final broadcastController = + ref.read(liveBroadcastControllerProvider(widget.rideId).notifier); + final user = ref.watch(currentUserProvider); + final currentUid = user?.uid; + + return rideAsync.when( + data: (ride) { + if (ride == null) { + return Scaffold( + backgroundColor: const Color(0xFF0D0E11), + appBar: AppBar( + backgroundColor: const Color(0xFF13151A), + title: const Text('LIVE GROUP RIDE'), + ), + body: const Center( + child: Text( + 'Group Ride not found.', + style: TextStyle(color: Colors.white70), + ), + ), + ); + } + + final isOrganizer = currentUid != null && ride.organizerUid == currentUid; + + // Check if ride was closed + if (ride.isCompleted || ride.isCanceled) { + return Scaffold( + backgroundColor: const Color(0xFF0D0E11), + appBar: AppBar( + backgroundColor: const Color(0xFF13151A), + title: Text( + ride.title.toUpperCase(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + ), + ), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + ride.isCompleted ? Icons.flag : Icons.cancel, + color: ride.isCompleted ? Colors.greenAccent : Colors.redAccent, + size: 48, + ), + const SizedBox(height: 16), + Text( + ride.isCompleted + ? 'This Group Ride has concluded.' + : 'This Group Ride was canceled.', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF), + foregroundColor: Colors.black, + ), + onPressed: () => context.pop(), + child: const Text('BACK TO GROUP RIDES'), + ), + ], + ), + ), + ), + ); + } + + return participantsAsync.when( + data: (participants) { + final acceptedParticipants = + participants.where((p) => p.status == ParticipantStatus.accepted).toList(); + + return presencesAsync.when( + data: (presences) { + // Determine Ride Lead (first participant with lead role, or organizer) + GroupRideParticipant? leadParticipant; + try { + leadParticipant = acceptedParticipants + .firstWhere((p) => p.role == GroupRideRole.lead); + } catch (_) { + try { + leadParticipant = acceptedParticipants + .firstWhere((p) => p.role == GroupRideRole.organizer); + } catch (_) {} + } + + LiveGroupPresence? leadPresence; + if (leadParticipant != null) { + try { + leadPresence = presences + .firstWhere((p) => p.userId == leadParticipant!.userId); + } catch (_) {} + } + + // Check for Regroup signal + LiveGroupPresence? regroupCaller; + try { + regroupCaller = presences.firstWhere( + (p) => p.status == LivePresenceStatus.regrouping && p.isFresh, + ); + } catch (_) {} + + // Determine map center: custom center -> local rider -> lead -> meetup + final center = _determineMapCenter( + customCenter: _customMapCenter, + localPos: broadcastState.lastPosition, + leadPresence: leadPresence, + meetup: ride.meetingPoint, + ); + + // Build markers + final markers = _buildMarkers( + ride: ride, + participants: acceptedParticipants, + presences: presences, + currentUid: currentUid, + leadUid: leadParticipant?.userId, + ); + + return Scaffold( + backgroundColor: const Color(0xFF0D0E11), + appBar: _buildAppBar( + context: context, + ride: ride, + isOrganizer: isOrganizer, + currentUid: currentUid, + participants: acceptedParticipants, + presences: presences, + leadPresence: leadPresence, + ), + body: Stack( + children: [ + // 1. Unified Map + Positioned.fill( + child: UnifiedMapView( + center: center, + zoom: _mapZoom, + markers: markers, + onPositionChanged: (pos) { + _customMapCenter = pos; + }, + ), + ), + + // 2. Top Banners (Regroup, Master Privacy) + Positioned( + top: 8, + left: 8, + right: 8, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (regroupCaller != null) + _buildRegroupBanner( + context: context, + caller: regroupCaller, + participants: acceptedParticipants, + isLeadOrOrganizer: isOrganizer || + (leadParticipant != null && + leadParticipant.userId == currentUid), + controller: broadcastController, + ), + if (!broadcastState.isMasterAllowed) + _buildMasterPrivacyBanner(context, ref), + ], + ), + ), + + // 3. Quick Status Bar & Broadcast Floating Control + Positioned( + bottom: 12, + left: 8, + right: 8, + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Quick Status Selector + _buildQuickStatusBar( + currentStatus: broadcastState.status, + isBroadcasting: broadcastState.isBroadcasting, + onStatusSelected: (s) => + broadcastController.setStatus(s), + ), + const SizedBox(height: 8), + + // Bottom Control Strip (Opt-in toggle, Roster opener, Recenter) + _buildBottomControlStrip( + context: context, + ride: ride, + broadcastState: broadcastState, + broadcastController: broadcastController, + participants: acceptedParticipants, + presences: presences, + leadPresence: leadPresence, + currentUid: currentUid, + ), + ], + ), + ), + ), + ], + ), + ); + }, + loading: () => const Scaffold( + backgroundColor: Color(0xFF0D0E11), + body: Center( + child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + ), + ), + error: (e, _) => Scaffold( + backgroundColor: const Color(0xFF0D0E11), + body: Center( + child: Text('Error loading live presence: $e', + style: const TextStyle(color: Colors.redAccent)), + ), + ), + ); + }, + loading: () => const Scaffold( + backgroundColor: Color(0xFF0D0E11), + body: Center( + child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + ), + ), + error: (e, _) => Scaffold( + backgroundColor: const Color(0xFF0D0E11), + body: Center( + child: Text('Error loading participants: $e', + style: const TextStyle(color: Colors.redAccent)), + ), + ), + ); + }, + loading: () => const Scaffold( + backgroundColor: Color(0xFF0D0E11), + body: Center( + child: CircularProgressIndicator(color: Color(0xFF00E5FF)), + ), + ), + error: (e, _) => Scaffold( + backgroundColor: const Color(0xFF0D0E11), + body: Center( + child: Text('Error loading ride: $e', + style: const TextStyle(color: Colors.redAccent)), + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar({ + required BuildContext context, + required GroupRide ride, + required bool isOrganizer, + required String? currentUid, + required List participants, + required List presences, + required LiveGroupPresence? leadPresence, + }) { + return AppBar( + backgroundColor: const Color(0xFF13151A), + elevation: 3, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Back', + onPressed: () => context.pop(), + ), + titleSpacing: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + ride.title.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: ride.status == GroupRideStatus.underway + ? const Color(0xFF00E5FF).withValues(alpha: 0.2) + : Colors.amber.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + ride.status.label.toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w900, + color: ride.status == GroupRideStatus.underway + ? const Color(0xFF00E5FF) + : Colors.amber, + ), + ), + ), + ], + ), + Text( + '${presences.length} active of ${participants.length} riders', + style: const TextStyle(color: Colors.white54, fontSize: 10), + ), + ], + ), + actions: [ + if (isOrganizer) + IconButton( + icon: const Icon(Icons.stop_circle_outlined, color: Colors.redAccent), + tooltip: 'End Ride', + onPressed: () => _confirmEndRide(context, ride, currentUid), + ), + IconButton( + icon: const Icon(Icons.people_outline, color: Color(0xFF00E5FF)), + tooltip: 'Roster', + onPressed: () => _showRosterSheet( + context: context, + participants: participants, + presences: presences, + leadPresence: leadPresence, + currentUid: currentUid, + ), + ), + ], + ); + } + + Widget _buildRegroupBanner({ + required BuildContext context, + required LiveGroupPresence caller, + required List participants, + required bool isLeadOrOrganizer, + required LiveBroadcastController controller, + }) { + String callerName = caller.userId; + try { + final p = participants.firstWhere((p) => p.userId == caller.userId); + callerName = p.riderDisplayName.isNotEmpty ? p.riderDisplayName : p.displayHandle; + } catch (_) {} + + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + boxShadow: const [ + BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.black, size: 22), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'REGROUP CALLED', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w900, + fontSize: 12, + letterSpacing: 0.5, + ), + ), + Text( + 'By $callerName • Check map & slow to regroup', + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.black87, fontSize: 11), + ), + ], + ), + ), + const SizedBox(width: 6), + if (isLeadOrOrganizer) + TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + ), + onPressed: () => controller.clearRegroup(), + child: const Text('CLEAR', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold)), + ) + else + TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + ), + onPressed: () => controller.triggerRegroup(), + child: const Text('ACK', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + Widget _buildMasterPrivacyBanner(BuildContext context, WidgetRef ref) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF1E222D), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.withValues(alpha: 0.6)), + ), + child: Row( + children: [ + const Icon(Icons.lock_outline, color: Colors.amber, size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Master Location Privacy is OFF. Location cannot be broadcast.', + style: TextStyle(color: Colors.white70, fontSize: 11), + ), + ), + const SizedBox(width: 6), + TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.amber, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + ), + onPressed: () { + ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true; + }, + child: const Text('ENABLE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + Widget _buildQuickStatusBar({ + required LivePresenceStatus currentStatus, + required bool isBroadcasting, + required ValueChanged onStatusSelected, + }) { + final statuses = [ + (LivePresenceStatus.riding, 'RIDING', Icons.two_wheeler), + (LivePresenceStatus.stopped, 'STOPPED', Icons.pause_circle_outline), + (LivePresenceStatus.needFuel, 'NEED FUEL', Icons.local_gas_station), + (LivePresenceStatus.regrouping, 'REGROUP', Icons.group_work_outlined), + (LivePresenceStatus.delayed, 'DELAYED', Icons.access_time), + (LivePresenceStatus.arrived, 'ARRIVED', Icons.flag_outlined), + ]; + + return Container( + height: 46, + decoration: BoxDecoration( + color: const Color(0xFF161922).withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white12), + boxShadow: const [ + BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)), + ], + ), + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + scrollDirection: Axis.horizontal, + itemCount: statuses.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final (status, label, icon) = statuses[index]; + final isSelected = currentStatus == status; + + Color activeColor = const Color(0xFF00E5FF); + if (status == LivePresenceStatus.needFuel) activeColor = Colors.orangeAccent; + if (status == LivePresenceStatus.regrouping) activeColor = Colors.amber; + if (status == LivePresenceStatus.arrived) activeColor = Colors.greenAccent; + + return InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => onStatusSelected(status), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected + ? activeColor.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSelected ? activeColor : Colors.transparent, + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: isSelected ? activeColor : Colors.white60, + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + color: isSelected ? activeColor : Colors.white70, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + fontSize: 11, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildBottomControlStrip({ + required BuildContext context, + required GroupRide ride, + required LiveBroadcastState broadcastState, + required LiveBroadcastController broadcastController, + required List participants, + required List presences, + required LiveGroupPresence? leadPresence, + required String? currentUid, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF161922).withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white12), + boxShadow: const [ + BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)), + ], + ), + child: Row( + children: [ + // Ghost mode / Broadcast switch + Expanded( + child: Row( + children: [ + Icon( + broadcastState.isBroadcasting + ? Icons.radar + : Icons.location_off, + color: broadcastState.isBroadcasting + ? const Color(0xFF00E5FF) + : Colors.white38, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + broadcastState.isBroadcasting + ? 'SHARING LIVE' + : 'GHOST MODE', + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: broadcastState.isBroadcasting + ? const Color(0xFF00E5FF) + : Colors.white60, + fontWeight: FontWeight.bold, + fontSize: 11, + letterSpacing: 0.5, + ), + ), + Text( + broadcastState.isBroadcasting + ? 'Broadcasting to riders' + : 'Location paused', + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white38, fontSize: 10), + ), + ], + ), + ), + Switch( + value: broadcastState.isOptedIn, + activeThumbColor: const Color(0xFF00E5FF), + onChanged: (val) => broadcastController.setOptIn(val), + ), + ], + ), + ), + + const SizedBox(width: 8), + + // Recenter Map Button + IconButton( + style: IconButton.styleFrom( + backgroundColor: Colors.white10, + padding: const EdgeInsets.all(8), + minimumSize: const Size(36, 36), + ), + icon: const Icon(Icons.my_location, color: Colors.white70, size: 18), + tooltip: 'Center on Me', + onPressed: () { + if (broadcastState.lastPosition != null) { + setState(() { + _customMapCenter = AppLatLng( + broadcastState.lastPosition!.latitude, + broadcastState.lastPosition!.longitude, + ); + _mapZoom = 15.0; + }); + } else { + setState(() { + _customMapCenter = AppLatLng( + ride.meetingPoint.latitude, + ride.meetingPoint.longitude, + ); + }); + } + }, + ), + + const SizedBox(width: 6), + + // Roster Opener Button + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.15), + foregroundColor: const Color(0xFF00E5FF), + side: const BorderSide(color: Color(0xFF00E5FF)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: Size.zero, + ), + icon: const Icon(Icons.format_list_bulleted, size: 15), + label: Text( + '${participants.length}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + onPressed: () => _showRosterSheet( + context: context, + participants: participants, + presences: presences, + leadPresence: leadPresence, + currentUid: currentUid, + ), + ), + ], + ), + ); + } + + void _showRosterSheet({ + required BuildContext context, + required List participants, + required List presences, + required LiveGroupPresence? leadPresence, + required String? currentUid, + }) { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF161922), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + const Icon(Icons.people, color: Color(0xFF00E5FF), size: 18), + const SizedBox(width: 8), + const Text( + 'LIVE RIDER ROSTER', + style: TextStyle( + color: Color(0xFF00E5FF), + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 13, + ), + ), + const Spacer(), + Text( + '${presences.length}/${participants.length} Active', + style: const TextStyle(color: Colors.white54, fontSize: 11), + ), + ], + ), + const Divider(height: 20, color: Colors.white12), + for (final p in participants) ...[ + _buildRosterItem( + participant: p, + presence: _findPresence(presences, p.userId), + leadPresence: leadPresence, + isCaller: p.userId == currentUid, + onCenter: (pos) { + Navigator.pop(ctx); + setState(() { + _customMapCenter = pos; + _mapZoom = 16.0; + }); + }, + ), + const SizedBox(height: 8), + ], + ], + ); + }, + ); + } + + Widget _buildRosterItem({ + required GroupRideParticipant participant, + required LiveGroupPresence? presence, + required LiveGroupPresence? leadPresence, + required bool isCaller, + required ValueChanged onCenter, + }) { + // Freshness + final isLive = presence != null && presence.isFresh; + final isRecent = presence != null && presence.isRecent; + final isOffline = presence == null || presence.isStale; + + // Straight-line distance from Lead + String distanceString = ''; + if (participant.role == GroupRideRole.lead) { + distanceString = 'RIDE LEAD'; + } else if (presence != null && leadPresence != null) { + final meters = Geolocator.distanceBetween( + presence.latitude, + presence.longitude, + leadPresence.latitude, + leadPresence.longitude, + ); + final miles = meters * 0.000621371; + if (miles < 0.05) { + distanceString = 'Pack (with Lead)'; + } else { + distanceString = '${miles.toStringAsFixed(1)} mi from Lead'; + } + } + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF1B1E28), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCaller + ? const Color(0xFF00E5FF).withValues(alpha: 0.5) + : Colors.white10, + ), + ), + child: Row( + children: [ + // Avatar + Role badge + Stack( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Colors.white10, + child: Text( + participant.riderDisplayName.isNotEmpty + ? participant.riderDisplayName[0].toUpperCase() + : '?', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + if (isLive) + const Positioned( + bottom: 0, + right: 0, + child: CircleAvatar( + radius: 5, + backgroundColor: Colors.greenAccent, + ), + ), + ], + ), + const SizedBox(width: 10), + + // Name and Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + participant.riderDisplayName.isNotEmpty + ? participant.riderDisplayName + : participant.displayHandle, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isCaller ? const Color(0xFF00E5FF) : Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + if (participant.role != GroupRideRole.participant) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: _roleColor(participant.role).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + participant.role.label.toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: _roleColor(participant.role), + ), + ), + ), + ], + ], + ), + const SizedBox(height: 3), + Row( + children: [ + // Status Badge + if (presence != null) + Text( + presence.status.label.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _statusColor(presence.status), + ), + ) + else + const Text( + 'OFFLINE', + style: TextStyle(color: Colors.white38, fontSize: 10), + ), + + if (distanceString.isNotEmpty) ...[ + const Text(' • ', style: TextStyle(color: Colors.white24, fontSize: 10)), + Text( + distanceString, + style: const TextStyle(color: Colors.white60, fontSize: 10), + ), + ], + ], + ), + ], + ), + ), + + // Freshness / Recenter button + if (presence != null && !isOffline) ...[ + Text( + isLive ? 'LIVE' : '${presence.secondsAgo}s ago', + style: TextStyle( + color: isLive + ? Colors.greenAccent + : (isRecent ? Colors.amber : Colors.white38), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + IconButton( + icon: const Icon(Icons.gps_fixed, size: 16, color: Color(0xFF00E5FF)), + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + tooltip: 'Center on rider', + onPressed: () => onCenter(AppLatLng(presence.latitude, presence.longitude)), + ), + ] else + const Text( + 'OFFLINE', + style: TextStyle(color: Colors.white38, fontSize: 10), + ), + ], + ), + ); + } + + Color _roleColor(GroupRideRole role) { + switch (role) { + case GroupRideRole.organizer: + return const Color(0xFF00E5FF); + case GroupRideRole.lead: + return const Color(0xFFFFD700); + case GroupRideRole.sweep: + return Colors.orangeAccent; + case GroupRideRole.participant: + return Colors.white70; + } + } + + Color _statusColor(LivePresenceStatus status) { + switch (status) { + case LivePresenceStatus.riding: + return Colors.greenAccent; + case LivePresenceStatus.stopped: + return Colors.white70; + case LivePresenceStatus.needFuel: + return Colors.orangeAccent; + case LivePresenceStatus.regrouping: + return Colors.amber; + case LivePresenceStatus.delayed: + return Colors.redAccent; + case LivePresenceStatus.arrived: + return const Color(0xFF00E5FF); + case LivePresenceStatus.offline: + return Colors.white38; + } + } + + LiveGroupPresence? _findPresence( + List presences, String uid) { + try { + return presences.firstWhere((p) => p.userId == uid); + } catch (_) { + return null; + } + } + + AppLatLng _determineMapCenter({ + required AppLatLng? customCenter, + required Position? localPos, + required LiveGroupPresence? leadPresence, + required GroupRideLocation meetup, + }) { + if (customCenter != null) return customCenter; + if (localPos != null) return AppLatLng(localPos.latitude, localPos.longitude); + if (leadPresence != null) { + return AppLatLng(leadPresence.latitude, leadPresence.longitude); + } + return AppLatLng(meetup.latitude, meetup.longitude); + } + + List _buildMarkers({ + required GroupRide ride, + required List participants, + required List presences, + required String? currentUid, + required String? leadUid, + }) { + final markers = []; + + // 1. Meeting Point Marker + markers.add( + AppMapMarker( + id: 'meetup_point', + position: AppLatLng(ride.meetingPoint.latitude, ride.meetingPoint.longitude), + label: ride.meetingPoint.displayName.isNotEmpty + ? ride.meetingPoint.displayName + : 'Meetup', + iconWidget: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.greenAccent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: const Icon(Icons.flag, color: Colors.black, size: 16), + ), + ), + ); + + // 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, + ), + ), + ), + ), + ); + } + + // 3. Destination Marker + if (ride.destination != null) { + markers.add( + AppMapMarker( + id: 'destination_point', + position: AppLatLng(ride.destination!.latitude, ride.destination!.longitude), + label: ride.destination!.displayName.isNotEmpty + ? ride.destination!.displayName + : 'Destination', + iconWidget: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: const Icon(Icons.sports_score, color: Colors.white, size: 16), + ), + ), + ); + } + + // 4. Live Rider Markers + for (final presence in presences) { + GroupRideParticipant? p; + try { + p = participants.firstWhere((item) => item.userId == presence.userId); + } catch (_) {} + + final isLead = presence.userId == leadUid || p?.role == GroupRideRole.lead; + final isSweep = p?.role == GroupRideRole.sweep; + final isCaller = presence.userId == currentUid; + + // Color scheme + Color markerColor = const Color(0xFF00E5FF); + if (isLead) markerColor = const Color(0xFFFFD700); // Gold for lead + if (isSweep) markerColor = Colors.orangeAccent; // Orange for sweep + if (presence.status == LivePresenceStatus.regrouping) markerColor = Colors.amber; + if (presence.status == LivePresenceStatus.needFuel) markerColor = Colors.deepOrangeAccent; + + final isStale = presence.isStale; + final opacity = isStale ? 0.4 : 1.0; + + markers.add( + AppMapMarker( + id: 'rider_${presence.userId}', + position: AppLatLng(presence.latitude, presence.longitude), + heading: presence.heading ?? 0.0, + label: p != null ? p.displayHandle : presence.userId, + iconWidget: Opacity( + opacity: opacity, + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: markerColor, + shape: BoxShape.circle, + border: Border.all( + color: isCaller ? Colors.white : Colors.black, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: markerColor.withValues(alpha: 0.6), + blurRadius: 6, + spreadRadius: 1, + ), + ], + ), + child: Icon( + isLead + ? Icons.navigation + : (isSweep ? Icons.shield : Icons.navigation), + color: Colors.black, + size: 20, + ), + ), + ), + ), + ); + } + + return markers; + } + + void _confirmEndRide( + BuildContext context, GroupRide ride, String? currentUid) { + if (currentUid == null) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF161922), + title: const Text('End Group Ride?', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + content: const Text( + 'This will conclude the group ride for all participants and terminate live location sharing.', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('CANCEL', style: TextStyle(color: Colors.white60)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + ), + onPressed: () async { + Navigator.pop(ctx); + final service = ref.read(groupRideServiceProvider); + await service.updateRideStatus( + callerUid: currentUid, + groupRideId: ride.id, + newStatus: GroupRideStatus.completed, + ); + if (context.mounted) { + context.pop(); + } + }, + child: const Text('END RIDE'), + ), + ], + ), + ); + } +} diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index bce6b4d..fceb164 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -561,6 +561,7 @@ class GroupRide { enum LivePresenceStatus { riding, stopped, + needFuel, regrouping, delayed, arrived, @@ -572,8 +573,10 @@ enum LivePresenceStatus { return 'Riding'; case LivePresenceStatus.stopped: return 'Stopped'; + case LivePresenceStatus.needFuel: + return 'Need Fuel'; case LivePresenceStatus.regrouping: - return 'Regrouping'; + return 'Regroup'; case LivePresenceStatus.delayed: return 'Delayed'; case LivePresenceStatus.arrived: @@ -620,6 +623,15 @@ class LiveGroupPresence { bool get isFresh => sharingEnabled && DateTime.now().difference(updatedAt).inSeconds < 60; + /// Location is considered recent if between 60 and 120 seconds old. + bool get isRecent { + final diff = DateTime.now().difference(updatedAt).inSeconds; + return sharingEnabled && diff >= 60 && diff < 120; + } + + /// Elapsed seconds since last update. + int get secondsAgo => DateTime.now().difference(updatedAt).inSeconds; + /// Location is considered stale if over 120 seconds old or sharing was disabled. bool get isStale => !sharingEnabled || DateTime.now().difference(updatedAt).inSeconds >= 120; diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart index c9bb79d..6e7e0d5 100644 --- a/lib/services/group_rides/group_ride_providers.dart +++ b/lib/services/group_rides/group_ride_providers.dart @@ -49,3 +49,29 @@ final groupRideLiveSharingAllowedProvider = StateProvider((ref) { final activeLiveRideSharingIdProvider = StateProvider((ref) { return null; }); + +/// Provider that returns the first active group ride for the current user (assembling or underway). +final activeUserGroupRideProvider = Provider((ref) { + final upcomingAsync = ref.watch(upcomingGroupRidesProvider); + final activeSharingId = ref.watch(activeLiveRideSharingIdProvider); + + return upcomingAsync.when( + data: (rides) { + if (activeSharingId != null) { + try { + return rides.firstWhere((r) => r.id == activeSharingId); + } catch (_) {} + } + try { + return rides.firstWhere((r) => + r.status == GroupRideStatus.underway || + r.status == GroupRideStatus.assembling); + } catch (_) { + return null; + } + }, + loading: () => null, + error: (_, _) => null, + ); +}); + diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index fff4231..c3e1bc4 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -819,7 +819,7 @@ class GroupRideService { } }) .whereType() - .where((p) => p.sharingEnabled && !p.isStale) + .where((p) => p.sharingEnabled) .toList(); }); } diff --git a/lib/services/group_rides/live_broadcast_controller.dart b/lib/services/group_rides/live_broadcast_controller.dart new file mode 100644 index 0000000..50c27c7 --- /dev/null +++ b/lib/services/group_rides/live_broadcast_controller.dart @@ -0,0 +1,398 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; +import '../auth/auth_providers.dart'; +import 'group_ride_models.dart'; +import 'group_ride_providers.dart'; +import 'group_ride_service.dart'; + +/// State of the local rider's live presence broadcast for a specific Group Ride. +@immutable +class LiveBroadcastState { + final bool isOptedIn; + final bool isMasterAllowed; + final bool isEligible; + final bool isBroadcasting; + final LivePresenceStatus status; + final Position? lastPosition; + final DateTime? lastPublishedAt; + final String? errorMessage; + final bool isRegroupActive; + + const LiveBroadcastState({ + this.isOptedIn = false, + this.isMasterAllowed = false, + this.isEligible = false, + this.isBroadcasting = false, + this.status = LivePresenceStatus.riding, + this.lastPosition, + this.lastPublishedAt, + this.errorMessage, + this.isRegroupActive = false, + }); + + LiveBroadcastState copyWith({ + bool? isOptedIn, + bool? isMasterAllowed, + bool? isEligible, + bool? isBroadcasting, + LivePresenceStatus? status, + Position? lastPosition, + DateTime? lastPublishedAt, + String? errorMessage, + bool? isRegroupActive, + }) { + return LiveBroadcastState( + isOptedIn: isOptedIn ?? this.isOptedIn, + isMasterAllowed: isMasterAllowed ?? this.isMasterAllowed, + isEligible: isEligible ?? this.isEligible, + isBroadcasting: isBroadcasting ?? this.isBroadcasting, + status: status ?? this.status, + lastPosition: lastPosition ?? this.lastPosition, + lastPublishedAt: lastPublishedAt ?? this.lastPublishedAt, + errorMessage: errorMessage, + isRegroupActive: isRegroupActive ?? this.isRegroupActive, + ); + } +} + +/// Controller responsible for ephemeral live location broadcasts in an active Group Ride. +/// +/// Implements dual-gate privacy (global + per-ride), 5s moving / 30s stopped heartbeat, +/// 30m accuracy filtering, and immediate termination on authorization loss. +class LiveBroadcastController extends StateNotifier { + final String rideId; + final String callerUid; + final GroupRideService service; + final Stream? positionStream; + final Stream? rideStream; + final Stream>? participantsStream; + final Stream>? presenceStream; + + StreamSubscription? _positionSub; + StreamSubscription? _rideSub; + StreamSubscription>? _participantsSub; + StreamSubscription>? _presenceSub; + Timer? _heartbeatTimer; + + Position? _lastBroadcastPosition; + DateTime? _lastBroadcastTime; + bool _isDisposed = false; + + LiveBroadcastController({ + required this.rideId, + required this.callerUid, + required this.service, + this.positionStream, + this.rideStream, + this.participantsStream, + this.presenceStream, + bool initialMasterAllowed = false, + }) : super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { + _initSubscriptions(); + } + + void _initSubscriptions() { + // 1. Listen to Ride Lifecycle + final activeRideStream = rideStream ?? service.groupRideStream(rideId); + _rideSub = activeRideStream.listen(_handleRideUpdate, onError: (e) { + debugPrint('[LiveBroadcastController] Ride stream error: $e'); + }); + + // 2. Listen to Participants (authorization check) + final activeParticipantsStream = + participantsStream ?? service.participantsStream(rideId); + _participantsSub = + activeParticipantsStream.listen(_handleParticipantsUpdate, onError: (e) { + debugPrint('[LiveBroadcastController] Participants stream error: $e'); + }); + + // 3. Listen to Live Presence (for regroup awareness) + final activePresenceStream = + presenceStream ?? service.livePresenceStream(rideId); + _presenceSub = activePresenceStream.listen(_handlePresenceUpdate, onError: (e) { + debugPrint('[LiveBroadcastController] Presence stream error: $e'); + }); + } + + void _handleRideUpdate(GroupRide? ride) { + if (_isDisposed) return; + if (ride == null) { + _revokeBroadcasting('Ride not found'); + return; + } + + // Ride must be assembling or underway + final isRideActive = ride.status == GroupRideStatus.assembling || + ride.status == GroupRideStatus.underway; + + if (!isRideActive) { + // Completed, canceled, or scheduled -> stop broadcast + _revokeBroadcasting('Ride is not in active state (${ride.status.name})'); + return; + } + + _evaluateEligibility(); + } + + void _handleParticipantsUpdate(List participants) { + if (_isDisposed) return; + final myParticipant = participants.where((p) => p.userId == callerUid).toList(); + + if (myParticipant.isEmpty || + myParticipant.first.status != ParticipantStatus.accepted) { + // User removed, declined, or left -> immediate revocation + _revokeBroadcasting('Caller is not an accepted participant'); + return; + } + + _evaluateEligibility(hasAcceptedMembership: true); + } + + void _handlePresenceUpdate(List presences) { + if (_isDisposed) return; + final regroupActive = presences.any((p) => + p.status == LivePresenceStatus.regrouping && p.isFresh); + + if (regroupActive != state.isRegroupActive) { + state = state.copyWith(isRegroupActive: regroupActive); + } + } + + void _evaluateEligibility({bool? hasAcceptedMembership}) { + final eligible = hasAcceptedMembership ?? state.isEligible; + final broadcasting = state.isOptedIn && state.isMasterAllowed && eligible; + + state = state.copyWith( + isEligible: eligible, + isBroadcasting: broadcasting, + ); + + if (broadcasting && _positionSub == null) { + _startBroadcasting(); + } else if (!broadcasting && _positionSub != null) { + _stopBroadcasting(); + } + } + + /// Updates the master privacy setting (global app setting). + void updateMasterAllowed(bool allowed) { + if (_isDisposed || state.isMasterAllowed == allowed) return; + state = state.copyWith(isMasterAllowed: allowed); + _evaluateEligibility(); + } + + /// Toggles per-ride opt-in for location broadcasting. + Future setOptIn(bool optIn) async { + if (_isDisposed) return; + if (state.isOptedIn == optIn) return; + + state = state.copyWith(isOptedIn: optIn); + _evaluateEligibility(); + + if (!optIn) { + // Immediately clear presence document in Firestore + await service.clearLivePresence( + callerUid: callerUid, + groupRideId: rideId, + ); + } else if (state.isBroadcasting && state.lastPosition != null) { + // Force immediate publish on opt-in + await _publishPosition(state.lastPosition!, force: true); + } + } + + /// Updates rider quick status (e.g. riding, needFuel, regrouping, stopped, arrived). + Future setStatus(LivePresenceStatus newStatus) async { + if (_isDisposed || state.status == newStatus) return; + state = state.copyWith(status: newStatus); + + if (state.isBroadcasting && state.lastPosition != null) { + await _publishPosition(state.lastPosition!, force: true); + } + } + + /// Calls for a group regroup. + Future triggerRegroup() async { + await setStatus(LivePresenceStatus.regrouping); + } + + /// Clears regroup call by switching back to riding. + Future clearRegroup() async { + await setStatus(LivePresenceStatus.riding); + } + + void _startBroadcasting() { + _positionSub?.cancel(); + final stream = positionStream ?? Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, + ), + ); + + _positionSub = stream.listen( + _onPositionUpdate, + onError: (e) { + state = state.copyWith(errorMessage: 'Location stream error: $e'); + }, + ); + + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _checkHeartbeat(); + }); + } + + void _stopBroadcasting() { + _positionSub?.cancel(); + _positionSub = null; + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + } + + void _onPositionUpdate(Position position) { + if (_isDisposed || !state.isBroadcasting) return; + + // Filter accuracy: reject fixes with accuracy > 30m + if (position.accuracy > 30.0) { + return; + } + + state = state.copyWith(lastPosition: position); + + final speedMph = position.speed * 2.23694; + final isMoving = speedMph > 3.0; + + final now = DateTime.now(); + final lastTime = _lastBroadcastTime; + + if (lastTime == null) { + _publishPosition(position, force: true); + return; + } + + final elapsedSeconds = now.difference(lastTime).inSeconds; + + if (isMoving) { + // Moving cadence: 5 seconds with displacement > 15m or >= 5 seconds interval + if (elapsedSeconds >= 5) { + double displacement = 0; + if (_lastBroadcastPosition != null) { + displacement = Geolocator.distanceBetween( + _lastBroadcastPosition!.latitude, + _lastBroadcastPosition!.longitude, + position.latitude, + position.longitude, + ); + } + if (displacement >= 15.0 || elapsedSeconds >= 10) { + _publishPosition(position); + } + } + } else { + // Stopped cadence: 30-second heartbeat + if (elapsedSeconds >= 30) { + _publishPosition(position); + } + } + } + + void _checkHeartbeat() { + if (_isDisposed || !state.isBroadcasting || state.lastPosition == null) return; + final lastTime = _lastBroadcastTime; + if (lastTime == null) return; + + final elapsed = DateTime.now().difference(lastTime).inSeconds; + // If stopped or no fix received, keep heartbeat within 30s + if (elapsed >= 30) { + _publishPosition(state.lastPosition!, force: true); + } + } + + Future _publishPosition(Position position, {bool force = false}) async { + if (!state.isBroadcasting && !force) return; + + final speedMph = position.speed * 2.23694; + final now = DateTime.now(); + + try { + await service.publishLivePresence( + callerUid: callerUid, + groupRideId: rideId, + latitude: position.latitude, + longitude: position.longitude, + heading: position.heading, + speedMph: speedMph, + accuracyMeters: position.accuracy, + status: state.status, + sharingEnabled: true, + ); + + _lastBroadcastPosition = position; + _lastBroadcastTime = now; + state = state.copyWith( + lastPublishedAt: now, + errorMessage: null, + ); + } catch (e) { + state = state.copyWith(errorMessage: 'Publish failed: $e'); + } + } + + void _revokeBroadcasting(String reason) { + state = state.copyWith( + isEligible: false, + isBroadcasting: false, + errorMessage: reason, + ); + _stopBroadcasting(); + + // Clean up presence document + service.clearLivePresence( + callerUid: callerUid, + groupRideId: rideId, + ); + } + + @override + void dispose() { + _isDisposed = true; + _stopBroadcasting(); + _rideSub?.cancel(); + _participantsSub?.cancel(); + _presenceSub?.cancel(); + + // Clean up ephemeral presence on exit + service.clearLivePresence( + callerUid: callerUid, + groupRideId: rideId, + ); + super.dispose(); + } +} + +/// Provider for LiveBroadcastController family parameterized by group ride ID. +final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose + .family( + (ref, rideId) { + final user = ref.watch(currentUserProvider); + final service = ref.watch(groupRideServiceProvider); + final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider); + final callerUid = user?.uid ?? ''; + + final controller = LiveBroadcastController( + rideId: rideId, + callerUid: callerUid, + service: service, + initialMasterAllowed: masterAllowed, + ); + + // Sync master privacy changes to controller + ref.listen(groupRideLiveSharingAllowedProvider, (prev, next) { + controller.updateMasterAllowed(next); + }); + + return controller; +}); diff --git a/test/live_broadcast_controller_test.dart b/test/live_broadcast_controller_test.dart new file mode 100644 index 0000000..9d00f11 --- /dev/null +++ b/test/live_broadcast_controller_test.dart @@ -0,0 +1,349 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart'; +import 'package:motorcycle_dash/services/group_rides/live_broadcast_controller.dart'; + +class MockGroupRideService extends GroupRideService { + final List> publishedPresences = []; + final List clearedPresences = []; + + @override + Future publishLivePresence({ + required String callerUid, + required String groupRideId, + required double latitude, + required double longitude, + double? heading, + double? speedMph, + double? accuracyMeters, + LivePresenceStatus status = LivePresenceStatus.riding, + bool sharingEnabled = true, + }) async { + publishedPresences.add({ + 'callerUid': callerUid, + 'groupRideId': groupRideId, + 'latitude': latitude, + 'longitude': longitude, + 'heading': heading, + 'speedMph': speedMph, + 'accuracyMeters': accuracyMeters, + 'status': status, + 'sharingEnabled': sharingEnabled, + }); + } + + @override + Future clearLivePresence({ + required String callerUid, + required String groupRideId, + }) async { + clearedPresences.add('$callerUid:$groupRideId'); + } +} + +Position _createPosition({ + required double latitude, + required double longitude, + double speed = 15.0, // m/s (~33 mph) + double accuracy = 5.0, + double heading = 90.0, +}) { + return Position( + latitude: latitude, + longitude: longitude, + timestamp: DateTime.now(), + accuracy: accuracy, + altitude: 100.0, + altitudeAccuracy: 5.0, + heading: heading, + headingAccuracy: 2.0, + speed: speed, + speedAccuracy: 1.0, + ); +} + +void main() { + group('LiveBroadcastController Unit Tests', () { + late MockGroupRideService mockService; + late StreamController rideController; + late StreamController> participantsController; + late StreamController> presenceController; + late StreamController positionController; + + final now = DateTime.now(); + final activeRide = GroupRide( + id: 'ride_live_1', + organizerUid: 'alice', + organizerHandle: 'alice_apex', + organizerDisplayName: 'Alice Apex', + title: 'Twisties Run', + status: GroupRideStatus.underway, + scheduledStartAt: now, + departureAt: now, + participantCount: 2, + meetingPoint: const GroupRideLocation( + latitude: 34.5, + longitude: -84.1, + displayName: 'Start', + ), + createdAt: now, + updatedAt: now, + ); + + final acceptedParticipant = GroupRideParticipant( + userId: 'bob', + role: GroupRideRole.participant, + status: ParticipantStatus.accepted, + riderHandle: 'bob_rider', + riderDisplayName: 'Bob Rider', + createdAt: now, + updatedAt: now, + ); + + setUp(() { + mockService = MockGroupRideService(); + rideController = StreamController.broadcast(); + participantsController = + StreamController>.broadcast(); + presenceController = + StreamController>.broadcast(); + positionController = StreamController.broadcast(); + }); + + tearDown(() { + rideController.close(); + participantsController.close(); + presenceController.close(); + positionController.close(); + }); + + test('dual-gate privacy: requires both master allowed and per-ride opt-in', + () async { + final controller = LiveBroadcastController( + rideId: 'ride_live_1', + callerUid: 'bob', + service: mockService, + rideStream: rideController.stream, + participantsStream: participantsController.stream, + presenceStream: presenceController.stream, + positionStream: positionController.stream, + initialMasterAllowed: false, + ); + + // Initially neither is enabled + expect(controller.state.isMasterAllowed, isFalse); + expect(controller.state.isOptedIn, isFalse); + expect(controller.state.isBroadcasting, isFalse); + + // Emit active ride and accepted membership + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isEligible, isTrue); + expect(controller.state.isBroadcasting, isFalse); + + // Enable master allowed only + controller.updateMasterAllowed(true); + expect(controller.state.isMasterAllowed, isTrue); + expect(controller.state.isBroadcasting, isFalse); // Still false because optIn is false + + // Enable optIn + await controller.setOptIn(true); + expect(controller.state.isOptedIn, isTrue); + expect(controller.state.isBroadcasting, isTrue); + + controller.dispose(); + }); + + test('accuracy filtering: ignores fixes with accuracy > 30m', () async { + final controller = LiveBroadcastController( + rideId: 'ride_live_1', + callerUid: 'bob', + service: mockService, + rideStream: rideController.stream, + participantsStream: participantsController.stream, + presenceStream: presenceController.stream, + positionStream: positionController.stream, + initialMasterAllowed: true, + ); + + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + // Emit fix with 50m accuracy (should be rejected) + positionController.add(_createPosition( + latitude: 34.501, + longitude: -84.101, + accuracy: 50.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.lastPosition, isNull); + expect(mockService.publishedPresences, isEmpty); + + // Emit fix with 10m accuracy (should be accepted) + positionController.add(_createPosition( + latitude: 34.502, + longitude: -84.102, + accuracy: 10.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.lastPosition, isNotNull); + expect(mockService.publishedPresences.length, equals(1)); + expect(mockService.publishedPresences.first['latitude'], equals(34.502)); + + controller.dispose(); + }); + + test('quick statuses: setting needFuel or regroup updates and publishes', + () async { + final controller = LiveBroadcastController( + rideId: 'ride_live_1', + callerUid: 'bob', + service: mockService, + rideStream: rideController.stream, + participantsStream: participantsController.stream, + presenceStream: presenceController.stream, + positionStream: positionController.stream, + initialMasterAllowed: true, + ); + + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + // Emit valid initial position + positionController.add(_createPosition( + latitude: 34.505, + longitude: -84.105, + accuracy: 5.0, + )); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(mockService.publishedPresences.length, equals(1)); + expect(mockService.publishedPresences.last['status'], + equals(LivePresenceStatus.riding)); + + // Select NEED FUEL + await controller.setStatus(LivePresenceStatus.needFuel); + expect(controller.state.status, equals(LivePresenceStatus.needFuel)); + expect(mockService.publishedPresences.last['status'], + equals(LivePresenceStatus.needFuel)); + + // Trigger REGROUP + await controller.triggerRegroup(); + expect(controller.state.status, equals(LivePresenceStatus.regrouping)); + expect(mockService.publishedPresences.last['status'], + equals(LivePresenceStatus.regrouping)); + + // Clear REGROUP + await controller.clearRegroup(); + expect(controller.state.status, equals(LivePresenceStatus.riding)); + expect(mockService.publishedPresences.last['status'], + equals(LivePresenceStatus.riding)); + + controller.dispose(); + }); + + test('authorization loss: ride completion immediately clears presence', + () async { + final controller = LiveBroadcastController( + rideId: 'ride_live_1', + callerUid: 'bob', + service: mockService, + rideStream: rideController.stream, + participantsStream: participantsController.stream, + presenceStream: presenceController.stream, + positionStream: positionController.stream, + initialMasterAllowed: true, + ); + + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + // Ride concludes + final completedRide = activeRide.copyWith(status: GroupRideStatus.completed); + rideController.add(completedRide); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isEligible, isFalse); + expect(controller.state.isBroadcasting, isFalse); + expect(mockService.clearedPresences, contains('bob:ride_live_1')); + + controller.dispose(); + }); + + test('authorization loss: participant removal immediately revokes and clears', + () async { + final controller = LiveBroadcastController( + rideId: 'ride_live_1', + callerUid: 'bob', + service: mockService, + rideStream: rideController.stream, + participantsStream: participantsController.stream, + presenceStream: presenceController.stream, + positionStream: positionController.stream, + initialMasterAllowed: true, + ); + + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + // Organizer removes Bob from participants + participantsController.add([]); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isEligible, isFalse); + expect(controller.state.isBroadcasting, isFalse); + expect(mockService.clearedPresences, contains('bob:ride_live_1')); + + controller.dispose(); + }); + + test('ghost mode: toggling opt-in to false clears presence and stops broadcast', + () async { + final controller = LiveBroadcastController( + rideId: 'ride_live_1', + callerUid: 'bob', + service: mockService, + rideStream: rideController.stream, + participantsStream: participantsController.stream, + presenceStream: presenceController.stream, + positionStream: positionController.stream, + initialMasterAllowed: true, + ); + + rideController.add(activeRide); + participantsController.add([acceptedParticipant]); + await controller.setOptIn(true); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.state.isBroadcasting, isTrue); + + // Toggle off opt-in + await controller.setOptIn(false); + expect(controller.state.isOptedIn, isFalse); + expect(controller.state.isBroadcasting, isFalse); + expect(mockService.clearedPresences, contains('bob:ride_live_1')); + + controller.dispose(); + }); + }); +} diff --git a/test/live_group_ride_screen_widget_test.dart b/test/live_group_ride_screen_widget_test.dart new file mode 100644 index 0000000..a90fe5b --- /dev/null +++ b/test/live_group_ride_screen_widget_test.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart'; +import 'package:motorcycle_dash/features/group_rides/live_group_ride_screen.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/location_service.dart'; +import 'package:motorcycle_dash/services/map/unified_map_view.dart'; +import 'package:motorcycle_dash/services/telemetry_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; + +class _FakeRideStateMachine extends StateNotifier + implements RideStateMachine { + _FakeRideStateMachine(super.initialState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + group('LiveGroupRideScreen Widget Tests', () { + final now = DateTime.now(); + + final testRide = GroupRide( + id: 'ride_live_test', + organizerUid: 'alice', + organizerHandle: 'alice_apex', + organizerDisplayName: 'Alice Apex', + title: 'Blood Mountain Run', + status: GroupRideStatus.underway, + scheduledStartAt: now, + departureAt: now, + participantCount: 3, + meetingPoint: const GroupRideLocation( + latitude: 34.6, + longitude: -83.9, + displayName: 'Dahlonega Square', + ), + destination: const GroupRideLocation( + latitude: 34.8, + longitude: -83.7, + displayName: 'Neels Gap', + ), + stops: const [ + GroupRideLocation( + latitude: 34.7, + longitude: -83.8, + displayName: 'Turner Corner Fuel', + ), + ], + createdAt: now, + updatedAt: now, + ); + + final participants = [ + GroupRideParticipant( + userId: 'alice', + role: GroupRideRole.lead, + status: ParticipantStatus.accepted, + riderHandle: 'alice_apex', + riderDisplayName: 'Alice Apex', + createdAt: now, + updatedAt: now, + ), + GroupRideParticipant( + userId: 'bob', + role: GroupRideRole.sweep, + status: ParticipantStatus.accepted, + riderHandle: 'bob_rider', + riderDisplayName: 'Bob Rider', + createdAt: now, + updatedAt: now, + ), + GroupRideParticipant( + userId: 'charlie', + role: GroupRideRole.participant, + status: ParticipantStatus.accepted, + riderHandle: 'charlie_twist', + riderDisplayName: 'Charlie Twist', + createdAt: now, + updatedAt: now, + ), + ]; + + final presences = [ + LiveGroupPresence( + userId: 'alice', + groupRideId: 'ride_live_test', + latitude: 34.65, + longitude: -83.85, + heading: 45.0, + speedMph: 45.0, + accuracyMeters: 5.0, + updatedAt: now, + expiresAt: now.add(const Duration(seconds: 120)), + status: LivePresenceStatus.riding, + sharingEnabled: true, + ), + LiveGroupPresence( + userId: 'bob', + groupRideId: 'ride_live_test', + latitude: 34.64, + longitude: -83.86, + heading: 45.0, + speedMph: 42.0, + accuracyMeters: 5.0, + updatedAt: now.subtract(const Duration(seconds: 10)), + expiresAt: now.add(const Duration(seconds: 110)), + status: LivePresenceStatus.needFuel, + sharingEnabled: true, + ), + ]; + + testWidgets('renders map screen with title, status, and quick status bar', + (tester) async { + const user = AuthUser( + uid: 'alice', + email: 'alice@example.com', + isAnonymous: false, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(user), + groupRideStreamProvider('ride_live_test') + .overrideWith((ref) => Stream.value(testRide)), + groupRideParticipantsProvider('ride_live_test') + .overrideWith((ref) => Stream.value(participants)), + groupRideLivePresenceProvider('ride_live_test') + .overrideWith((ref) => Stream.value(presences)), + groupRideLiveSharingAllowedProvider.overrideWith((ref) => true), + ], + child: const MaterialApp( + home: LiveGroupRideScreen(rideId: 'ride_live_test'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('BLOOD MOUNTAIN RUN'), findsOneWidget); + expect(find.text('UNDERWAY'), findsOneWidget); + + // Quick Status bar items + expect(find.text('RIDING'), findsWidgets); + expect(find.text('STOPPED'), findsOneWidget); + expect(find.text('NEED FUEL'), findsWidgets); + expect(find.text('REGROUP'), findsOneWidget); + expect(find.text('DELAYED'), findsOneWidget); + expect(find.text('ARRIVED'), findsOneWidget); + }); + + testWidgets('displays regroup banner when regrouping is active', + (tester) async { + const user = AuthUser( + uid: 'alice', + email: 'alice@example.com', + isAnonymous: false, + ); + + final regroupPresences = [ + LiveGroupPresence( + userId: 'bob', + groupRideId: 'ride_live_test', + latitude: 34.64, + longitude: -83.86, + heading: 45.0, + speedMph: 0.0, + accuracyMeters: 5.0, + updatedAt: now, + expiresAt: now.add(const Duration(seconds: 120)), + status: LivePresenceStatus.regrouping, + sharingEnabled: true, + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(user), + groupRideStreamProvider('ride_live_test') + .overrideWith((ref) => Stream.value(testRide)), + groupRideParticipantsProvider('ride_live_test') + .overrideWith((ref) => Stream.value(participants)), + groupRideLivePresenceProvider('ride_live_test') + .overrideWith((ref) => Stream.value(regroupPresences)), + groupRideLiveSharingAllowedProvider.overrideWith((ref) => true), + ], + child: const MaterialApp( + home: LiveGroupRideScreen(rideId: 'ride_live_test'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('REGROUP CALLED'), findsOneWidget); + expect(find.textContaining('Bob Rider'), findsOneWidget); + }); + + testWidgets('shows live roster sheet with lead and separation distance', + (tester) async { + const user = AuthUser( + uid: 'bob', + email: 'bob@example.com', + isAnonymous: false, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(user), + groupRideStreamProvider('ride_live_test') + .overrideWith((ref) => Stream.value(testRide)), + groupRideParticipantsProvider('ride_live_test') + .overrideWith((ref) => Stream.value(participants)), + groupRideLivePresenceProvider('ride_live_test') + .overrideWith((ref) => Stream.value(presences)), + groupRideLiveSharingAllowedProvider.overrideWith((ref) => true), + ], + child: const MaterialApp( + home: LiveGroupRideScreen(rideId: 'ride_live_test'), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Open Roster Bottom Sheet + final rosterButton = find.byIcon(Icons.format_list_bulleted); + expect(rosterButton, findsOneWidget); + await tester.tap(rosterButton); + await tester.pumpAndSettle(); + + expect(find.text('LIVE RIDER ROSTER'), findsOneWidget); + expect(find.text('Alice Apex'), findsOneWidget); + expect(find.text('RIDE LEAD'), findsWidgets); + expect(find.text('Bob Rider'), findsOneWidget); + expect(find.text('SWEEP'), findsOneWidget); + expect(find.text('Charlie Twist'), findsOneWidget); + expect(find.text('OFFLINE'), findsWidgets); + }); + + testWidgets('responsive layout on 320dp width without overflow', + (tester) async { + tester.view.physicalSize = const Size(320 * 2.0, 640 * 2.0); + tester.view.devicePixelRatio = 2.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + const user = AuthUser( + uid: 'alice', + email: 'alice@example.com', + isAnonymous: false, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(user), + groupRideStreamProvider('ride_live_test') + .overrideWith((ref) => Stream.value(testRide)), + groupRideParticipantsProvider('ride_live_test') + .overrideWith((ref) => Stream.value(participants)), + groupRideLivePresenceProvider('ride_live_test') + .overrideWith((ref) => Stream.value(presences)), + groupRideLiveSharingAllowedProvider.overrideWith((ref) => true), + ], + child: const MaterialApp( + home: LiveGroupRideScreen(rideId: 'ride_live_test'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('BLOOD MOUNTAIN RUN'), findsOneWidget); + expect(find.byType(UnifiedMapView), findsOneWidget); + }); + + testWidgets('dashboard displays active group ride banner when ride is active', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + rideStateMachineProvider.overrideWith( + (ref) => _FakeRideStateMachine(RideSession()), + ), + activeUserGroupRideProvider.overrideWithValue(testRide), + telemetryStreamProvider.overrideWith( + (ref) => Stream.value(TelemetrySample( + timestamp: DateTime.now(), + speed: 0.0, + heading: 0.0, + leanAngle: 0.0, + ))), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + locationPermissionGrantedProvider + .overrideWith((ref) => Future.value(true)), + ], + child: const MaterialApp( + home: DashboardScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect( + find.textContaining('GROUP RIDE ACTIVE: BLOOD MOUNTAIN RUN'), + findsOneWidget, + ); + expect(find.text('3 RIDERS'), findsOneWidget); + }); + }); +}