fix: harden group ride privacy and live presence

This commit is contained in:
Developer 2026-09-05 14:55:47 -04:00
parent ea3650f43d
commit 2319222f22
16 changed files with 1491 additions and 117 deletions

View File

@ -4,7 +4,7 @@ import {
assertSucceeds, assertSucceeds,
initializeTestEnvironment, initializeTestEnvironment,
} from '@firebase/rules-unit-testing'; } from '@firebase/rules-unit-testing';
import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc } from 'firebase/firestore'; import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } from 'firebase/firestore';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -386,6 +386,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 1, participantCount: 1,
accessUids: ['alice'],
visibility: 'friendsOnly', visibility: 'friendsOnly',
joinPolicy: 'inviteOnly', joinPolicy: 'inviteOnly',
meetingPoint: { meetingPoint: {
@ -408,6 +409,21 @@ describe('Social Architecture Security Rules', () => {
riderDisplayName: 'Alice Apex', riderDisplayName: 'Alice Apex',
})); }));
// Alice can create ride AND organizer participant in an atomic WriteBatch (as done by client app)
const batch = writeBatch(alice);
batch.set(doc(alice, 'group_rides/ride_batch_101'), {
...rideData,
id: 'ride_batch_101',
});
batch.set(doc(alice, 'group_rides/ride_batch_101/participants/alice'), {
userId: 'alice',
role: 'organizer',
status: 'accepted',
riderHandle: 'alice_apex',
riderDisplayName: 'Alice Apex',
});
await assertSucceeds(batch.commit());
// Bob CANNOT create a ride claiming Alice is the organizer // Bob CANNOT create a ride claiming Alice is the organizer
await assertFails(setDoc(doc(bob, 'group_rides/ride_fake'), { await assertFails(setDoc(doc(bob, 'group_rides/ride_fake'), {
...rideData, ...rideData,
@ -431,6 +447,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 1, participantCount: 1,
accessUids: ['alice'],
visibility: 'private', visibility: 'private',
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
}); });
@ -441,6 +458,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 1, participantCount: 1,
accessUids: ['alice'],
visibility: 'friendsOnly', visibility: 'friendsOnly',
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
}); });
@ -474,6 +492,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 1, participantCount: 1,
accessUids: ['alice', 'bob'],
visibility: 'friendsOnly', visibility: 'friendsOnly',
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
}); });
@ -519,6 +538,175 @@ describe('Social Architecture Security Rules', () => {
})); }));
}); });
test('upcoming rides and my rides collection queries enforce accessUids and organizerUid', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const unauth = testEnv.unauthenticatedContext().firestore();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
// Alice's ride with Bob invited
await setDoc(doc(db, 'group_rides/ride_query_test'), {
id: 'ride_query_test',
organizerUid: 'alice',
title: 'Query Verification Ride',
status: 'scheduled',
scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z',
participantCount: 1,
accessUids: ['alice', 'bob'],
visibility: 'friendsOnly',
joinPolicy: 'inviteOnly',
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' },
});
});
// 1. Alice queries My Rides (organizerUid == 'alice') -> SUCCEEDS
const myRidesQuery = query(
collection(alice, 'group_rides'),
where('organizerUid', '==', 'alice'),
where('status', 'in', ['scheduled', 'assembling', 'underway']),
);
const aliceSnap = await assertSucceeds(getDocs(myRidesQuery));
expect(aliceSnap.docs.length).toBe(1);
// 2. Bob queries Upcoming Rides (accessUids array-contains 'bob') -> SUCCEEDS
const bobUpcomingQuery = query(
collection(bob, 'group_rides'),
where('accessUids', 'array-contains', 'bob'),
where('status', 'in', ['scheduled', 'assembling', 'underway']),
);
const bobSnap = await assertSucceeds(getDocs(bobUpcomingQuery));
expect(bobSnap.docs.length).toBe(1);
// 3. Charlie queries Upcoming Rides (accessUids array-contains 'charlie') -> SUCCEEDS (0 results)
const charlieUpcomingQuery = query(
collection(charlie, 'group_rides'),
where('accessUids', 'array-contains', 'charlie'),
where('status', 'in', ['scheduled', 'assembling', 'underway']),
);
const charlieSnap = await assertSucceeds(getDocs(charlieUpcomingQuery));
expect(charlieSnap.docs.length).toBe(0);
// 4. Broad query WITHOUT accessUids or organizerUid -> DENIED
const broadQuery = query(
collection(bob, 'group_rides'),
where('status', '==', 'scheduled'),
);
await assertFails(getDocs(broadQuery));
// 5. Unauthenticated user CANNOT query group_rides
const unauthQuery = query(
collection(unauth, 'group_rides'),
where('organizerUid', '==', 'alice'),
where('status', 'in', ['scheduled', 'assembling', 'underway']),
);
await assertFails(getDocs(unauthQuery));
});
test('status-aware participant authorization and privilege escalation prevention', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const charlie = testEnv.authenticatedContext('charlie').firestore();
const dave = testEnv.authenticatedContext('dave').firestore();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
// Ride with requestToJoin policy
await setDoc(doc(db, 'group_rides/ride_request_only'), {
id: 'ride_request_only',
organizerUid: 'alice',
title: 'Request Only Ride',
status: 'scheduled',
scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z',
participantCount: 1,
accessUids: ['alice'],
visibility: 'public',
joinPolicy: 'requestToJoin',
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point R' },
});
// Ride with open join policy
await setDoc(doc(db, 'group_rides/ride_open'), {
id: 'ride_open',
organizerUid: 'alice',
title: 'Open Ride',
status: 'scheduled',
scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z',
participantCount: 1,
accessUids: ['alice'],
visibility: 'public',
joinPolicy: 'open',
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point O' },
});
// Ride with inviteOnly policy
await setDoc(doc(db, 'group_rides/ride_invite_strict'), {
id: 'ride_invite_strict',
organizerUid: 'alice',
title: 'Invite Only Ride',
status: 'scheduled',
scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z',
participantCount: 1,
accessUids: ['alice'],
visibility: 'public',
joinPolicy: 'inviteOnly',
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point I' },
});
});
// 1. On requestToJoin ride, Charlie CANNOT self-join with 'accepted'
await assertFails(setDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), {
userId: 'charlie',
role: 'participant',
status: 'accepted',
}));
// 2. Charlie CAN create 'requested' participant record
await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), {
userId: 'charlie',
role: 'participant',
status: 'requested',
}));
// 3. Charlie CANNOT self-promote from 'requested' to 'accepted'
await assertFails(updateDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), {
status: 'accepted',
}));
// 4. Organizer Alice CAN approve Charlie's request
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_request_only/participants/charlie'), {
status: 'accepted',
}));
// 5. On inviteOnly ride, stranger Dave CANNOT request or join
await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), {
userId: 'dave',
role: 'participant',
status: 'requested',
}));
await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), {
userId: 'dave',
role: 'participant',
status: 'accepted',
}));
// 6. On open ride, Dave CAN directly join with 'accepted'
await assertSucceeds(setDoc(doc(dave, 'group_rides/ride_open/participants/dave'), {
userId: 'dave',
role: 'participant',
status: 'accepted',
}));
// 7. Dave can update accessUids and increment participantCount on open ride
await assertSucceeds(updateDoc(doc(dave, 'group_rides/ride_open'), {
accessUids: ['alice', 'dave'],
participantCount: 2,
updatedAt: '2026-09-10T09:05:00Z',
}));
});
test('live group presence security rules', async () => { test('live group presence security rules', async () => {
const alice = testEnv.authenticatedContext('alice').firestore(); const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore(); const bob = testEnv.authenticatedContext('bob').firestore();
@ -536,6 +724,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 2, participantCount: 2,
accessUids: ['alice', 'bob', 'charlie'],
visibility: 'public', visibility: 'public',
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' }, meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' },
}); });
@ -621,9 +810,21 @@ describe('Social Architecture Security Rules', () => {
latitude: 95.0, latitude: 95.0,
})); }));
// 8. Accepted participant Alice CAN read Bob's presence // 8. Accepted participant Alice CAN read Bob's presence via point-get
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
// 8b. Collection listing is STRICTLY DENIED for everyone (including organizer and accepted participants)
await assertFails(getDocs(collection(alice, 'group_rides/ride_live/live_presence')));
await assertFails(getDocs(collection(bob, 'group_rides/ride_live/live_presence')));
await assertFails(getDocs(collection(dave, 'group_rides/ride_live/live_presence')));
await assertFails(getDocs(collection(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_live/live_presence')));
// 8c. Signed-out point-get is DENIED
await assertFails(getDoc(doc(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_live/live_presence/bob')));
// 8d. Invited Charlie CANNOT point-get Bob's presence
await assertFails(getDoc(doc(charlie, 'group_rides/ride_live/live_presence/bob')));
// 9. Stranger Dave CANNOT read Bob's presence even on public ride // 9. Stranger Dave CANNOT read Bob's presence even on public ride
await assertFails(getDoc(doc(dave, 'group_rides/ride_live/live_presence/bob'))); await assertFails(getDoc(doc(dave, 'group_rides/ride_live/live_presence/bob')));
@ -647,6 +848,27 @@ describe('Social Architecture Security Rules', () => {
}); });
await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob'))); await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
// 11b. Participant roster block privacy: Peer Charlie blocks Peer Bob
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await setDoc(doc(db, 'group_rides/ride_live/participants/charlie'), {
userId: 'charlie',
role: 'participant',
status: 'accepted',
riderHandle: 'charlie_rider',
riderDisplayName: 'Charlie Rider',
});
await setDoc(doc(db, 'users/charlie/blocks/bob'), { blockedUid: 'bob' });
});
// Charlie CANNOT read Bob's participant document due to mutual block
await assertFails(getDoc(doc(charlie, 'group_rides/ride_live/participants/bob')));
// Bob CANNOT read Charlie's participant document due to mutual block
await assertFails(getDoc(doc(bob, 'group_rides/ride_live/participants/charlie')));
// But Organizer Alice CAN still read Bob's participant document
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/participants/bob')));
// And Bob CAN read his own participant document
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_live/participants/bob')));
// 12. If ride is canceled, publishing is denied // 12. If ride is canceled, publishing is denied
await testEnv.withSecurityRulesDisabled(async (context) => { await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore(); const db = context.firestore();
@ -659,6 +881,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 2, participantCount: 2,
accessUids: ['alice', 'bob'],
visibility: 'public', visibility: 'public',
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' }, meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' },
}); });
@ -712,6 +935,7 @@ describe('Social Architecture Security Rules', () => {
scheduledStartAt: '2026-09-10T09:00:00Z', scheduledStartAt: '2026-09-10T09:00:00Z',
departureAt: '2026-09-10T09:30:00Z', departureAt: '2026-09-10T09:30:00Z',
participantCount: 2, participantCount: 2,
accessUids: ['alice', 'bob'],
visibility: 'public', visibility: 'public',
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
}); });
@ -748,7 +972,7 @@ describe('Social Architecture Security Rules', () => {
// Alice CANNOT read Bob's old presence // Alice CANNOT read Bob's old presence
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
// Scenario B: Voluntary Leave (Bob leaves the ride) // Scenario B: Voluntary Leave (Bob deletes his participant record)
await resetLiveState(); await resetLiveState();
// Bob deletes his participant record (voluntary leave) // Bob deletes his participant record (voluntary leave)
await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'))); await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob')));
@ -760,6 +984,33 @@ describe('Social Architecture Security Rules', () => {
// Alice CANNOT read Bob's presence // Alice CANNOT read Bob's presence
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
// Scenario B2: Voluntary Leave via status transition (status: 'accepted' -> 'left', presence doc temporarily remains)
await resetLiveState();
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'), {
status: 'left',
}));
// Stale presence document still exists in database, but Alice direct get is DENIED
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
// Bob self get for cleanup verification remains ALLOWED
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob')));
// Scenario B3: Organizer removal via status transition (status: 'accepted' -> 'removed', presence doc temporarily remains)
await resetLiveState();
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'), {
status: 'removed',
}));
// Stale presence document still exists in database, but Alice direct get is DENIED
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
// Scenario B4: Participant status transition to 'declined' (presence doc temporarily remains)
await resetLiveState();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await updateDoc(doc(db, 'group_rides/ride_lifecycle/participants/bob'), { status: 'declined' });
});
// Stale presence document still exists in database, but Alice direct get is DENIED
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
// Scenario C: Completed Ride (Ride marked completed) // Scenario C: Completed Ride (Ride marked completed)
await resetLiveState(); await resetLiveState();
await testEnv.withSecurityRulesDisabled(async (context) => { await testEnv.withSecurityRulesDisabled(async (context) => {
@ -781,6 +1032,17 @@ describe('Social Architecture Security Rules', () => {
// Bob CANNOT read Alice's live presence // Bob CANNOT read Alice's live presence
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice')));
// Scenario C2: Canceled Ride (Ride marked canceled, presence doc temporarily remains)
await resetLiveState();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await updateDoc(doc(db, 'group_rides/ride_lifecycle'), { status: 'canceled' });
});
// 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) // Scenario D: Mid-Session Block (Alice blocks Bob during active ride)
await resetLiveState(); await resetLiveState();
// Both can read initially // Both can read initially
@ -792,7 +1054,32 @@ describe('Social Architecture Security Rules', () => {
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
// Bob CANNOT read Alice's presence // Bob CANNOT read Alice's presence
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice')));
// Scenario E: Participants Collection Listing is STRICTLY DENIED for everyone
await resetLiveState();
await assertFails(getDocs(collection(alice, 'group_rides/ride_lifecycle/participants')));
await assertFails(getDocs(collection(bob, 'group_rides/ride_lifecycle/participants')));
await assertFails(getDocs(collection(testEnv.authenticatedContext('stranger').firestore(), 'group_rides/ride_lifecycle/participants')));
await assertFails(getDocs(collection(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_lifecycle/participants')));
// Scenario F: Participant single-doc get authorization
const charlie = testEnv.authenticatedContext('charlie').firestore();
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/charlie'), {
userId: 'charlie',
role: 'participant',
status: 'invited',
}); });
});
// Charlie (invited) CAN read his own participant document
await assertSucceeds(getDoc(doc(charlie, 'group_rides/ride_lifecycle/participants/charlie')));
// Charlie (invited) CANNOT read peer Bob's participant document
await assertFails(getDoc(doc(charlie, 'group_rides/ride_lifecycle/participants/bob')));
// Organizer Alice CAN read invited Charlie's participant document
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/participants/charlie')));
});
test('quick status security: organizer cannot impersonate or modify another rider status', async () => { test('quick status security: organizer cannot impersonate or modify another rider status', async () => {
const alice = testEnv.authenticatedContext('alice').firestore(); const alice = testEnv.authenticatedContext('alice').firestore();

View File

@ -1,4 +1,33 @@
{ {
"indexes": [], "indexes": [
{
"collectionGroup": "group_rides",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "accessUids",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "status",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "group_rides",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "organizerUid",
"order": "ASCENDING"
},
{
"fieldPath": "status",
"order": "ASCENDING"
}
]
}
],
"fieldOverrides": [] "fieldOverrides": []
} }

View File

@ -194,8 +194,9 @@ service cloud.firestore {
return signedIn() && resource.data.organizerUid == request.auth.uid; return signedIn() && resource.data.organizerUid == request.auth.uid;
} }
function isParticipant() { function hasParticipantAccess(uid) {
return signedIn() && exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)); return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status in ['invited', 'accepted'];
} }
function isFriendOfOrganizer() { function isFriendOfOrganizer() {
@ -204,10 +205,10 @@ service cloud.firestore {
function canReadRide() { function canReadRide() {
return signedIn() && ( return signedIn() && (
// Organizer or active participant can always read if not mutually blocked isOrganizer() ||
(isOrganizer() || isParticipant()) ||
( (
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
hasParticipantAccess(request.auth.uid) ||
resource.data.visibility == 'public' || resource.data.visibility == 'public' ||
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) (resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
) )
@ -215,12 +216,22 @@ service cloud.firestore {
); );
} }
allow read: if canReadRide(); allow get: if canReadRide();
allow list: if signedIn() && (
resource.data.organizerUid == request.auth.uid ||
(
'accessUids' in resource.data &&
request.auth.uid in resource.data.accessUids
)
);
allow create: if signedIn() allow create: if signedIn()
&& request.resource.data.organizerUid == request.auth.uid && request.resource.data.organizerUid == request.auth.uid
&& request.resource.data.id == groupRideId && request.resource.data.id == groupRideId
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint', 'accessUids'])
&& request.resource.data.participantCount >= 1 && request.resource.data.accessUids == [request.auth.uid]
&& request.resource.data.participantCount == 1
&& ( && (
!('routeId' in request.resource.data) || !('routeId' in request.resource.data) ||
request.resource.data.routeId == null || request.resource.data.routeId == null ||
@ -229,6 +240,7 @@ service cloud.firestore {
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
) )
); );
function validStatusTransition() { function validStatusTransition() {
let prev = resource.data.status; let prev = resource.data.status;
let next = request.resource.data.status; let next = request.resource.data.status;
@ -250,67 +262,186 @@ service cloud.firestore {
); );
} }
allow update: if signedIn() && ( function validOrganizerRideUpdate() {
// Organizer can update ride authority fields with valid status transitions and owned route attachment let hasPrevAccess = 'accessUids' in resource.data;
(isOrganizer() && validStatusTransition() && validRouteAttachment()) || let hasNextAccess = 'accessUids' in request.resource.data;
// Participants/invitees can only atomically adjust participantCount during join/leave transactions let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
( let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
return validStatusTransition() && validRouteAttachment() && (
(added.size() == 0 && removed.size() == 0 && countDelta == 0) ||
(added.size() == 1 && removed.size() == 0 && countDelta == 0) ||
(added.size() == 1 && removed.size() == 0 && countDelta == 1) ||
(added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0))
);
}
function validParticipantRideUpdate() {
let hasPrevAccess = 'accessUids' in resource.data;
let hasNextAccess = 'accessUids' in request.resource.data;
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
canReadRide() && canReadRide() &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt']) (
// Join open ride: caller added to accessUids, count +1
(
resource.data.joinPolicy == 'open' &&
added == [request.auth.uid] &&
removed.size() == 0 &&
countDelta == 1
) ||
// Accept invite: caller was already in accessUids, count +1
(
request.auth.uid in prevAccess &&
added.size() == 0 &&
removed.size() == 0 &&
countDelta == 1
) ||
// Decline invite or leave: caller removed from accessUids, count unchanged (declined) or count -1 (left accepted)
(
request.auth.uid in prevAccess &&
removed == [request.auth.uid] &&
added.size() == 0 &&
(countDelta == -1 || countDelta == 0)
) )
); );
}
allow update: if signedIn() && (
(isOrganizer() && validOrganizerRideUpdate()) ||
validParticipantRideUpdate()
);
allow delete: if isOrganizer(); allow delete: if isOrganizer();
// --- PARTICIPANTS SUBCOLLECTION --- // --- PARTICIPANTS SUBCOLLECTION ---
match /participants/{participantUid} { match /participants/{participantUid} {
function rideDoc() { function rideDoc() {
return get(/databases/$(database)/documents/group_rides/$(groupRideId)); return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
? get(/databases/$(database)/documents/group_rides/$(groupRideId))
: (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
: null);
} }
function rideOrganizer() { function rideOrganizer() {
return rideDoc().data.organizerUid; return rideDoc().data.organizerUid;
} }
allow read: if signedIn() && ( 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';
}
// Collection listing is strictly disallowed (point-read architecture over known accessUids)
allow list: if false;
// Single-document read: self, organizer, or accepted peer (if not mutually blocked)
allow get: if signedIn() && (
request.auth.uid == participantUid || request.auth.uid == participantUid ||
request.auth.uid == rideOrganizer() || request.auth.uid == rideOrganizer() ||
exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) || (
rideDoc().data.visibility == 'public' || isAcceptedParticipant(request.auth.uid) &&
(rideDoc().data.visibility == 'friendsOnly' && exists(/databases/$(database)/documents/users/$(rideOrganizer())/friends/$(request.auth.uid))) !areMutuallyBlocked(request.auth.uid, participantUid)
)
); );
// Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create // Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create
allow create: if signedIn() && ( allow create: if signedIn() && (
// Self-organizer initialization on ride create // Self-organizer initialization on ride create
(request.auth.uid == participantUid && request.resource.data.role == 'organizer') || (
request.auth.uid == participantUid &&
request.auth.uid == rideOrganizer() &&
request.resource.data.role == 'organizer' &&
request.resource.data.status == 'accepted'
) ||
// Organizer inviting a non-blocked rider // Organizer inviting a non-blocked rider
( (
request.auth.uid == rideOrganizer() && request.auth.uid == rideOrganizer() &&
request.resource.data.status == 'invited' && request.resource.data.status == 'invited' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, participantUid) !areMutuallyBlocked(request.auth.uid, participantUid)
) || ) ||
// Rider requesting to join or joining open ride // Rider requesting to join (joinPolicy == requestToJoin)
( (
request.auth.uid == participantUid && request.auth.uid == participantUid &&
(request.resource.data.status == 'requested' || request.resource.data.status == 'accepted') && request.auth.uid != rideOrganizer() &&
rideDoc().data.joinPolicy == 'requestToJoin' &&
request.resource.data.status == 'requested' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
) ||
// Rider joining open ride (joinPolicy == open)
(
request.auth.uid == participantUid &&
request.auth.uid != rideOrganizer() &&
rideDoc().data.joinPolicy == 'open' &&
request.resource.data.status == 'accepted' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer()) !areMutuallyBlocked(request.auth.uid, rideOrganizer())
) )
); );
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer // State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
allow update: if signedIn() && ( allow update: if signedIn() && (
// Participant responding to invite or leaving // Invitee accepting own invitation (invited -> accepted)
( (
request.auth.uid == participantUid && request.auth.uid == participantUid &&
( resource.data.status == 'invited' &&
request.resource.data.status in ['accepted', 'declined', 'left'] || request.resource.data.status == 'accepted' &&
// Transfer target becoming organizer request.resource.data.role == resource.data.role
(request.resource.data.role == 'organizer' && request.auth.uid == rideOrganizer())
)
) || ) ||
// Organizer approving, removing, or transferring // Invitee declining own invitation (invited -> declined)
( (
request.auth.uid == rideOrganizer() request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
request.resource.data.status == 'declined' &&
request.resource.data.role == resource.data.role
) ||
// Participant leaving (accepted -> left)
(
request.auth.uid == participantUid &&
resource.data.status == 'accepted' &&
request.resource.data.status == 'left' &&
request.resource.data.role == resource.data.role
) ||
// Organizer approving request (requested -> accepted)
(
request.auth.uid == rideOrganizer() &&
resource.data.status == 'requested' &&
request.resource.data.status == 'accepted'
) ||
// Organizer declining request (requested -> declined)
(
request.auth.uid == rideOrganizer() &&
resource.data.status == 'requested' &&
request.resource.data.status == 'declined'
) ||
// Organizer removing participant (status -> removed)
(
request.auth.uid == rideOrganizer() &&
request.resource.data.status == 'removed'
) ||
// Organizer role transfer
(
request.auth.uid == rideOrganizer() &&
request.resource.data.role in ['organizer', 'participant']
) )
); );
@ -324,7 +455,11 @@ service cloud.firestore {
// Ephemeral location and status sharing during an active or assembling ride. // Ephemeral location and status sharing during an active or assembling ride.
match /live_presence/{presenceUid} { match /live_presence/{presenceUid} {
function rideDoc() { function rideDoc() {
return get(/databases/$(database)/documents/group_rides/$(groupRideId)); return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
? get(/databases/$(database)/documents/group_rides/$(groupRideId))
: (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
: null);
} }
function isAcceptedParticipant(uid) { function isAcceptedParticipant(uid) {
@ -346,16 +481,21 @@ service cloud.firestore {
data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']); data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']);
} }
// READ: // Collection listing is strictly disallowed
// Caller must be signed in, be an accepted participant of this ride, target must be accepted participant, allow list: if false;
// 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() // READ (SINGLE DOCUMENT GET ONLY):
&& isAcceptedParticipant(request.auth.uid) // Self access: permitted as required by own-presence lifecycle / cleanup.
&& isAcceptedParticipant(presenceUid) // Peer read: caller accepted, target accepted, ride live-eligible, sharing enabled, not mutually blocked.
&& rideIsLiveEligible() allow get: if signedIn() && (
&& (
request.auth.uid == presenceUid || request.auth.uid == presenceUid ||
(resource.data.sharingEnabled == true && !areMutuallyBlocked(request.auth.uid, presenceUid)) (
isAcceptedParticipant(request.auth.uid) &&
isAcceptedParticipant(presenceUid) &&
rideIsLiveEligible() &&
resource.data.sharingEnabled == true &&
!areMutuallyBlocked(request.auth.uid, presenceUid)
)
); );
// WRITE (CREATE/UPDATE): // WRITE (CREATE/UPDATE):

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart'; import 'app_colors.dart';
import 'app_spacing.dart'; import 'app_spacing.dart';
import 'app_typography.dart'; import 'app_typography.dart';
@ -40,8 +41,15 @@ class AppTheme {
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: AppTypography.headlineSm, titleTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.darkOnSurface,
),
iconTheme: IconThemeData(color: AppColors.darkOnSurface), iconTheme: IconThemeData(color: AppColors.darkOnSurface),
actionsIconTheme: IconThemeData(color: AppColors.darkOnSurface),
systemOverlayStyle: SystemUiOverlayStyle.light,
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: AppColors.darkSurfaceContainer, color: AppColors.darkSurfaceContainer,
@ -237,8 +245,15 @@ class AppTheme {
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: AppTypography.headlineSm, titleTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.lightOnSurface,
),
iconTheme: IconThemeData(color: AppColors.lightOnSurface), iconTheme: IconThemeData(color: AppColors.lightOnSurface),
actionsIconTheme: IconThemeData(color: AppColors.lightOnSurface),
systemOverlayStyle: SystemUiOverlayStyle.dark,
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: AppColors.lightSurfaceContainer, color: AppColors.lightSurfaceContainer,

View File

@ -2065,11 +2065,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
Icons.radar, Icons.radar,
color: isCurrentlySharing color: isCurrentlySharing
? colors.cyberCyan ? colors.cyberCyan
: Colors.white70, : Theme.of(context).colorScheme.onSurfaceVariant,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
child: Text(
'LIVE RIDE PRESENCE', 'LIVE RIDE PRESENCE',
style: TextStyle( style: TextStyle(
color: colors.cyberCyan, color: colors.cyberCyan,
@ -2077,8 +2078,11 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
letterSpacing: 1.0, letterSpacing: 1.0,
fontSize: 12, fontSize: 12,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const Spacer(), ),
const SizedBox(width: 8),
if (isCurrentlySharing) if (isCurrentlySharing)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(

View File

@ -236,11 +236,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
loading: () => Center( loading: () => Center(
child: CircularProgressIndicator(color: colors.cyberCyan), child: CircularProgressIndicator(color: colors.cyberCyan),
), ),
error: (e, _) => Center( error: (e, _) => _buildErrorState(
child: Text( colors: colors,
'Error loading rides: $e', error: e,
style: TextStyle(color: colors.raceRed), onRetry: () => ref.invalidate(upcomingGroupRidesProvider),
),
), ),
); );
} }
@ -252,14 +251,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
ResponsiveSnapshot snapshot, ResponsiveSnapshot snapshot,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
) { ) {
final ridesAsync = ref.watch(upcomingGroupRidesProvider); final ridesAsync = ref.watch(myGroupRidesProvider);
return ridesAsync.when( return ridesAsync.when(
data: (rides) { data: (myRides) {
final myRides = rides
.where((r) => r.organizerUid == currentUid)
.toList();
if (myRides.isEmpty) { if (myRides.isEmpty) {
return _buildEmptyState( return _buildEmptyState(
colors: colors, colors: colors,
@ -305,10 +300,50 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
loading: () => Center( loading: () => Center(
child: CircularProgressIndicator(color: colors.cyberCyan), child: CircularProgressIndicator(color: colors.cyberCyan),
), ),
error: (e, _) => Center( error: (e, _) => _buildErrorState(
child: Text( colors: colors,
'Error: $e', error: e,
style: TextStyle(color: colors.raceRed), onRetry: () => ref.invalidate(myGroupRidesProvider),
),
);
}
Widget _buildErrorState({
required MotorSignalThemeColors colors,
required Object error,
required VoidCallback onRetry,
}) {
debugPrint('[GroupRidesHubScreen] Load error: $error');
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 44, color: colors.raceRed),
const SizedBox(height: 12),
const Text(
'Unable to load group rides. Try again.',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: colors.surfaceContainerHigh,
foregroundColor: Colors.white,
side: BorderSide(color: colors.outline),
),
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('RETRY'),
),
],
), ),
), ),
); );

View File

@ -338,9 +338,41 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
error: (e, _) => Scaffold( error: (e, _) => Scaffold(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
body: Center( body: Center(
child: Text( child: Padding(
'Error loading live presence: $e', padding: const EdgeInsets.all(24.0),
style: TextStyle(color: colors.raceRed), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.radar, size: 48, color: colors.warningAmber),
const SizedBox(height: 16),
Text(
'Unable to load live ride locations.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Try again.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 20),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
),
onPressed: () => ref.invalidate(groupRideLivePresenceProvider(widget.rideId)),
child: const Text('RETRY'),
),
],
),
), ),
), ),
), ),

View File

@ -526,6 +526,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
fontSize: 12, fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Container( Container(

View File

@ -63,12 +63,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text('Settings'),
'Settings',
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
),
), ),
body: SafeArea( body: SafeArea(
top: false,
child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context), child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context),
), ),
); );
@ -155,7 +153,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget _buildCompactLayout(BuildContext context) { Widget _buildCompactLayout(BuildContext context) {
return ListView( return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
children: [ children: [
_buildSectionHeader('Membership & Entitlement'), _buildSectionHeader('Membership & Entitlement'),
const SizedBox(height: 10), const SizedBox(height: 10),
@ -265,6 +263,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
fontSize: 12, fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),

View File

@ -363,6 +363,7 @@ class GroupRide {
final String? routeTitle; final String? routeTitle;
final double? routeDistanceMeters; final double? routeDistanceMeters;
final int? routeStopCount; final int? routeStopCount;
final List<String> accessUids;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final DateTime? canceledAt; final DateTime? canceledAt;
@ -382,6 +383,7 @@ class GroupRide {
required this.departureAt, required this.departureAt,
this.participantLimit, this.participantLimit,
this.participantCount = 1, this.participantCount = 1,
this.accessUids = const [],
required this.meetingPoint, required this.meetingPoint,
this.destination, this.destination,
this.stops = const [], this.stops = const [],
@ -419,6 +421,7 @@ class GroupRide {
'departureAt': departureAt.toIso8601String(), 'departureAt': departureAt.toIso8601String(),
if (participantLimit != null) 'participantLimit': participantLimit, if (participantLimit != null) 'participantLimit': participantLimit,
'participantCount': participantCount, 'participantCount': participantCount,
'accessUids': accessUids,
'meetingPoint': meetingPoint.toJson(), 'meetingPoint': meetingPoint.toJson(),
if (destination != null) 'destination': destination!.toJson(), if (destination != null) 'destination': destination!.toJson(),
'stops': stops.map((s) => s.toJson()).toList(), 'stops': stops.map((s) => s.toJson()).toList(),
@ -455,6 +458,11 @@ class GroupRide {
.toList() ?? .toList() ??
const []; const [];
final accessUidsList = (json['accessUids'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[json['organizerUid'] as String? ?? ''];
return GroupRide( return GroupRide(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
organizerUid: json['organizerUid'] as String? ?? '', organizerUid: json['organizerUid'] as String? ?? '',
@ -478,6 +486,7 @@ class GroupRide {
departureAt: departure, departureAt: departure,
participantLimit: json['participantLimit'] as int?, participantLimit: json['participantLimit'] as int?,
participantCount: json['participantCount'] as int? ?? 1, participantCount: json['participantCount'] as int? ?? 1,
accessUids: accessUidsList,
meetingPoint: json['meetingPoint'] != null meetingPoint: json['meetingPoint'] != null
? GroupRideLocation.fromJson( ? GroupRideLocation.fromJson(
json['meetingPoint'] as Map<String, dynamic>) json['meetingPoint'] as Map<String, dynamic>)
@ -516,6 +525,7 @@ class GroupRide {
DateTime? departureAt, DateTime? departureAt,
int? participantLimit, int? participantLimit,
int? participantCount, int? participantCount,
List<String>? accessUids,
GroupRideLocation? meetingPoint, GroupRideLocation? meetingPoint,
GroupRideLocation? destination, GroupRideLocation? destination,
List<GroupRideLocation>? stops, List<GroupRideLocation>? stops,
@ -542,6 +552,7 @@ class GroupRide {
departureAt: departureAt ?? this.departureAt, departureAt: departureAt ?? this.departureAt,
participantLimit: participantLimit ?? this.participantLimit, participantLimit: participantLimit ?? this.participantLimit,
participantCount: participantCount ?? this.participantCount, participantCount: participantCount ?? this.participantCount,
accessUids: accessUids ?? this.accessUids,
meetingPoint: meetingPoint ?? this.meetingPoint, meetingPoint: meetingPoint ?? this.meetingPoint,
destination: destination ?? this.destination, destination: destination ?? this.destination,
stops: stops ?? this.stops, stops: stops ?? this.stops,

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_providers.dart'; import '../auth/auth_providers.dart';
import 'group_ride_models.dart'; import 'group_ride_models.dart';
@ -16,11 +17,97 @@ final groupRideStreamProvider =
}); });
/// Stream of participants for a specific Group Ride. /// Stream of participants for a specific Group Ride.
/// Uses bounded point-document listeners per UID discovered via parent GroupRide.accessUids
/// (plus the signed-in user's own UID if available) to ensure zero collection list queries
/// and complete peer block isolation.
final groupRideParticipantsProvider = final groupRideParticipantsProvider =
StreamProvider.family<List<GroupRideParticipant>, String>((ref, rideId) { StreamProvider.family.autoDispose<List<GroupRideParticipant>, String>((ref, rideId) {
final service = ref.watch(groupRideServiceProvider); final service = ref.watch(groupRideServiceProvider);
return service.participantsStream(rideId); final rideAsync = ref.watch(groupRideStreamProvider(rideId));
final currentUser = ref.watch(currentUserProvider);
final controller = StreamController<List<GroupRideParticipant>>();
final participantSubscriptions = <String, StreamSubscription<GroupRideParticipant?>>{};
final activeParticipants = <String, GroupRideParticipant>{};
void emitCurrent() {
if (controller.isClosed) return;
final list = activeParticipants.values.toList()
..sort((a, b) {
if (a.role == GroupRideRole.organizer) return -1;
if (b.role == GroupRideRole.organizer) return 1;
return a.createdAt.compareTo(b.createdAt);
}); });
controller.add(list);
}
void reconcileCandidates(List<String> candidateUids) {
final candidateSet = candidateUids.toSet();
if (currentUser != null && !currentUser.isAnonymous) {
candidateSet.add(currentUser.uid);
}
final existingUids = participantSubscriptions.keys.toList();
for (final uid in existingUids) {
if (!candidateSet.contains(uid)) {
participantSubscriptions.remove(uid)?.cancel();
activeParticipants.remove(uid);
}
}
for (final uid in candidateSet) {
if (!participantSubscriptions.containsKey(uid)) {
final stream = service.participantDocStream(groupRideId: rideId, participantUid: uid);
participantSubscriptions[uid] = stream.listen(
(participant) {
if (participant != null) {
activeParticipants[uid] = participant;
} else {
activeParticipants.remove(uid);
}
emitCurrent();
},
onError: (_) {
activeParticipants.remove(uid);
emitCurrent();
},
);
}
}
emitCurrent();
}
rideAsync.when(
data: (ride) {
if (ride == null) {
for (final sub in participantSubscriptions.values) {
sub.cancel();
}
participantSubscriptions.clear();
activeParticipants.clear();
emitCurrent();
return;
}
reconcileCandidates(ride.accessUids);
},
error: (e, st) {
if (!controller.isClosed) controller.addError(e, st);
},
loading: () {},
);
ref.onDispose(() {
for (final sub in participantSubscriptions.values) {
sub.cancel();
}
participantSubscriptions.clear();
activeParticipants.clear();
controller.close();
});
return controller.stream;
});
/// Stream of upcoming Group Rides for the current user. /// Stream of upcoming Group Rides for the current user.
final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) { final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
@ -32,11 +119,171 @@ final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
return service.upcomingRidesStream(callerUid: user.uid); return service.upcomingRidesStream(callerUid: user.uid);
}); });
/// Stream of live presence records for all participants in a specific Group Ride. /// Stream of Group Rides organized by the current user.
final groupRideLivePresenceProvider = final myGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
StreamProvider.family<List<LiveGroupPresence>, String>((ref, rideId) { final user = ref.watch(currentUserProvider);
if (user == null || user.isAnonymous) {
return Stream.value([]);
}
final service = ref.watch(groupRideServiceProvider); final service = ref.watch(groupRideServiceProvider);
return service.livePresenceStream(rideId); return service.myRidesStream(callerUid: user.uid);
});
/// Stream of live presence records for all accepted participants in a specific Group Ride.
/// Uses bounded point-document listeners per candidate UID discovered via parent GroupRide.accessUids.
/// Isolates peer permission-denied / sharing-off events to the specific peer and recovers cleanly.
final groupRideLivePresenceProvider =
StreamProvider.family.autoDispose<List<LiveGroupPresence>, String>((ref, rideId) {
final service = ref.watch(groupRideServiceProvider);
final rideAsync = ref.watch(groupRideStreamProvider(rideId));
final controller = StreamController<List<LiveGroupPresence>>();
final activeSubscriptions = <String, StreamSubscription<LiveGroupPresence?>>{};
final activePresences = <String, LiveGroupPresence>{};
final retryTimers = <String, Timer>{};
final participantSubscriptions = <String, StreamSubscription<GroupRideParticipant?>>{};
final acceptedUids = <String>{};
void emitCurrent() {
if (controller.isClosed) return;
final now = DateTime.now();
final freshList = activePresences.values
.where((p) => p.sharingEnabled && p.updatedAt.isAfter(now.subtract(const Duration(seconds: 120))))
.toList();
controller.add(freshList);
}
// Periodic sweep for stale presences (> 120s)
final ttlTimer = Timer.periodic(const Duration(seconds: 15), (_) => emitCurrent());
void stopPresenceListener(String uid) {
retryTimers.remove(uid)?.cancel();
activeSubscriptions.remove(uid)?.cancel();
activePresences.remove(uid);
emitCurrent();
}
void startPresenceListener(String uid) {
if (activeSubscriptions.containsKey(uid) || !acceptedUids.contains(uid)) return;
retryTimers.remove(uid)?.cancel();
final stream = service.livePresenceDocStream(groupRideId: rideId, targetUid: uid);
activeSubscriptions[uid] = stream.listen(
(presence) {
if (presence != null && presence.sharingEnabled) {
activePresences[uid] = presence;
} else {
activePresences.remove(uid);
}
emitCurrent();
},
onError: (e) {
// Peer error (sharing disabled, mutual block, or permission-denied)
activePresences.remove(uid);
emitCurrent();
activeSubscriptions.remove(uid)?.cancel();
// Bounded retry to recover when sharing is toggled back ON
if (!controller.isClosed && acceptedUids.contains(uid)) {
retryTimers[uid] = Timer(const Duration(seconds: 10), () {
if (!controller.isClosed && acceptedUids.contains(uid)) {
startPresenceListener(uid);
}
});
}
},
);
}
void reconcileCandidates(List<String> candidateUids) {
final candidateSet = candidateUids.toSet();
// 1. Remove participant listeners and presence listeners for candidates no longer present
final existingUids = participantSubscriptions.keys.toList();
for (final uid in existingUids) {
if (!candidateSet.contains(uid)) {
participantSubscriptions.remove(uid)?.cancel();
acceptedUids.remove(uid);
stopPresenceListener(uid);
}
}
// 2. Add participant listener for new candidate UIDs
for (final uid in candidateSet) {
if (!participantSubscriptions.containsKey(uid)) {
final pStream = service.participantDocStream(groupRideId: rideId, participantUid: uid);
participantSubscriptions[uid] = pStream.listen(
(participant) {
final wasAccepted = acceptedUids.contains(uid);
final isNowAccepted = participant?.status == ParticipantStatus.accepted;
if (isNowAccepted) {
acceptedUids.add(uid);
if (!wasAccepted || !activeSubscriptions.containsKey(uid)) {
startPresenceListener(uid);
}
} else {
acceptedUids.remove(uid);
stopPresenceListener(uid);
}
},
onError: (err) {
// If participant document itself is denied (e.g. mutual block on roster), exclude UID
acceptedUids.remove(uid);
stopPresenceListener(uid);
},
);
}
}
}
rideAsync.when(
data: (ride) {
if (ride == null || ride.isCompleted || ride.isCanceled) {
for (final sub in participantSubscriptions.values) {
sub.cancel();
}
participantSubscriptions.clear();
for (final sub in activeSubscriptions.values) {
sub.cancel();
}
activeSubscriptions.clear();
for (final timer in retryTimers.values) {
timer.cancel();
}
retryTimers.clear();
acceptedUids.clear();
activePresences.clear();
emitCurrent();
return;
}
reconcileCandidates(ride.accessUids);
},
error: (e, st) {
if (!controller.isClosed) controller.addError(e, st);
},
loading: () {},
);
ref.onDispose(() {
ttlTimer.cancel();
for (final timer in retryTimers.values) {
timer.cancel();
}
retryTimers.clear();
for (final sub in participantSubscriptions.values) {
sub.cancel();
}
participantSubscriptions.clear();
for (final sub in activeSubscriptions.values) {
sub.cancel();
}
activeSubscriptions.clear();
controller.close();
});
return controller.stream;
}); });
/// Master privacy setting: whether group ride live location sharing is permitted by user. /// Master privacy setting: whether group ride live location sharing is permitted by user.

View File

@ -71,6 +71,7 @@ class GroupRideService {
departureAt: departureAt, departureAt: departureAt,
participantLimit: participantLimit, participantLimit: participantLimit,
participantCount: 1, participantCount: 1,
accessUids: [organizer.uid],
meetingPoint: meetingPoint, meetingPoint: meetingPoint,
destination: destination, destination: destination,
stops: stops, stops: stops,
@ -270,7 +271,14 @@ class GroupRideService {
updatedAt: now, updatedAt: now,
); );
await participantRef.set(invite.toJson()); final batch = firestore.batch();
batch.set(participantRef, invite.toJson());
batch.update(rideRef, {
'accessUids': FieldValue.arrayUnion([friendProfile.uid]),
'updatedAt': now.toIso8601String(),
});
await batch.commit();
} }
/// Responds to a Group Ride invitation (Invitee only). /// Responds to a Group Ride invitation (Invitee only).
@ -325,6 +333,11 @@ class GroupRideService {
'status': ParticipantStatus.declined.name, 'status': ParticipantStatus.declined.name,
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); });
transaction.update(rideRef, {
'accessUids': FieldValue.arrayRemove([callerUid]),
'updatedAt': now.toIso8601String(),
});
} }
}); });
} }
@ -439,6 +452,7 @@ class GroupRideService {
}); });
transaction.update(rideRef, { transaction.update(rideRef, {
'accessUids': FieldValue.arrayUnion([requesterUid]),
'participantCount': FieldValue.increment(1), 'participantCount': FieldValue.increment(1),
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); });
@ -504,6 +518,7 @@ class GroupRideService {
transaction.set(participantRef, participant.toJson()); transaction.set(participantRef, participant.toJson());
transaction.update(rideRef, { transaction.update(rideRef, {
'accessUids': FieldValue.arrayUnion([caller.uid]),
'participantCount': FieldValue.increment(1), 'participantCount': FieldValue.increment(1),
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); });
@ -555,11 +570,15 @@ class GroupRideService {
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); });
if (wasAccepted && ride.participantCount > 0) { if (ride.organizerUid != callerUid) {
transaction.update(rideRef, { final rideUpdates = <String, dynamic>{
'participantCount': FieldValue.increment(-1), 'accessUids': FieldValue.arrayRemove([callerUid]),
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); };
if (wasAccepted && ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
transaction.update(rideRef, rideUpdates);
} }
}); });
} }
@ -600,12 +619,14 @@ class GroupRideService {
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); });
if (wasAccepted && ride.participantCount > 0) { final rideUpdates = <String, dynamic>{
transaction.update(rideRef, { 'accessUids': FieldValue.arrayRemove([targetUid]),
'participantCount': FieldValue.increment(-1),
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); };
if (wasAccepted && ride.participantCount > 0) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
} }
transaction.update(rideRef, rideUpdates);
}); });
} }
@ -807,38 +828,63 @@ class GroupRideService {
} }
} }
/// Realtime stream of live presence records for all participants in an eligible ride. /// Realtime stream of a single participant's membership document.
Stream<List<LiveGroupPresence>> livePresenceStream(String groupRideId) { Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) {
final firestore = _firestore; final firestore = _firestore;
if (firestore == null) return Stream.value([]); if (firestore == null) return Stream.value(null);
return firestore
.collection('group_rides')
.doc(groupRideId)
.collection('participants')
.doc(participantUid)
.snapshots()
.map((snap) {
if (!snap.exists || snap.data() == null) return null;
try {
return GroupRideParticipant.fromJson(snap.data()!);
} catch (_) {
return null;
}
});
}
/// Realtime single-document stream for a specific participant's live presence.
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) {
final firestore = _firestore;
if (firestore == null) return Stream.value(null);
return firestore return firestore
.collection('group_rides') .collection('group_rides')
.doc(groupRideId) .doc(groupRideId)
.collection('live_presence') .collection('live_presence')
.doc(targetUid)
.snapshots() .snapshots()
.map((snap) { .map((snap) {
return snap.docs if (!snap.exists || snap.data() == null) return null;
.map((d) {
try { try {
return LiveGroupPresence.fromJson(d.data()); final presence = LiveGroupPresence.fromJson(snap.data()!);
return presence.sharingEnabled ? presence : null;
} catch (_) { } catch (_) {
return null; return null;
} }
})
.whereType<LiveGroupPresence>()
.where((p) => p.sharingEnabled)
.toList();
}); });
} }
/// Stream of upcoming Group Rides visible to the caller (excluding canceled/completed). /// Stream of upcoming Group Rides visible to the caller (personal schedule via accessUids).
Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) { Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) {
final firestore = _firestore; final firestore = _firestore;
if (firestore == null) return Stream.value([]); if (firestore == null) return Stream.value([]);
return firestore return firestore
.collection('group_rides') .collection('group_rides')
.where('accessUids', arrayContains: callerUid)
.where('status', whereIn: [ .where('status', whereIn: [
GroupRideStatus.scheduled.name, GroupRideStatus.scheduled.name,
GroupRideStatus.assembling.name, GroupRideStatus.assembling.name,
@ -851,7 +897,38 @@ class GroupRideService {
.map((d) { .map((d) {
try { try {
return GroupRide.fromJson(d.data()); return GroupRide.fromJson(d.data());
} catch (_) { } catch (e) {
debugPrint('[GroupRideService] upcomingRidesStream parse error: $e');
return null;
}
})
.whereType<GroupRide>()
.toList();
});
}
/// Stream of Group Rides organized by the caller.
Stream<List<GroupRide>> myRidesStream({required String callerUid}) {
final firestore = _firestore;
if (firestore == null) return Stream.value([]);
return firestore
.collection('group_rides')
.where('organizerUid', isEqualTo: callerUid)
.where('status', whereIn: [
GroupRideStatus.scheduled.name,
GroupRideStatus.assembling.name,
GroupRideStatus.underway.name,
])
.limit(30)
.snapshots()
.map((snap) {
return snap.docs
.map((d) {
try {
return GroupRide.fromJson(d.data());
} catch (e) {
debugPrint('[GroupRideService] myRidesStream parse error: $e');
return null; return null;
} }
}) })

View File

@ -121,8 +121,16 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
}); });
// 2. Listen to Participants (authorization check) // 2. Listen to Participants (authorization check)
final activeParticipantsStream = // Uses caller point-listener instead of broad collection query to adhere to allow list: if false security rule
participantsStream ?? service.participantsStream(rideId); final activeParticipantsStream = participantsStream ??
(callerUid.isNotEmpty
? service
.participantDocStream(
groupRideId: rideId,
participantUid: callerUid,
)
.map((p) => p != null ? [p] : <GroupRideParticipant>[])
: const Stream<List<GroupRideParticipant>>.empty());
_participantsSub = _participantsSub =
activeParticipantsStream.listen(_handleParticipantsUpdate, onError: (e) { activeParticipantsStream.listen(_handleParticipantsUpdate, onError: (e) {
debugPrint('[LiveBroadcastController] Participants stream error: $e'); debugPrint('[LiveBroadcastController] Participants stream error: $e');
@ -130,7 +138,7 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
// 3. Listen to Live Presence (for regroup awareness) // 3. Listen to Live Presence (for regroup awareness)
final activePresenceStream = final activePresenceStream =
presenceStream ?? service.livePresenceStream(rideId); presenceStream ?? const Stream<List<LiveGroupPresence>>.empty();
_presenceSub = activePresenceStream.listen(_handlePresenceUpdate, onError: (e) { _presenceSub = activePresenceStream.listen(_handlePresenceUpdate, onError: (e) {
debugPrint('[LiveBroadcastController] Presence stream error: $e'); debugPrint('[LiveBroadcastController] Presence stream error: $e');
}); });
@ -486,6 +494,14 @@ final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose
rideId: rideId, rideId: rideId,
callerUid: callerUid, callerUid: callerUid,
service: service, service: service,
participantsStream: callerUid.isNotEmpty
? service
.participantDocStream(
groupRideId: rideId,
participantUid: callerUid,
)
.map((p) => p != null ? [p] : <GroupRideParticipant>[])
: null,
positionStream: locationService.getFilteredPositionStream(), positionStream: locationService.getFilteredPositionStream(),
initialMasterAllowed: masterAllowed, initialMasterAllowed: masterAllowed,
); );

View File

@ -361,6 +361,91 @@ class FriendshipService {
batch.set(blockRef, block.toJson()); batch.set(blockRef, block.toJson());
await batch.commit(); await batch.commit();
// Clean up active shared group rides
try {
await _cleanupSharedGroupRidesOnBlock(
firestore: firestore,
myUid: myUid,
targetUid: targetUid,
);
} catch (e) {
debugPrint('[FriendshipService] Error cleaning up shared group rides on block: $e');
}
}
static Future<void> _cleanupSharedGroupRidesOnBlock({
required FirebaseFirestore firestore,
required String myUid,
required String targetUid,
}) async {
// 1. Rides organized by myUid where targetUid has access
final myRidesSnap = await firestore
.collection('group_rides')
.where('organizerUid', isEqualTo: myUid)
.where('accessUids', arrayContains: targetUid)
.get();
for (final rideDoc in myRidesSnap.docs) {
try {
final pRef = rideDoc.reference.collection('participants').doc(targetUid);
final pSnap = await pRef.get();
final wasAccepted = pSnap.exists && pSnap.data()?['status'] == 'accepted';
final batch = firestore.batch();
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([targetUid]),
'updatedAt': DateTime.now().toIso8601String(),
};
if (wasAccepted) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
batch.update(rideDoc.reference, rideUpdates);
if (pSnap.exists) {
batch.update(pRef, {
'status': 'removed',
'updatedAt': DateTime.now().toIso8601String(),
});
}
await batch.commit();
} catch (e) {
debugPrint('[FriendshipService] Evicting blocked user from ride ${rideDoc.id} failed: $e');
}
}
// 2. Rides organized by targetUid where myUid has access
final theirRidesSnap = await firestore
.collection('group_rides')
.where('organizerUid', isEqualTo: targetUid)
.where('accessUids', arrayContains: myUid)
.get();
for (final rideDoc in theirRidesSnap.docs) {
try {
final pRef = rideDoc.reference.collection('participants').doc(myUid);
final pSnap = await pRef.get();
final wasAccepted = pSnap.exists && pSnap.data()?['status'] == 'accepted';
final batch = firestore.batch();
final rideUpdates = <String, dynamic>{
'accessUids': FieldValue.arrayRemove([myUid]),
'updatedAt': DateTime.now().toIso8601String(),
};
if (wasAccepted) {
rideUpdates['participantCount'] = FieldValue.increment(-1);
}
batch.update(rideDoc.reference, rideUpdates);
if (pSnap.exists) {
batch.update(pRef, {
'status': 'left',
'updatedAt': DateTime.now().toIso8601String(),
});
}
await batch.commit();
} catch (e) {
debugPrint('[FriendshipService] Leaving ride ${rideDoc.id} of blocked user failed: $e');
}
}
} }
/// Unblocks a previously blocked rider. /// Unblocks a previously blocked rider.

View File

@ -289,6 +289,12 @@ class _FakeGroupRideService implements GroupRideService {
Stream<List<GroupRide>> watchActiveRides() => Stream.value([]); Stream<List<GroupRide>> watchActiveRides() => Stream.value([]);
Stream<List<GroupRide>> watchMyRides() => Stream.value([]); Stream<List<GroupRide>> watchMyRides() => Stream.value([]);
@override
Stream<List<GroupRide>> upcomingRidesStream({required String callerUid}) => watchUpcomingRides();
@override
Stream<List<GroupRide>> myRidesStream({required String callerUid}) => watchMyRides();
Stream<List<GroupRideParticipant>> watchParticipants(String rideId) => Stream.value([ Stream<List<GroupRideParticipant>> watchParticipants(String rideId) => Stream.value([
GroupRideParticipant( GroupRideParticipant(
userId: 'user-1', userId: 'user-1',
@ -302,7 +308,16 @@ class _FakeGroupRideService implements GroupRideService {
]); ]);
@override @override
Stream<List<LiveGroupPresence>> livePresenceStream(String rideId) => Stream.value([]); Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) => Stream.value(null);
@override
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) => Stream.value(null);
@override @override
Future<void> clearLivePresence({ Future<void> clearLivePresence({

View File

@ -1,5 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/app_colors.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/services/group_rides/group_ride_models.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/services/group_rides/group_ride_service.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
@ -130,11 +136,384 @@ void main() {
test('offline live presence and upcoming streams return safely', () async { test('offline live presence and upcoming streams return safely', () async {
final service = GroupRideService(); final service = GroupRideService();
final stream = service.livePresenceStream('ride_offline'); final stream = service.livePresenceDocStream(groupRideId: 'ride_offline', targetUid: 'user_alice');
expect(await stream.first, isEmpty); expect(await stream.first, isNull);
final pStream = service.participantDocStream(groupRideId: 'ride_offline', participantUid: 'user_alice');
expect(await pStream.first, isNull);
final upcoming = service.upcomingRidesStream(callerUid: 'user_alice'); final upcoming = service.upcomingRidesStream(callerUid: 'user_alice');
expect(await upcoming.first, isEmpty); expect(await upcoming.first, isEmpty);
}); });
}); });
group('Live Presence Point Listener Lifecycle Tests', () {
test('reconciles candidates from accessUids and only subscribes to accepted participants', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_123',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Sunday Morning Twisties',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_accepted', 'user_invited', 'user_declined'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_123').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<LiveGroupPresence>>[];
container.listen(
groupRideLivePresenceProvider('ride_123'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_accepted').add(
GroupRideParticipant(
userId: 'user_accepted',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_invited').add(
GroupRideParticipant(
userId: 'user_invited',
role: GroupRideRole.participant,
status: ParticipantStatus.invited,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_declined').add(
GroupRideParticipant(
userId: 'user_declined',
role: GroupRideRole.participant,
status: ParticipantStatus.declined,
riderHandle: 'charlie',
riderDisplayName: 'Charlie',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
final presenceAlice = LiveGroupPresence(
userId: 'user_accepted',
groupRideId: 'ride_123',
latitude: 37.7749,
longitude: -122.4194,
speedMph: 45.0,
sharingEnabled: true,
updatedAt: DateTime.now(),
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
);
fakeService.getPresenceController('user_accepted').add(presenceAlice);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.any((p) => p.userId == 'user_accepted'), isTrue);
expect(states.last.any((p) => p.userId == 'user_invited'), isFalse);
expect(states.last.any((p) => p.userId == 'user_declined'), isFalse);
});
test('peer error isolation: peer permission-denied does not drop other peer presence', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_error_isolation',
organizerUid: 'organizer_1',
organizerHandle: 'organizer',
organizerDisplayName: 'Organizer',
title: 'Ride',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_alice', 'user_bob'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_error_isolation').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<LiveGroupPresence>>[];
container.listen(
groupRideLivePresenceProvider('ride_error_isolation'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
userId: 'user_alice',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_bob').add(
GroupRideParticipant(
userId: 'user_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
final presenceAlice = LiveGroupPresence(
userId: 'user_alice',
groupRideId: 'ride_error_isolation',
latitude: 37.7749,
longitude: -122.4194,
speedMph: 45.0,
sharingEnabled: true,
updatedAt: DateTime.now(),
expiresAt: DateTime.now().add(const Duration(minutes: 5)),
);
fakeService.getPresenceController('user_alice').add(presenceAlice);
fakeService.getPresenceController('user_bob').addError('permission-denied');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 1);
expect(states.last.first.userId, 'user_alice');
});
test('participants provider reconciles candidates from accessUids and callerUid without broad collection queries', () async {
final fakeService = _FakeGroupRideLifecycleService();
final rideStreamController = StreamController<GroupRide?>.broadcast();
final rideDoc = GroupRide(
id: 'ride_participants_test',
organizerUid: 'user_alice',
organizerHandle: 'alice',
organizerDisplayName: 'Alice',
title: 'Ride',
status: GroupRideStatus.scheduled,
scheduledStartAt: DateTime.now(),
departureAt: DateTime.now().add(const Duration(minutes: 15)),
meetingPoint: const GroupRideLocation(latitude: 37.77, longitude: -122.41, displayName: 'SF'),
joinPolicy: GroupRideJoinPolicy.open,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
accessUids: const ['user_alice', 'user_bob'],
);
final container = ProviderContainer(
overrides: [
groupRideServiceProvider.overrideWithValue(fakeService),
groupRideStreamProvider('ride_participants_test').overrideWith((ref) => rideStreamController.stream),
],
);
addTearDown(container.dispose);
final states = <List<GroupRideParticipant>>[];
container.listen(
groupRideParticipantsProvider('ride_participants_test'),
(previous, next) {
next.whenData((data) => states.add(data));
},
);
rideStreamController.add(rideDoc);
await Future<void>.delayed(const Duration(milliseconds: 20));
fakeService.getParticipantController('user_alice').add(
GroupRideParticipant(
userId: 'user_alice',
role: GroupRideRole.organizer,
status: ParticipantStatus.accepted,
riderHandle: 'alice',
riderDisplayName: 'Alice',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
fakeService.getParticipantController('user_bob').add(
GroupRideParticipant(
userId: 'user_bob',
role: GroupRideRole.participant,
status: ParticipantStatus.accepted,
riderHandle: 'bob',
riderDisplayName: 'Bob',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.isNotEmpty, isTrue);
expect(states.last.length, 2);
expect(states.last.first.userId, 'user_alice');
expect(states.last.last.userId, 'user_bob');
});
});
group('UI Layout and Styling Regression Tests', () {
testWidgets('Live presence header does not overflow on 393px width constraint', (tester) async {
tester.view.physicalSize = const Size(393, 852);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.darkTheme,
home: Scaffold(
body: Center(
child: SizedBox(
width: 393,
child: Row(
children: [
const Icon(Icons.sensors, color: AppColors.brandBlue, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'LIVE RIDE PRESENCE',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: Colors.white,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.brandBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.brandBlue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
const Text(
'BROADCASTING',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.brandBlue,
),
),
],
),
),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('LIVE RIDE PRESENCE'), findsOneWidget);
expect(find.text('BROADCASTING'), findsOneWidget);
});
test('Light theme AppBar has dark status bar icons and high contrast title', () {
final lightTheme = AppTheme.lightTheme;
expect(lightTheme.appBarTheme.titleTextStyle?.color, AppColors.lightOnSurface);
expect(lightTheme.appBarTheme.iconTheme?.color, AppColors.lightOnSurface);
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
});
});
}
class _FakeGroupRideLifecycleService implements GroupRideService {
final Map<String, StreamController<GroupRideParticipant?>> participantControllers = {};
final Map<String, StreamController<LiveGroupPresence?>> presenceControllers = {};
StreamController<GroupRideParticipant?> getParticipantController(String uid) {
return participantControllers.putIfAbsent(
uid,
() => StreamController<GroupRideParticipant?>.broadcast(),
);
}
StreamController<LiveGroupPresence?> getPresenceController(String uid) {
return presenceControllers.putIfAbsent(
uid,
() => StreamController<LiveGroupPresence?>.broadcast(),
);
}
@override
Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) {
return getParticipantController(participantUid).stream;
}
@override
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) {
return getPresenceController(targetUid).stream;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }