fix: harden live group ride privacy architecture
This commit is contained in:
parent
f464d52462
commit
173c99d450
|
|
@ -518,5 +518,156 @@ describe('Social Architecture Security Rules', () => {
|
|||
title: 'Hijacked Title',
|
||||
}));
|
||||
});
|
||||
|
||||
test('live group presence security rules', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
// Ride setup: Alice is organizer, Bob is accepted participant, Charlie is invited only, Dave is stranger
|
||||
await setDoc(doc(db, 'group_rides/ride_live'), {
|
||||
id: 'ride_live',
|
||||
organizerUid: 'alice',
|
||||
title: 'Live Tracking 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: 'San Francisco' },
|
||||
});
|
||||
|
||||
await setDoc(doc(db, 'group_rides/ride_live/participants/alice'), {
|
||||
userId: 'alice',
|
||||
role: 'organizer',
|
||||
status: 'accepted',
|
||||
riderHandle: 'alice_rider',
|
||||
riderDisplayName: 'Alice Rider',
|
||||
});
|
||||
|
||||
await setDoc(doc(db, 'group_rides/ride_live/participants/bob'), {
|
||||
userId: 'bob',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
riderHandle: 'bob_rider',
|
||||
riderDisplayName: 'Bob Rider',
|
||||
});
|
||||
|
||||
await setDoc(doc(db, 'group_rides/ride_live/participants/charlie'), {
|
||||
userId: 'charlie',
|
||||
role: 'participant',
|
||||
status: 'invited',
|
||||
riderHandle: 'charlie_rider',
|
||||
riderDisplayName: 'Charlie Rider',
|
||||
});
|
||||
|
||||
await setDoc(doc(db, 'users/alice'), { id: 'alice' });
|
||||
await setDoc(doc(db, 'users/bob'), { id: 'bob' });
|
||||
});
|
||||
|
||||
const validBobPresence = {
|
||||
userId: 'bob',
|
||||
groupRideId: 'ride_live',
|
||||
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',
|
||||
};
|
||||
|
||||
// 1. Accepted participant Bob publishes his own presence: SUCCEEDS
|
||||
await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), validBobPresence));
|
||||
|
||||
// 2. Stranger Dave CANNOT publish presence
|
||||
await assertFails(setDoc(doc(dave, 'group_rides/ride_live/live_presence/dave'), {
|
||||
...validBobPresence,
|
||||
userId: 'dave',
|
||||
}));
|
||||
|
||||
// 3. Invited-only Charlie CANNOT publish presence until accepted
|
||||
await assertFails(setDoc(doc(charlie, 'group_rides/ride_live/live_presence/charlie'), {
|
||||
...validBobPresence,
|
||||
userId: 'charlie',
|
||||
}));
|
||||
|
||||
// 4. Bob CANNOT publish presence for Alice (spoofing)
|
||||
await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/alice'), {
|
||||
...validBobPresence,
|
||||
userId: 'alice',
|
||||
}));
|
||||
|
||||
// 5. Organizer Alice CANNOT publish on Bob's presence document
|
||||
await assertFails(setDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'), {
|
||||
...validBobPresence,
|
||||
userId: 'bob',
|
||||
}));
|
||||
|
||||
// 6. Non-whitelisted keys rejected
|
||||
await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), {
|
||||
...validBobPresence,
|
||||
leanAngle: 42.0,
|
||||
}));
|
||||
|
||||
// 7. Invalid coordinates rejected
|
||||
await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), {
|
||||
...validBobPresence,
|
||||
latitude: 95.0,
|
||||
}));
|
||||
|
||||
// 8. Accepted participant Alice CAN read Bob's presence
|
||||
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 9. Stranger Dave CANNOT read Bob's presence even on public ride
|
||||
await assertFails(getDoc(doc(dave, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 10. If Bob has sharingEnabled: false, Alice CANNOT read Bob's presence
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'group_rides/ride_live/live_presence/bob'), {
|
||||
...validBobPresence,
|
||||
sharingEnabled: false,
|
||||
});
|
||||
});
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
|
||||
// But Bob CAN still read his own presence
|
||||
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 11. If Alice blocks Bob, Alice CANNOT read Bob's presence even if sharingEnabled: true
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'group_rides/ride_live/live_presence/bob'), validBobPresence);
|
||||
await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' });
|
||||
});
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 12. If ride is canceled, publishing is denied
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await deleteDoc(doc(db, 'users/alice/blocks/bob'));
|
||||
await setDoc(doc(db, 'group_rides/ride_live'), {
|
||||
id: 'ride_live',
|
||||
organizerUid: 'alice',
|
||||
title: 'Live Tracking Run',
|
||||
status: 'canceled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 2,
|
||||
visibility: 'public',
|
||||
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' },
|
||||
});
|
||||
});
|
||||
await assertFails(setDoc(doc(bob, 'group_rides/ride_live/live_presence/bob'), validBobPresence));
|
||||
|
||||
// 13. Bob can delete his own presence record
|
||||
await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_live/live_presence/bob')));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,64 @@ service cloud.firestore {
|
|||
request.auth.uid == rideOrganizer()
|
||||
);
|
||||
}
|
||||
|
||||
// --- LIVE PRESENCE SUBCOLLECTION ---
|
||||
// Ephemeral location and status sharing during an active or assembling ride.
|
||||
match /live_presence/{presenceUid} {
|
||||
function rideDoc() {
|
||||
return get(/databases/$(database)/documents/group_rides/$(groupRideId));
|
||||
}
|
||||
|
||||
function isAcceptedParticipant(uid) {
|
||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
||||
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
|
||||
}
|
||||
|
||||
function rideIsLiveEligible() {
|
||||
return rideDoc().data.status in ['scheduled', 'assembling', 'underway'];
|
||||
}
|
||||
|
||||
function validCoordinates(data) {
|
||||
return data.latitude >= -90.0 && data.latitude <= 90.0 &&
|
||||
data.longitude >= -180.0 && data.longitude <= 180.0;
|
||||
}
|
||||
|
||||
function validPresenceKeys(data) {
|
||||
return data.keys().hasAll(['userId', 'groupRideId', 'latitude', 'longitude', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']) &&
|
||||
data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']);
|
||||
}
|
||||
|
||||
// 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).
|
||||
allow read: if signedIn()
|
||||
&& isAcceptedParticipant(request.auth.uid)
|
||||
&& rideIsLiveEligible()
|
||||
&& (
|
||||
request.auth.uid == presenceUid ||
|
||||
(resource.data.sharingEnabled == true && !areMutuallyBlocked(request.auth.uid, presenceUid))
|
||||
);
|
||||
|
||||
// WRITE (CREATE/UPDATE):
|
||||
// Caller must write only their own document, must be an accepted participant,
|
||||
// ride must be live-eligible, valid coordinates, valid schema keys,
|
||||
// request.resource.data.userId == request.auth.uid and groupRideId == groupRideId.
|
||||
allow create, update: if signedIn()
|
||||
&& request.auth.uid == presenceUid
|
||||
&& request.resource.data.userId == request.auth.uid
|
||||
&& request.resource.data.groupRideId == groupRideId
|
||||
&& isAcceptedParticipant(request.auth.uid)
|
||||
&& rideIsLiveEligible()
|
||||
&& validCoordinates(request.resource.data)
|
||||
&& validPresenceKeys(request.resource.data);
|
||||
|
||||
// DELETE:
|
||||
// Rider can delete their own live presence record, or ride organizer during cleanup.
|
||||
allow delete: if signedIn() && (
|
||||
request.auth.uid == presenceUid ||
|
||||
request.auth.uid == rideDoc().data.organizerUid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match /{document=**} {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
_buildHeaderCard(context, ride, isOrganizer),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Organizer Status Controls
|
||||
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
||||
_buildOrganizerControlCard(context, ride),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Dynamic action banner based on membership/organizer state
|
||||
_buildActionBanner(
|
||||
context,
|
||||
|
|
@ -211,6 +217,19 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Live Presence Ephemeral Section (for active members)
|
||||
if (ride.isLiveEligible &&
|
||||
myParticipant != null &&
|
||||
myParticipant.status == ParticipantStatus.accepted) ...[
|
||||
_buildLivePresenceSection(
|
||||
context,
|
||||
ride,
|
||||
myParticipant,
|
||||
currentUid,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Schedule card
|
||||
_buildScheduleCard(ride),
|
||||
const SizedBox(height: 16),
|
||||
|
|
@ -1160,4 +1179,436 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
final period = dt.hour >= 12 ? 'PM' : 'AM';
|
||||
return '$hour:$minute $period';
|
||||
}
|
||||
|
||||
Widget _buildOrganizerControlCard(BuildContext context, GroupRide ride) {
|
||||
final service = ref.read(groupRideServiceProvider);
|
||||
final currentUid = ref.read(currentUserProvider)?.uid;
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF1B1E28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFF00E5FF), width: 0.8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.admin_panel_settings, color: Color(0xFF00E5FF), size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'ORGANIZER CONTROLS',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.0,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Current Status: ${ride.status.label.toUpperCase()}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (ride.status == GroupRideStatus.scheduled) ...[
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF).withValues(alpha: 0.2),
|
||||
foregroundColor: const Color(0xFF00E5FF),
|
||||
side: const BorderSide(color: Color(0xFF00E5FF)),
|
||||
),
|
||||
icon: const Icon(Icons.group, size: 16),
|
||||
label: const Text('START ASSEMBLING'),
|
||||
onPressed: _isProcessing || currentUid == null
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isProcessing = true);
|
||||
await service.updateRideStatus(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
newStatus: GroupRideStatus.assembling,
|
||||
);
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
},
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
icon: const Icon(Icons.two_wheeler, size: 16),
|
||||
label: const Text('START RIDE (UNDERWAY)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: _isProcessing || currentUid == null
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isProcessing = true);
|
||||
await service.updateRideStatus(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
newStatus: GroupRideStatus.underway,
|
||||
);
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (ride.status == GroupRideStatus.assembling) ...[
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF00E5FF),
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
icon: const Icon(Icons.two_wheeler, size: 16),
|
||||
label: const Text('START RIDE (UNDERWAY)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: _isProcessing || currentUid == null
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isProcessing = true);
|
||||
await service.updateRideStatus(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
newStatus: GroupRideStatus.underway,
|
||||
);
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (ride.status == GroupRideStatus.underway) ...[
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.greenAccent.withValues(alpha: 0.2),
|
||||
foregroundColor: Colors.greenAccent,
|
||||
side: const BorderSide(color: Colors.greenAccent),
|
||||
),
|
||||
icon: const Icon(Icons.flag, size: 16),
|
||||
label: const Text('FINISH / COMPLETE RIDE', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
onPressed: _isProcessing || currentUid == null
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isProcessing = true);
|
||||
await service.updateRideStatus(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
newStatus: GroupRideStatus.completed,
|
||||
);
|
||||
if (mounted) setState(() => _isProcessing = false);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLivePresenceSection(
|
||||
BuildContext context,
|
||||
GroupRide ride,
|
||||
GroupRideParticipant myParticipant,
|
||||
String? currentUid,
|
||||
) {
|
||||
if (currentUid == null) return const SizedBox.shrink();
|
||||
|
||||
final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider);
|
||||
final activeSharingRideId = ref.watch(activeLiveRideSharingIdProvider);
|
||||
final isCurrentlySharing = activeSharingRideId == ride.id;
|
||||
final livePresenceAsync = ref.watch(groupRideLivePresenceProvider(ride.id));
|
||||
final service = ref.read(groupRideServiceProvider);
|
||||
|
||||
return Card(
|
||||
color: const Color(0xFF161922),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: isCurrentlySharing
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white12,
|
||||
width: isCurrentlySharing ? 1.2 : 0.8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.radar,
|
||||
color: isCurrentlySharing
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'LIVE RIDE PRESENCE',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.0,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isCurrentlySharing)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00E5FF).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, color: Color(0xFF00E5FF), size: 8),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'BROADCASTING',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF00E5FF),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!masterAllowed) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.amber.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline, color: Colors.amber, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Master Location Privacy is OFF',
|
||||
style: TextStyle(
|
||||
color: Colors.amber,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Live location sharing is currently blocked by your privacy preference. Enable it below to share coordinates in this ride.',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.amber,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
||||
},
|
||||
child: const Text('ALLOW LOCATION SHARING', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text(
|
||||
'Share My Live Location in this Ride',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
isCurrentlySharing
|
||||
? 'Broadcasting ephemeral coordinates to accepted riders'
|
||||
: 'Disabled — other riders cannot see your location',
|
||||
style: TextStyle(
|
||||
color: isCurrentlySharing
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
value: isCurrentlySharing,
|
||||
activeThumbColor: const Color(0xFF00E5FF),
|
||||
onChanged: (val) async {
|
||||
if (val) {
|
||||
ref.read(activeLiveRideSharingIdProvider.notifier).state = ride.id;
|
||||
await service.publishLivePresence(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
latitude: ride.meetingPoint.latitude,
|
||||
longitude: ride.meetingPoint.longitude,
|
||||
status: LivePresenceStatus.riding,
|
||||
sharingEnabled: true,
|
||||
);
|
||||
} else {
|
||||
ref.read(activeLiveRideSharingIdProvider.notifier).state = null;
|
||||
await service.clearLivePresence(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isCurrentlySharing) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.redAccent,
|
||||
side: const BorderSide(color: Colors.redAccent),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
icon: const Icon(Icons.location_off, size: 16),
|
||||
label: const Text(
|
||||
'STOP SHARING IMMEDIATELY',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
ref.read(activeLiveRideSharingIdProvider.notifier).state = null;
|
||||
await service.clearLivePresence(
|
||||
callerUid: currentUid,
|
||||
groupRideId: ride.id,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const Divider(height: 24, color: Colors.white12),
|
||||
livePresenceAsync.when(
|
||||
data: (presences) {
|
||||
if (presences.isEmpty) {
|
||||
return const Text(
|
||||
'No riders currently broadcasting live location.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ACTIVE RIDERS (${presences.length})',
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final p in presences)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
p.isFresh ? Icons.circle : Icons.circle_outlined,
|
||||
color: p.isFresh ? Colors.greenAccent : Colors.amber,
|
||||
size: 10,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.userId == currentUid
|
||||
? 'You (${p.status.label})'
|
||||
: 'Rider (${p.status.label})',
|
||||
style: TextStyle(
|
||||
color: p.userId == currentUid
|
||||
? const Color(0xFF00E5FF)
|
||||
: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: p.userId == currentUid
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (p.isFresh ? Colors.greenAccent : Colors.amber).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
p.isFresh ? 'FRESH' : 'STALE',
|
||||
style: TextStyle(
|
||||
color: p.isFresh ? Colors.greenAccent : Colors.amber,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 24,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00E5FF)),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Stage 6.1 Privacy Freeze: Ephemeral location is isolated to /group_rides/{id}/live_presence with 120s TTL and auto-purges on ride completion. Moving radar map and rider dots will be rendered in Stage 7.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 10, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import '../../services/auth/auth_state.dart';
|
|||
import '../../services/auth/auth_user.dart';
|
||||
import '../../services/sync/cloud_restore_controller.dart';
|
||||
import '../../services/sync/sync_providers.dart';
|
||||
import '../../services/group_rides/group_ride_providers.dart';
|
||||
import 'widgets/current_plan_card.dart';
|
||||
|
||||
class AccountScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -748,11 +749,73 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
isAuthenticated: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildGroupRidePrivacyCard(isEvolution),
|
||||
const SizedBox(height: 16),
|
||||
_buildLocalRidingActiveCard(isEvolution),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroupRidePrivacyCard(bool isEvolution) {
|
||||
final isAllowed = ref.watch(groupRideLiveSharingAllowedProvider);
|
||||
return _buildAuthCard(
|
||||
isEvolution: isEvolution,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.share_location, color: Color(0xFF00E5FF), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'GROUP RIDE LIVE LOCATION',
|
||||
style: TextStyle(
|
||||
fontFamily: isEvolution ? 'Sora' : null,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Control whether MotorSignal can broadcast your live location to other accepted riders during an active Group Ride.',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12, height: 1.35),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text(
|
||||
'Allow Live Location Sharing',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
isAllowed
|
||||
? 'Permitted when opted in inside individual rides'
|
||||
: 'Master block: disabled for all group rides',
|
||||
style: TextStyle(
|
||||
color: isAllowed ? const Color(0xFF00E5FF) : Colors.white38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
value: isAllowed,
|
||||
activeThumbColor: const Color(0xFF00E5FF),
|
||||
onChanged: (val) {
|
||||
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = val;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Privacy contract: Live location is ephemeral, shared only with accepted riders while a ride is active, and never saved to historical telemetry or rider profiles.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 10, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnonymousView(
|
||||
BuildContext context,
|
||||
AuthUser user,
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ enum GroupRideJoinPolicy {
|
|||
enum GroupRideStatus {
|
||||
scheduled,
|
||||
assembling,
|
||||
underway,
|
||||
completed,
|
||||
canceled;
|
||||
|
||||
|
|
@ -174,6 +175,8 @@ enum GroupRideStatus {
|
|||
return 'Scheduled';
|
||||
case GroupRideStatus.assembling:
|
||||
return 'Assembling';
|
||||
case GroupRideStatus.underway:
|
||||
return 'Underway';
|
||||
case GroupRideStatus.completed:
|
||||
return 'Completed';
|
||||
case GroupRideStatus.canceled:
|
||||
|
|
@ -386,6 +389,11 @@ class GroupRide {
|
|||
|
||||
bool get isCanceled => status == GroupRideStatus.canceled;
|
||||
bool get isCompleted => status == GroupRideStatus.completed;
|
||||
bool get isUnderway => status == GroupRideStatus.underway;
|
||||
bool get isLiveEligible =>
|
||||
status == GroupRideStatus.scheduled ||
|
||||
status == GroupRideStatus.assembling ||
|
||||
status == GroupRideStatus.underway;
|
||||
bool get isFull =>
|
||||
participantLimit != null && participantCount >= participantLimit!;
|
||||
|
||||
|
|
@ -548,3 +556,177 @@ class GroupRide {
|
|||
participantCount.hashCode ^
|
||||
participantLimit.hashCode;
|
||||
}
|
||||
|
||||
/// Status of rider in live group ride presence.
|
||||
enum LivePresenceStatus {
|
||||
riding,
|
||||
stopped,
|
||||
regrouping,
|
||||
delayed,
|
||||
arrived,
|
||||
offline;
|
||||
|
||||
String get label {
|
||||
switch (this) {
|
||||
case LivePresenceStatus.riding:
|
||||
return 'Riding';
|
||||
case LivePresenceStatus.stopped:
|
||||
return 'Stopped';
|
||||
case LivePresenceStatus.regrouping:
|
||||
return 'Regrouping';
|
||||
case LivePresenceStatus.delayed:
|
||||
return 'Delayed';
|
||||
case LivePresenceStatus.arrived:
|
||||
return 'Arrived';
|
||||
case LivePresenceStatus.offline:
|
||||
return 'Offline';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral live group presence for an accepted group ride participant.
|
||||
///
|
||||
/// Strictly isolated from public profiles, garage, telemetry bundles, and
|
||||
/// persistent user records. Whitelisted coordinates and coarse indicators only.
|
||||
@immutable
|
||||
class LiveGroupPresence {
|
||||
final String userId;
|
||||
final String groupRideId;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double? heading;
|
||||
final double? speedMph;
|
||||
final double? accuracyMeters;
|
||||
final DateTime updatedAt;
|
||||
final DateTime expiresAt;
|
||||
final bool sharingEnabled;
|
||||
final LivePresenceStatus status;
|
||||
|
||||
const LiveGroupPresence({
|
||||
required this.userId,
|
||||
required this.groupRideId,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.heading,
|
||||
this.speedMph,
|
||||
this.accuracyMeters,
|
||||
required this.updatedAt,
|
||||
required this.expiresAt,
|
||||
this.sharingEnabled = true,
|
||||
this.status = LivePresenceStatus.riding,
|
||||
});
|
||||
|
||||
/// Location is considered fresh if updated within the last 60 seconds and sharing is active.
|
||||
bool get isFresh =>
|
||||
sharingEnabled && DateTime.now().difference(updatedAt).inSeconds < 60;
|
||||
|
||||
/// Location is considered stale if over 120 seconds old or sharing was disabled.
|
||||
bool get isStale =>
|
||||
!sharingEnabled || DateTime.now().difference(updatedAt).inSeconds >= 120;
|
||||
|
||||
/// Coordinates within valid physical limits.
|
||||
bool get isValidCoordinates =>
|
||||
latitude >= -90.0 &&
|
||||
latitude <= 90.0 &&
|
||||
longitude >= -180.0 &&
|
||||
longitude <= 180.0;
|
||||
|
||||
/// Reasonable GPS accuracy (within 100m) if provided.
|
||||
bool get isAccurate => accuracyMeters == null || accuracyMeters! <= 100.0;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'userId': userId,
|
||||
'groupRideId': groupRideId,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
if (heading != null) 'heading': heading,
|
||||
if (speedMph != null) 'speedMph': speedMph,
|
||||
if (accuracyMeters != null) 'accuracyMeters': accuracyMeters,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'sharingEnabled': sharingEnabled,
|
||||
'status': status.name,
|
||||
};
|
||||
|
||||
factory LiveGroupPresence.fromJson(Map<String, dynamic> json) {
|
||||
final updated = json['updatedAt'] != null
|
||||
? DateTime.tryParse(json['updatedAt'] as String) ?? DateTime.now()
|
||||
: DateTime.now();
|
||||
final expires = json['expiresAt'] != null
|
||||
? DateTime.tryParse(json['expiresAt'] as String) ??
|
||||
updated.add(const Duration(seconds: 120))
|
||||
: updated.add(const Duration(seconds: 120));
|
||||
|
||||
return LiveGroupPresence(
|
||||
userId: json['userId'] as String? ?? '',
|
||||
groupRideId: json['groupRideId'] as String? ?? '',
|
||||
latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
|
||||
longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
|
||||
heading: (json['heading'] as num?)?.toDouble(),
|
||||
speedMph: (json['speedMph'] as num?)?.toDouble(),
|
||||
accuracyMeters: (json['accuracyMeters'] as num?)?.toDouble(),
|
||||
updatedAt: updated,
|
||||
expiresAt: expires,
|
||||
sharingEnabled: json['sharingEnabled'] as bool? ?? false,
|
||||
status: LivePresenceStatus.values.firstWhere(
|
||||
(s) => s.name == (json['status'] as String?),
|
||||
orElse: () => LivePresenceStatus.offline,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
LiveGroupPresence copyWith({
|
||||
String? userId,
|
||||
String? groupRideId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
double? heading,
|
||||
double? speedMph,
|
||||
double? accuracyMeters,
|
||||
DateTime? updatedAt,
|
||||
DateTime? expiresAt,
|
||||
bool? sharingEnabled,
|
||||
LivePresenceStatus? status,
|
||||
}) {
|
||||
return LiveGroupPresence(
|
||||
userId: userId ?? this.userId,
|
||||
groupRideId: groupRideId ?? this.groupRideId,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
heading: heading ?? this.heading,
|
||||
speedMph: speedMph ?? this.speedMph,
|
||||
accuracyMeters: accuracyMeters ?? this.accuracyMeters,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
sharingEnabled: sharingEnabled ?? this.sharingEnabled,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is LiveGroupPresence &&
|
||||
runtimeType == other.runtimeType &&
|
||||
userId == other.userId &&
|
||||
groupRideId == other.groupRideId &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude &&
|
||||
heading == other.heading &&
|
||||
speedMph == other.speedMph &&
|
||||
accuracyMeters == other.accuracyMeters &&
|
||||
sharingEnabled == other.sharingEnabled &&
|
||||
status == other.status;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
userId.hashCode ^
|
||||
groupRideId.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode ^
|
||||
heading.hashCode ^
|
||||
speedMph.hashCode ^
|
||||
accuracyMeters.hashCode ^
|
||||
sharingEnabled.hashCode ^
|
||||
status.hashCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,21 @@ final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
|||
final service = ref.watch(groupRideServiceProvider);
|
||||
return service.upcomingRidesStream(callerUid: user.uid);
|
||||
});
|
||||
|
||||
/// Stream of live presence records for all participants in a specific Group Ride.
|
||||
final groupRideLivePresenceProvider =
|
||||
StreamProvider.family<List<LiveGroupPresence>, String>((ref, rideId) {
|
||||
final service = ref.watch(groupRideServiceProvider);
|
||||
return service.livePresenceStream(rideId);
|
||||
});
|
||||
|
||||
/// Master privacy setting: whether group ride live location sharing is permitted by user.
|
||||
/// Defaults to false (explicit opt-in required).
|
||||
final groupRideLiveSharingAllowedProvider = StateProvider<bool>((ref) {
|
||||
return false;
|
||||
});
|
||||
|
||||
/// Active Group Ride ID currently being broadcasted to by the local rider, if any.
|
||||
final activeLiveRideSharingIdProvider = StateProvider<String?>((ref) {
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -703,6 +703,127 @@ class GroupRideService {
|
|||
});
|
||||
}
|
||||
|
||||
/// Updates the status of a Group Ride (e.g. scheduled -> assembling -> underway -> completed).
|
||||
/// Only the ride organizer has authority to transition ride state.
|
||||
Future<void> updateRideStatus({
|
||||
required String callerUid,
|
||||
required String groupRideId,
|
||||
required GroupRideStatus newStatus,
|
||||
}) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return;
|
||||
|
||||
final rideRef = firestore.collection('group_rides').doc(groupRideId);
|
||||
final snap = await rideRef.get();
|
||||
if (!snap.exists || snap.data() == null) {
|
||||
throw StateError('Group ride not found.');
|
||||
}
|
||||
|
||||
final currentRide = GroupRide.fromJson(snap.data()!);
|
||||
if (currentRide.organizerUid != callerUid) {
|
||||
throw StateError('Only the organizer can change ride status.');
|
||||
}
|
||||
if (currentRide.isCanceled || currentRide.isCompleted) {
|
||||
throw StateError('Cannot change status of a closed ride.');
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
await rideRef.update({
|
||||
'status': newStatus.name,
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes ephemeral live presence for an accepted participant during an eligible ride.
|
||||
/// Strictly isolates coordinates to the ride subcollection; never writes to user profiles.
|
||||
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 {
|
||||
final now = DateTime.now();
|
||||
final presence = LiveGroupPresence(
|
||||
userId: callerUid,
|
||||
groupRideId: groupRideId,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
heading: heading,
|
||||
speedMph: speedMph,
|
||||
accuracyMeters: accuracyMeters,
|
||||
updatedAt: now,
|
||||
expiresAt: now.add(const Duration(seconds: 120)),
|
||||
sharingEnabled: sharingEnabled,
|
||||
status: status,
|
||||
);
|
||||
|
||||
if (!presence.isValidCoordinates) {
|
||||
throw ArgumentError('Invalid physical coordinates for live presence.');
|
||||
}
|
||||
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return;
|
||||
|
||||
final presenceRef = firestore
|
||||
.collection('group_rides')
|
||||
.doc(groupRideId)
|
||||
.collection('live_presence')
|
||||
.doc(callerUid);
|
||||
|
||||
await presenceRef.set(presence.toJson());
|
||||
}
|
||||
|
||||
/// Explicitly clears or disables live presence for a rider.
|
||||
Future<void> clearLivePresence({
|
||||
required String callerUid,
|
||||
required String groupRideId,
|
||||
}) async {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return;
|
||||
|
||||
final presenceRef = firestore
|
||||
.collection('group_rides')
|
||||
.doc(groupRideId)
|
||||
.collection('live_presence')
|
||||
.doc(callerUid);
|
||||
|
||||
try {
|
||||
await presenceRef.delete();
|
||||
} catch (e) {
|
||||
debugPrint('[GroupRideService] clearLivePresence error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Realtime stream of live presence records for all participants in an eligible ride.
|
||||
Stream<List<LiveGroupPresence>> livePresenceStream(String groupRideId) {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return Stream.value([]);
|
||||
|
||||
return firestore
|
||||
.collection('group_rides')
|
||||
.doc(groupRideId)
|
||||
.collection('live_presence')
|
||||
.snapshots()
|
||||
.map((snap) {
|
||||
return snap.docs
|
||||
.map((d) {
|
||||
try {
|
||||
return LiveGroupPresence.fromJson(d.data());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<LiveGroupPresence>()
|
||||
.where((p) => p.sharingEnabled && !p.isStale)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed).
|
||||
Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) {
|
||||
final firestore = _firestore;
|
||||
|
|
@ -710,7 +831,11 @@ class GroupRideService {
|
|||
|
||||
return firestore
|
||||
.collection('group_rides')
|
||||
.where('status', isEqualTo: GroupRideStatus.scheduled.name)
|
||||
.where('status', whereIn: [
|
||||
GroupRideStatus.scheduled.name,
|
||||
GroupRideStatus.assembling.name,
|
||||
GroupRideStatus.underway.name,
|
||||
])
|
||||
.limit(30)
|
||||
.snapshots()
|
||||
.map((snap) {
|
||||
|
|
|
|||
|
|
@ -149,4 +149,133 @@ void main() {
|
|||
expect(restored.displayHandle, equals('@bob_rider'));
|
||||
});
|
||||
});
|
||||
|
||||
group('LiveGroupPresence and Ride Lifecycle Tests', () {
|
||||
test('verifies GroupRideStatus underway and isLiveEligible semantics', () {
|
||||
expect(GroupRideStatus.underway.label, equals('Underway'));
|
||||
|
||||
final now = DateTime.now();
|
||||
final rideScheduled = GroupRide(
|
||||
id: 'r1',
|
||||
organizerUid: 'alice',
|
||||
organizerHandle: 'alice',
|
||||
organizerDisplayName: 'Alice',
|
||||
title: 'Run',
|
||||
status: GroupRideStatus.scheduled,
|
||||
scheduledStartAt: now,
|
||||
departureAt: now,
|
||||
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
expect(rideScheduled.isLiveEligible, isTrue);
|
||||
expect(rideScheduled.isUnderway, isFalse);
|
||||
|
||||
final rideUnderway = rideScheduled.copyWith(status: GroupRideStatus.underway);
|
||||
expect(rideUnderway.isUnderway, isTrue);
|
||||
expect(rideUnderway.isLiveEligible, isTrue);
|
||||
|
||||
final rideCompleted = rideScheduled.copyWith(status: GroupRideStatus.completed);
|
||||
expect(rideCompleted.isLiveEligible, isFalse);
|
||||
expect(rideCompleted.isUnderway, isFalse);
|
||||
|
||||
final rideCanceled = rideScheduled.copyWith(status: GroupRideStatus.canceled);
|
||||
expect(rideCanceled.isLiveEligible, isFalse);
|
||||
});
|
||||
|
||||
test('serializes and deserializes LiveGroupPresence correctly', () {
|
||||
final now = DateTime.now();
|
||||
final expires = now.add(const Duration(seconds: 120));
|
||||
|
||||
final presence = LiveGroupPresence(
|
||||
userId: 'bob',
|
||||
groupRideId: 'ride_101',
|
||||
latitude: 34.5378,
|
||||
longitude: -83.9849,
|
||||
heading: 180.5,
|
||||
speedMph: 48.2,
|
||||
accuracyMeters: 4.5,
|
||||
updatedAt: now,
|
||||
expiresAt: expires,
|
||||
sharingEnabled: true,
|
||||
status: LivePresenceStatus.riding,
|
||||
);
|
||||
|
||||
final json = presence.toJson();
|
||||
expect(json['userId'], equals('bob'));
|
||||
expect(json['groupRideId'], equals('ride_101'));
|
||||
expect(json['latitude'], equals(34.5378));
|
||||
expect(json['longitude'], equals(-83.9849));
|
||||
expect(json['heading'], equals(180.5));
|
||||
expect(json['speedMph'], equals(48.2));
|
||||
expect(json['accuracyMeters'], equals(4.5));
|
||||
expect(json['sharingEnabled'], isTrue);
|
||||
expect(json['status'], equals('riding'));
|
||||
|
||||
final restored = LiveGroupPresence.fromJson(json);
|
||||
expect(restored.userId, equals('bob'));
|
||||
expect(restored.groupRideId, equals('ride_101'));
|
||||
expect(restored.latitude, equals(34.5378));
|
||||
expect(restored.longitude, equals(-83.9849));
|
||||
expect(restored.status, equals(LivePresenceStatus.riding));
|
||||
expect(restored.isValidCoordinates, isTrue);
|
||||
expect(restored.isAccurate, isTrue);
|
||||
expect(restored.isFresh, isTrue);
|
||||
expect(restored.isStale, isFalse);
|
||||
});
|
||||
|
||||
test('coordinate bounds validation rejects invalid coordinates', () {
|
||||
final now = DateTime.now();
|
||||
final invalidLat = LiveGroupPresence(
|
||||
userId: 'bob',
|
||||
groupRideId: 'r1',
|
||||
latitude: 91.0,
|
||||
longitude: 0.0,
|
||||
updatedAt: now,
|
||||
expiresAt: now.add(const Duration(seconds: 120)),
|
||||
);
|
||||
expect(invalidLat.isValidCoordinates, isFalse);
|
||||
|
||||
final invalidLng = LiveGroupPresence(
|
||||
userId: 'bob',
|
||||
groupRideId: 'r1',
|
||||
latitude: 0.0,
|
||||
longitude: 185.0,
|
||||
updatedAt: now,
|
||||
expiresAt: now.add(const Duration(seconds: 120)),
|
||||
);
|
||||
expect(invalidLng.isValidCoordinates, isFalse);
|
||||
});
|
||||
|
||||
test('freshness and staleness calculation respects timestamp and sharing flag', () {
|
||||
final now = DateTime.now();
|
||||
final fresh = LiveGroupPresence(
|
||||
userId: 'bob',
|
||||
groupRideId: 'r1',
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
updatedAt: now.subtract(const Duration(seconds: 10)),
|
||||
expiresAt: now.add(const Duration(seconds: 110)),
|
||||
sharingEnabled: true,
|
||||
);
|
||||
expect(fresh.isFresh, isTrue);
|
||||
expect(fresh.isStale, isFalse);
|
||||
|
||||
final stale = LiveGroupPresence(
|
||||
userId: 'bob',
|
||||
groupRideId: 'r1',
|
||||
latitude: 35.0,
|
||||
longitude: -84.0,
|
||||
updatedAt: now.subtract(const Duration(seconds: 130)),
|
||||
expiresAt: now.subtract(const Duration(seconds: 10)),
|
||||
sharingEnabled: true,
|
||||
);
|
||||
expect(stale.isFresh, isFalse);
|
||||
expect(stale.isStale, isTrue);
|
||||
|
||||
final disabled = fresh.copyWith(sharingEnabled: false);
|
||||
expect(disabled.isFresh, isFalse);
|
||||
expect(disabled.isStale, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,5 +114,27 @@ void main() {
|
|||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('publishLivePresence validates physical coordinates', () async {
|
||||
final service = GroupRideService();
|
||||
expect(
|
||||
() => service.publishLivePresence(
|
||||
callerUid: 'user_alice',
|
||||
groupRideId: 'ride_1',
|
||||
latitude: 105.0, // out of range
|
||||
longitude: -84.0,
|
||||
),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('offline live presence and upcoming streams return safely', () async {
|
||||
final service = GroupRideService();
|
||||
final stream = service.livePresenceStream('ride_offline');
|
||||
expect(await stream.first, isEmpty);
|
||||
|
||||
final upcoming = service.upcomingRidesStream(callerUid: 'user_alice');
|
||||
expect(await upcoming.first, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue