feat: add live group ride experience

This commit is contained in:
Developer 2026-09-03 23:42:59 -04:00
parent 121be97ecc
commit 3d957fbf63
12 changed files with 2534 additions and 5 deletions

View File

@ -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')));
});
});
});

View File

@ -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 ||

View File

@ -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<NavigatorState>();
@ -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);
},
),
],
),
],
),

View File

@ -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),

View File

@ -1516,6 +1516,32 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
),
],
],
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) {

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -49,3 +49,29 @@ final groupRideLiveSharingAllowedProvider = StateProvider<bool>((ref) {
final activeLiveRideSharingIdProvider = StateProvider<String?>((ref) {
return null;
});
/// Provider that returns the first active group ride for the current user (assembling or underway).
final activeUserGroupRideProvider = Provider<GroupRide?>((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,
);
});

View File

@ -819,7 +819,7 @@ class GroupRideService {
}
})
.whereType<LiveGroupPresence>()
.where((p) => p.sharingEnabled && !p.isStale)
.where((p) => p.sharingEnabled)
.toList();
});
}

View File

@ -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<LiveBroadcastState> {
final String rideId;
final String callerUid;
final GroupRideService service;
final Stream<Position>? positionStream;
final Stream<GroupRide?>? rideStream;
final Stream<List<GroupRideParticipant>>? participantsStream;
final Stream<List<LiveGroupPresence>>? presenceStream;
StreamSubscription<Position>? _positionSub;
StreamSubscription<GroupRide?>? _rideSub;
StreamSubscription<List<GroupRideParticipant>>? _participantsSub;
StreamSubscription<List<LiveGroupPresence>>? _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<GroupRideParticipant> 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<LiveGroupPresence> 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<void> 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<void> 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<void> triggerRegroup() async {
await setStatus(LivePresenceStatus.regrouping);
}
/// Clears regroup call by switching back to riding.
Future<void> 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<void> _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<LiveBroadcastController, LiveBroadcastState, String>(
(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<bool>(groupRideLiveSharingAllowedProvider, (prev, next) {
controller.updateMasterAllowed(next);
});
return controller;
});

View File

@ -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<Map<String, dynamic>> publishedPresences = [];
final List<String> clearedPresences = [];
@override
Future<void> 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<void> 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<GroupRide?> rideController;
late StreamController<List<GroupRideParticipant>> participantsController;
late StreamController<List<LiveGroupPresence>> presenceController;
late StreamController<Position> 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<GroupRide?>.broadcast();
participantsController =
StreamController<List<GroupRideParticipant>>.broadcast();
presenceController =
StreamController<List<LiveGroupPresence>>.broadcast();
positionController = StreamController<Position>.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<void>.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<void>.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<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 10));
// Emit valid initial position
positionController.add(_createPosition(
latitude: 34.505,
longitude: -84.105,
accuracy: 5.0,
));
await Future<void>.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<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
// Ride concludes
final completedRide = activeRide.copyWith(status: GroupRideStatus.completed);
rideController.add(completedRide);
await Future<void>.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<void>.delayed(const Duration(milliseconds: 10));
expect(controller.state.isBroadcasting, isTrue);
// Organizer removes Bob from participants
participantsController.add([]);
await Future<void>.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<void>.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();
});
});
}

View File

@ -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<RideSession>
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);
});
});
}