From 30539108fa25ec562eb61e3749224c1f5c2a75ee Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 25 Sep 2026 15:35:32 -0400 Subject: [PATCH] fix(group-rides): address PR #27 review findings and responsive dashboard layout --- .../widgets/responsive_dashboard_layout.dart | 8 + .../group_rides/group_ride_detail_screen.dart | 449 +++++++++++------- .../group_rides/group_ride_service.dart | 2 +- .../live_broadcast_controller.dart | 59 ++- test/group_ride_moderation_widget_test.dart | 38 +- 5 files changed, 376 insertions(+), 180 deletions(-) diff --git a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart index 6bcb41e..8463ef6 100644 --- a/lib/features/dashboard/widgets/responsive_dashboard_layout.dart +++ b/lib/features/dashboard/widgets/responsive_dashboard_layout.dart @@ -148,6 +148,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), SizedBox(height: isShortPortrait ? 6 : 16), RideActionButton(compact: isShortPortrait), + if (!isShortPortrait) ...[ + const SizedBox(height: 6), + const PerformanceLaunchShortcut(compact: true), + ], ] else if (mode == DashboardLayout.touring) ...[ SizedBox(height: isShortPortrait ? 2 : 6), SpeedReadout( @@ -211,6 +215,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget { ), SizedBox(height: isShortPortrait ? 4 : 10), RideActionButton(compact: isShortPortrait), + if (!isShortPortrait) ...[ + const SizedBox(height: 6), + const PerformanceLaunchShortcut(compact: true), + ], ] else ...[ // Sport Cockpit Mode if (!isShortPortrait) const Spacer(flex: 1), diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index 983743e..057adf1 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -8,6 +9,7 @@ import '../../core/theme/theme_extensions.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/social/social_providers.dart'; import '../../services/routes/route_providers.dart'; import '../../services/clubs/club_models.dart'; @@ -63,16 +65,17 @@ class _GroupRideDetailScreenState extends ConsumerState { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) { + final theme = Theme.of(ctx); return friendsAsync.when( data: (friends) { if (friends.isEmpty) { - return const Padding( - padding: EdgeInsets.all(32.0), + return Padding( + padding: const EdgeInsets.all(32.0), child: Center( child: Text( 'No friends found to invite. Add friends from the Friends screen first.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70), + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), ), ), ); @@ -93,7 +96,7 @@ class _GroupRideDetailScreenState extends ConsumerState { for (final f in friends) ListTile( leading: CircleAvatar( - backgroundColor: Colors.white10, + backgroundColor: colors.surfaceContainer, child: Text( f.displayName.isNotEmpty ? f.displayName[0].toUpperCase() @@ -102,16 +105,16 @@ class _GroupRideDetailScreenState extends ConsumerState { ), title: Text( f.displayName, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: theme.colorScheme.onSurface), ), subtitle: Text( f.displayHandle, - style: const TextStyle(color: Colors.white54), + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), ), trailing: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), onPressed: () async { try { @@ -190,14 +193,32 @@ class _GroupRideDetailScreenState extends ConsumerState { fontSize: 15, ), ), + actions: [ + IconButton( + key: const ValueKey('group_ride_copy_id_button'), + icon: const Icon(Icons.share_outlined), + tooltip: 'Copy Ride ID', + onPressed: () { + Clipboard.setData(ClipboardData(text: widget.rideId)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied Ride ID: ${widget.rideId}'), + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ], ), body: rideAsync.when( data: (ride) { if (ride == null) { - return const Center( + return Center( child: Text( 'Group ride not found.', - style: TextStyle(color: Colors.white70), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ); } @@ -516,15 +537,15 @@ class _GroupRideDetailScreenState extends ConsumerState { vertical: 4, ), decoration: BoxDecoration( - color: Colors.white10, + color: colors.surfaceContainer, borderRadius: BorderRadius.circular(6), ), child: Text( ride.visibility.label.toUpperCase(), - style: const TextStyle( + style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, - color: Colors.white70, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), @@ -537,7 +558,10 @@ class _GroupRideDetailScreenState extends ConsumerState { (ride.status == GroupRideStatus.scheduled || ride.status == GroupRideStatus.assembling))) PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.white70), + icon: Icon( + Icons.more_vert, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), color: colors.surfaceContainerHigh, onSelected: (val) async { if (val == 'invite') { @@ -550,19 +574,23 @@ class _GroupRideDetailScreenState extends ConsumerState { }, itemBuilder: (ctx) => [ if (isOrganizer) - const PopupMenuItem( + PopupMenuItem( value: 'invite', child: Text( 'Invite Friends', - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(ctx).colorScheme.onSurface, + ), ), ), if (ride.status == GroupRideStatus.scheduled) - const PopupMenuItem( + PopupMenuItem( value: 'edit', child: Text( 'Edit Details', - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(ctx).colorScheme.onSurface, + ), ), ), PopupMenuItem( @@ -579,10 +607,10 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 12), Text( ride.title, - style: const TextStyle( + style: TextStyle( fontSize: 22, fontWeight: FontWeight.w900, - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 6), @@ -594,8 +622,8 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 12), Text( ride.description, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 13, height: 1.4, ), @@ -647,6 +675,7 @@ class _GroupRideDetailScreenState extends ConsumerState { String? currentUid, MotorSignalThemeColors colors, ) { + final theme = Theme.of(context); if (currentUid == null) { return Card( color: colors.surfaceContainerHigh, @@ -658,18 +687,21 @@ class _GroupRideDetailScreenState extends ConsumerState { padding: const EdgeInsets.all(16), child: Column( children: [ - const Text( + Text( 'Sign in with your MotorSignal account to join this group ride.', - style: TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 13, + ), textAlign: TextAlign.center, ), const SizedBox(height: 10), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), - onPressed: () => context.push('/settings/account'), + onPressed: () => context.push('/account'), child: const Text('SIGN IN'), ), ], @@ -696,10 +728,10 @@ class _GroupRideDetailScreenState extends ConsumerState { ), child: Column( children: [ - const Text( + Text( 'You have been invited to this Group Ride!', style: TextStyle( - color: Colors.white, + color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 14, ), @@ -710,8 +742,8 @@ class _GroupRideDetailScreenState extends ConsumerState { Expanded( child: OutlinedButton( style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white30), + foregroundColor: theme.colorScheme.onSurfaceVariant, + side: BorderSide(color: colors.outlineVariant), ), onPressed: _isProcessing ? null @@ -734,7 +766,7 @@ class _GroupRideDetailScreenState extends ConsumerState { child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), onPressed: _isProcessing ? null @@ -797,9 +829,12 @@ class _GroupRideDetailScreenState extends ConsumerState { children: [ Icon(Icons.check_circle, color: colors.cyberCyan, size: 18), const SizedBox(width: 6), - const Text( + Text( 'You are participating in this ride', - style: TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 13, + ), ), ], ), @@ -820,7 +855,7 @@ class _GroupRideDetailScreenState extends ConsumerState { return ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, minimumSize: const Size.fromHeight(46), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -923,37 +958,49 @@ class _GroupRideDetailScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Meetup Arrival', - style: TextStyle(color: Colors.white60, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), const SizedBox(height: 4), Text( _formatTime(ride.scheduledStartAt), - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), ), Text( '${ride.scheduledStartAt.month}/${ride.scheduledStartAt.day}/${ride.scheduledStartAt.year}', - style: const TextStyle( - color: Colors.white54, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, ), ), ], ), ), - const Icon(Icons.arrow_forward, color: Colors.white24), + Icon( + Icons.arrow_forward, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), + ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Text( + Text( 'Kickstands Up (KSU)', - style: TextStyle(color: Colors.white60, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), const SizedBox(height: 4), Text( @@ -964,10 +1011,10 @@ class _GroupRideDetailScreenState extends ConsumerState { fontWeight: FontWeight.bold, ), ), - const Text( + Text( 'Departure', style: TextStyle( - color: Colors.white54, + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, ), ), @@ -983,6 +1030,7 @@ class _GroupRideDetailScreenState extends ConsumerState { } Widget _buildLocationsCard(GroupRide ride, MotorSignalThemeColors colors) { + final theme = Theme.of(context); return Card( color: colors.surfaceContainerHigh, shape: RoundedRectangleBorder( @@ -1019,8 +1067,8 @@ class _GroupRideDetailScreenState extends ConsumerState { CircleAvatar( radius: 12, backgroundColor: colors.cyberCyan, - child: const Icon(Icons.two_wheeler, - size: 14, color: Colors.black), + child: Icon(Icons.two_wheeler, + size: 14, color: colors.onBrand), ), const SizedBox(width: 12), Expanded( @@ -1029,8 +1077,8 @@ class _GroupRideDetailScreenState extends ConsumerState { children: [ Text( ride.meetingPoint.displayName, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: theme.colorScheme.onSurface, fontSize: 15, fontWeight: FontWeight.bold, ), @@ -1038,8 +1086,8 @@ class _GroupRideDetailScreenState extends ConsumerState { if (ride.meetingPoint.address.isNotEmpty) Text( ride.meetingPoint.address, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, fontSize: 12, ), ), @@ -1047,8 +1095,8 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 4), Text( 'Note: ${ride.meetingPoint.notes}', - style: const TextStyle( - color: Colors.white54, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, fontSize: 11, fontStyle: FontStyle.italic, ), @@ -1068,9 +1116,9 @@ class _GroupRideDetailScreenState extends ConsumerState { ], ), if (ride.destination != null) ...[ - const Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Divider(color: Colors.white12), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Divider(color: colors.outlineVariant), ), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -1088,8 +1136,8 @@ class _GroupRideDetailScreenState extends ConsumerState { children: [ Text( ride.destination!.displayName, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: theme.colorScheme.onSurface, fontSize: 15, fontWeight: FontWeight.bold, ), @@ -1097,8 +1145,8 @@ class _GroupRideDetailScreenState extends ConsumerState { if (ride.destination!.address.isNotEmpty) Text( ride.destination!.address, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, fontSize: 12, ), ), @@ -1185,8 +1233,8 @@ class _GroupRideDetailScreenState extends ConsumerState { if (hasRoute) ...[ Text( ride.routeTitle ?? 'Attached Route', - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -1195,7 +1243,10 @@ class _GroupRideDetailScreenState extends ConsumerState { if (ride.routeDistanceMeters != null) ...[ Text( '${(ride.routeDistanceMeters! / 1609.34).toStringAsFixed(1)} mi straight-line preview', - style: const TextStyle(color: Colors.white70, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ), const SizedBox(height: 12), ], @@ -1210,7 +1261,7 @@ class _GroupRideDetailScreenState extends ConsumerState { label: const Text('VIEW ROUTE / PLANNER'), style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -1286,14 +1337,23 @@ class _GroupRideDetailScreenState extends ConsumerState { ], ), ] else ...[ - const Text( + Text( 'No custom route attached yet.', - style: TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), ), const SizedBox(height: 4), - const Text( + Text( 'Attach a multi-stop route with straight-line map preview and navigation handoff.', - style: TextStyle(color: Colors.white38, fontSize: 11), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), + fontSize: 11, + ), ), if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[ const SizedBox(height: 12), @@ -1310,7 +1370,7 @@ class _GroupRideDetailScreenState extends ConsumerState { label: const Text('PLAN NEW ROUTE'), style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -1370,10 +1430,10 @@ class _GroupRideDetailScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'ATTACH SAVED ROUTE', style: TextStyle( - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 16, ), @@ -1398,9 +1458,13 @@ class _GroupRideDetailScreenState extends ConsumerState { child: Center( child: Column( children: [ - const Text( + Text( 'You have no saved routes yet.', - style: TextStyle(color: Colors.white54), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), const SizedBox(height: 12), ElevatedButton( @@ -1412,7 +1476,7 @@ class _GroupRideDetailScreenState extends ConsumerState { }, style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), child: const Text('CREATE A ROUTE'), ), @@ -1437,21 +1501,28 @@ class _GroupRideDetailScreenState extends ConsumerState { ), title: Text( route.title, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface, fontWeight: FontWeight.bold, ), ), subtitle: Text( '${route.stops.length} stops • ${route.formattedDistanceMiles}', - style: const TextStyle( - color: Colors.white54, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, fontSize: 12, ), ), - trailing: const Icon( + trailing: Icon( Icons.chevron_right, - color: Colors.white30, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.4), ), onTap: () async { Navigator.pop(sheetCtx); @@ -1546,12 +1617,12 @@ class _GroupRideDetailScreenState extends ConsumerState { children: [ CircleAvatar( radius: 10, - backgroundColor: Colors.white12, + backgroundColor: colors.surfaceContainer, child: Text( '${i + 1}', - style: const TextStyle( + style: TextStyle( fontSize: 10, - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, ), ), @@ -1563,15 +1634,15 @@ class _GroupRideDetailScreenState extends ConsumerState { children: [ Text( ride.stops[i].displayName, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 13, ), ), Text( ride.stops[i].locationType.label, - style: const TextStyle( - color: Colors.white54, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, ), ), @@ -1580,10 +1651,10 @@ class _GroupRideDetailScreenState extends ConsumerState { ), if (ride.stops[i].address.isNotEmpty) IconButton( - icon: const Icon( + icon: Icon( Icons.navigation_outlined, size: 16, - color: Colors.white60, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), onPressed: () => _launchMapNavigation(ride.stops[i]), ), @@ -1667,6 +1738,7 @@ class _GroupRideDetailScreenState extends ConsumerState { // Pending join requests (visible to organizer and club leadership) if ((isOrganizer || isClubAdmin) && + ride.joinPolicy == GroupRideJoinPolicy.requestToJoin && !ride.isCanceled && !ride.isCompleted) ...[ Builder( @@ -1705,7 +1777,7 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 6), for (final req in requests) _buildJoinRequestTile(ride, req, currentUid, colors), - const Divider(color: Colors.white12), + Divider(color: colors.outlineVariant), ], ); }, @@ -1717,15 +1789,18 @@ class _GroupRideDetailScreenState extends ConsumerState { width: 14, height: 14, child: CircularProgressIndicator( - strokeWidth: 1.5, + strokeWidth: 2, color: colors.warningAmber, ), ), const SizedBox(width: 8), Expanded( child: Text( - 'Checking for pending join requests...', - style: TextStyle(color: colors.warningAmber, fontSize: 11), + 'Checking pending join requests...', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11, + ), overflow: TextOverflow.ellipsis, ), ), @@ -1751,8 +1826,10 @@ class _GroupRideDetailScreenState extends ConsumerState { visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), ), - child: Text('Retry', - style: TextStyle(color: colors.cyberCyan, fontSize: 11)), + child: Text( + 'Retry', + style: TextStyle(color: colors.cyberCyan, fontSize: 11), + ), ), ], ), @@ -1784,19 +1861,28 @@ class _GroupRideDetailScreenState extends ConsumerState { ), title: Text( p.riderDisplayName, - style: const TextStyle(color: Colors.white, fontSize: 13), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 13, + ), ), subtitle: Text( '${p.displayHandle} • ${p.role.label}', - style: const TextStyle(color: Colors.white54, fontSize: 11), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), trailing: isOrganizer && p.userId != currentUid && !ride.isCanceled ? PopupMenuButton( - icon: const Icon( + icon: Icon( Icons.more_vert, size: 18, - color: Colors.white38, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), ), color: colors.surfaceContainerHigh, onSelected: (action) async { @@ -1815,11 +1901,13 @@ class _GroupRideDetailScreenState extends ConsumerState { } }, itemBuilder: (ctx) => [ - const PopupMenuItem( + PopupMenuItem( value: 'transfer', child: Text( 'Make Organizer', - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(ctx).colorScheme.onSurface, + ), ), ), PopupMenuItem( @@ -1837,10 +1925,10 @@ class _GroupRideDetailScreenState extends ConsumerState { // Invited riders (visible to organizer) if (isOrganizer && invited.isNotEmpty) ...[ const SizedBox(height: 12), - const Text( + Text( 'INVITATIONS PENDING', style: TextStyle( - color: Colors.white60, + color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold, fontSize: 11, ), @@ -1851,11 +1939,17 @@ class _GroupRideDetailScreenState extends ConsumerState { contentPadding: EdgeInsets.zero, title: Text( inv.riderDisplayName, - style: const TextStyle(color: Colors.white70, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 12, + ), ), subtitle: Text( '${inv.displayHandle} • Invited', - style: const TextStyle(color: Colors.white38, fontSize: 10), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 10, + ), ), ), ], @@ -1890,15 +1984,18 @@ class _GroupRideDetailScreenState extends ConsumerState { ), title: Text( req.displayName, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontSize: 13, fontWeight: FontWeight.w600, ), ), subtitle: Text( req.displayHandle, - style: const TextStyle(color: Colors.white54, fontSize: 11), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), trailing: isModerating ? SizedBox( @@ -2000,6 +2097,7 @@ class _GroupRideDetailScreenState extends ConsumerState { } void _confirmCancelRide(GroupRide ride, MotorSignalThemeColors colors) { + final theme = Theme.of(context); showDialog( context: context, builder: (ctx) => AlertDialog( @@ -2008,26 +2106,29 @@ class _GroupRideDetailScreenState extends ConsumerState { borderRadius: BorderRadius.circular(16), side: BorderSide(color: colors.outlineVariant), ), - title: const Text( + title: Text( 'Cancel Group Ride?', - style: TextStyle(color: Colors.white), + style: TextStyle(color: theme.colorScheme.onSurface), ), - content: const Text( + content: Text( 'This will cancel the group ride for all participants.', - style: TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 13, + ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text( + child: Text( 'KEEP RIDE', - style: TextStyle(color: Colors.white60), + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.raceRed, - foregroundColor: Colors.white, + foregroundColor: colors.onBrand, ), onPressed: () async { Navigator.pop(ctx); @@ -2053,6 +2154,7 @@ class _GroupRideDetailScreenState extends ConsumerState { String currentUid, MotorSignalThemeColors colors, ) { + final theme = Theme.of(context); showDialog( context: context, builder: (ctx) => AlertDialog( @@ -2061,23 +2163,29 @@ class _GroupRideDetailScreenState extends ConsumerState { borderRadius: BorderRadius.circular(16), side: BorderSide(color: colors.outlineVariant), ), - title: const Text( + title: Text( 'Leave Group Ride?', - style: TextStyle(color: Colors.white), + style: TextStyle(color: theme.colorScheme.onSurface), ), - content: const Text( + content: Text( 'You will no longer be listed as a participant.', - style: TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 13, + ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('STAY', style: TextStyle(color: Colors.white60)), + child: Text( + 'STAY', + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.raceRed, - foregroundColor: Colors.white, + foregroundColor: colors.onBrand, ), onPressed: () async { Navigator.pop(ctx); @@ -2140,7 +2248,10 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 10), Text( 'Current Status: ${ride.status.label.toUpperCase()}', - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), ), const SizedBox(height: 12), Wrap( @@ -2171,7 +2282,7 @@ class _GroupRideDetailScreenState extends ConsumerState { ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), icon: const Icon(Icons.two_wheeler, size: 16), label: const Text( @@ -2195,7 +2306,7 @@ class _GroupRideDetailScreenState extends ConsumerState { ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), icon: const Icon(Icons.two_wheeler, size: 16), label: const Text( @@ -2288,7 +2399,10 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 10), Text( 'Current Status: ${ride.status.label.toUpperCase()}', - style: const TextStyle(color: Colors.white70, fontSize: 13), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), ), const SizedBox(height: 12), Wrap( @@ -2331,6 +2445,7 @@ class _GroupRideDetailScreenState extends ConsumerState { GroupRide ride, MotorSignalThemeColors colors, ) { + final theme = Theme.of(context); final titleController = TextEditingController(text: ride.title); final descController = TextEditingController(text: ride.description); @@ -2342,16 +2457,16 @@ class _GroupRideDetailScreenState extends ConsumerState { borderRadius: BorderRadius.circular(16), side: BorderSide(color: colors.outlineVariant), ), - title: const Text( + title: Text( 'Edit Ride Details', - style: TextStyle(color: Colors.white), + style: TextStyle(color: theme.colorScheme.onSurface), ), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: titleController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: theme.colorScheme.onSurface), decoration: InputDecoration( labelText: 'Title', labelStyle: TextStyle(color: colors.cyberCyan), @@ -2363,7 +2478,7 @@ class _GroupRideDetailScreenState extends ConsumerState { const SizedBox(height: 12), TextField( controller: descController, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: theme.colorScheme.onSurface), maxLines: 3, decoration: InputDecoration( labelText: 'Description', @@ -2378,12 +2493,15 @@ class _GroupRideDetailScreenState extends ConsumerState { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('CANCEL', style: TextStyle(color: Colors.white60)), + child: Text( + 'CANCEL', + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, ), onPressed: () async { final newTitle = titleController.text.trim(); @@ -2436,9 +2554,10 @@ class _GroupRideDetailScreenState extends ConsumerState { final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider); final activeSharingRideId = ref.watch(activeLiveRideSharingIdProvider); - final isCurrentlySharing = activeSharingRideId == ride.id; + final broadcastState = ref.watch(liveBroadcastControllerProvider(ride.id)); + final broadcastController = ref.read(liveBroadcastControllerProvider(ride.id).notifier); + final isCurrentlySharing = broadcastState.isBroadcasting || activeSharingRideId == ride.id; final livePresenceAsync = ref.watch(groupRideLivePresenceProvider(ride.id)); - final service = ref.read(groupRideServiceProvider); return Card( color: colors.surfaceContainerHigh, @@ -2538,9 +2657,12 @@ class _GroupRideDetailScreenState extends ConsumerState { ], ), const SizedBox(height: 6), - const Text( + Text( 'Live location sharing is currently blocked by your privacy preference. Enable it below to share coordinates in this ride.', - style: TextStyle(color: Colors.white70, fontSize: 11), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), const SizedBox(height: 8), ElevatedButton( @@ -2575,10 +2697,10 @@ class _GroupRideDetailScreenState extends ConsumerState { ] else ...[ SwitchListTile( contentPadding: EdgeInsets.zero, - title: const Text( + title: Text( 'Share My Live Location in this Ride', style: TextStyle( - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w600, fontSize: 13, ), @@ -2590,31 +2712,19 @@ class _GroupRideDetailScreenState extends ConsumerState { style: TextStyle( color: isCurrentlySharing ? colors.cyberCyan - : Colors.white38, + : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, ), ), value: isCurrentlySharing, activeThumbColor: colors.cyberCyan, onChanged: (val) async { + if (val && !broadcastState.isMasterAllowed) { + ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true; + } + await broadcastController.setOptIn(val); if (val) { - ref.read(activeLiveRideSharingIdProvider.notifier).state = - ride.id; - await service.publishLivePresence( - callerUid: currentUid, - groupRideId: ride.id, - latitude: ride.meetingPoint.latitude, - longitude: ride.meetingPoint.longitude, - status: LivePresenceStatus.riding, - sharingEnabled: true, - ); - } else { - ref.read(activeLiveRideSharingIdProvider.notifier).state = - null; - await service.clearLivePresence( - callerUid: currentUid, - groupRideId: ride.id, - ); + await broadcastController.refreshAndPublishCurrentLocation(); } }, ), @@ -2638,12 +2748,7 @@ class _GroupRideDetailScreenState extends ConsumerState { ), ), onPressed: () async { - ref.read(activeLiveRideSharingIdProvider.notifier).state = - null; - await service.clearLivePresence( - callerUid: currentUid, - groupRideId: ride.id, - ); + await broadcastController.setOptIn(false); }, ), ), @@ -2656,7 +2761,7 @@ class _GroupRideDetailScreenState extends ConsumerState { key: const ValueKey('open_live_group_ride_map_button'), style: ElevatedButton.styleFrom( backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + foregroundColor: colors.onBrand, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -2676,13 +2781,16 @@ class _GroupRideDetailScreenState extends ConsumerState { }, ), ), - const Divider(height: 24, color: Colors.white12), + Divider(height: 24, color: colors.outlineVariant), livePresenceAsync.when( data: (presences) { if (presences.isEmpty) { - return const Text( + return Text( 'No riders currently broadcasting live location.', - style: TextStyle(color: Colors.white38, fontSize: 12), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), ); } return Column( @@ -2690,8 +2798,8 @@ class _GroupRideDetailScreenState extends ConsumerState { children: [ Text( 'ACTIVE RIDERS (${presences.length})', - style: const TextStyle( - color: Colors.white60, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 11, fontWeight: FontWeight.bold, letterSpacing: 0.5, @@ -2719,7 +2827,7 @@ class _GroupRideDetailScreenState extends ConsumerState { style: TextStyle( color: p.userId == currentUid ? colors.cyberCyan - : Colors.white, + : Theme.of(context).colorScheme.onSurface, fontSize: 12, fontWeight: p.userId == currentUid ? FontWeight.bold @@ -2772,10 +2880,13 @@ class _GroupRideDetailScreenState extends ConsumerState { error: (_, _) => const SizedBox.shrink(), ), const SizedBox(height: 12), - const Text( + Text( 'Stage 6.1 Privacy Freeze: Ephemeral location is isolated to /group_rides/{id}/live_presence with 120s TTL and auto-purges on ride completion. Moving radar map and rider dots will be rendered in Stage 7.', style: TextStyle( - color: Colors.white38, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), fontSize: 10, height: 1.3, ), diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index ccc30af..d6ad5af 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -583,7 +583,7 @@ class GroupRideService { final response = await httpClient.get( uri, headers: { - 'Authorization': 'Bearer $idToken', + 'Authorization': 'Bearer $token', 'Accept': 'application/json', }, ).timeout(const Duration(seconds: 15)); diff --git a/lib/services/group_rides/live_broadcast_controller.dart b/lib/services/group_rides/live_broadcast_controller.dart index 3b827c7..1963415 100644 --- a/lib/services/group_rides/live_broadcast_controller.dart +++ b/lib/services/group_rides/live_broadcast_controller.dart @@ -75,6 +75,8 @@ class LiveBroadcastState { /// 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 { + static final Set _persistedOptedInRideIds = {}; + final String rideId; final String callerUid; final GroupRideService service; @@ -107,9 +109,13 @@ class LiveBroadcastController extends StateNotifier { this.participantsStream, this.presenceStream, bool initialMasterAllowed = false, + bool? initialOptIn, DateTime Function()? now, }) : _now = now ?? DateTime.now, - super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { + super(LiveBroadcastState( + isMasterAllowed: initialMasterAllowed, + isOptedIn: initialOptIn ?? _persistedOptedInRideIds.contains(rideId), + )) { _initSubscriptions(); } @@ -151,12 +157,11 @@ class LiveBroadcastController extends StateNotifier { return; } - // Ride must be assembling or underway - final isRideActive = ride.status == GroupRideStatus.assembling || - ride.status == GroupRideStatus.underway; + // Ride must be live-eligible (scheduled, assembling, or underway) + final isRideActive = ride.isLiveEligible; if (!isRideActive) { - // Completed, canceled, or scheduled -> stop broadcast + // Completed or canceled -> stop broadcast _revokeBroadcasting('Ride is not in active state (${ride.status.name})'); return; } @@ -232,14 +237,49 @@ class LiveBroadcastController extends StateNotifier { _evaluateEligibility(); if (!optIn) { + _persistedOptedInRideIds.remove(rideId); // 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); + } else { + _persistedOptedInRideIds.add(rideId); + if (state.isBroadcasting) { + if (state.lastPosition != null) { + // Force immediate publish on opt-in + await _publishPosition(state.lastPosition!, force: true); + } else { + await refreshAndPublishCurrentLocation(); + } + } + } + } + + /// Forces an immediate location sample and presence publish if broadcasting. + Future refreshAndPublishCurrentLocation() async { + if (_isDisposed) return; + try { + Position? pos; + try { + pos = await Geolocator.getLastKnownPosition(); + pos ??= await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 4), + ), + ); + } catch (_) { + // Fallback or ignore in headless test environments + } + if (pos != null && !_isDisposed) { + _onPositionUpdate(pos); + if (state.isBroadcasting) { + await _publishPosition(pos, force: true); + } + } + } catch (e) { + debugPrint('[LiveBroadcastController] refreshAndPublishCurrentLocation error: $e'); } } @@ -446,6 +486,7 @@ class LiveBroadcastController extends StateNotifier { } void _revokeBroadcasting(String reason) { + _persistedOptedInRideIds.remove(rideId); state = state.copyWith( isEligible: false, isBroadcasting: false, @@ -481,7 +522,7 @@ class LiveBroadcastController extends StateNotifier { } /// Provider for LiveBroadcastController family parameterized by group ride ID. -final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose +final liveBroadcastControllerProvider = StateNotifierProvider .family( (ref, rideId) { final user = ref.watch(currentUserProvider); diff --git a/test/group_ride_moderation_widget_test.dart b/test/group_ride_moderation_widget_test.dart index 065f501..22717a2 100644 --- a/test/group_ride_moderation_widget_test.dart +++ b/test/group_ride_moderation_widget_test.dart @@ -84,14 +84,50 @@ class _RecordingGroupRideService extends Fake implements GroupRideService { Future respondToJoinRequest({ required String callerUid, required String groupRideId, - required String requesterUid, required bool approve, + required String requesterUid, }) async { lastModeratedCallerUid = callerUid; lastModeratedGroupRideId = groupRideId; lastModeratedRequesterUid = requesterUid; lastModeratedApprove = approve; } + + @override + Stream participantDocStream({ + required String groupRideId, + required String participantUid, + }) => + Stream.value(null); + + @override + Stream groupRideStream(String groupRideId) => Stream.value(null); + + @override + Stream livePresenceDocStream({ + required String groupRideId, + required String targetUid, + }) => + Stream.value(null); + + @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 {} + + @override + Future clearLivePresence({ + required String callerUid, + required String groupRideId, + }) async {} } void main() {