feat: add live group ride experience
This commit is contained in:
parent
121be97ecc
commit
3d957fbf63
|
|
@ -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')));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -819,7 +819,7 @@ class GroupRideService {
|
|||
}
|
||||
})
|
||||
.whereType<LiveGroupPresence>()
|
||||
.where((p) => p.sharingEnabled && !p.isStale)
|
||||
.where((p) => p.sharingEnabled)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue