diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 5b6c3ed..3fac68a 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -4,7 +4,7 @@ import { assertSucceeds, initializeTestEnvironment, } 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 path from 'node:path'; @@ -386,6 +386,7 @@ describe('Social Architecture Security Rules', () => { scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, + accessUids: ['alice'], visibility: 'friendsOnly', joinPolicy: 'inviteOnly', meetingPoint: { @@ -408,6 +409,21 @@ describe('Social Architecture Security Rules', () => { 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 await assertFails(setDoc(doc(bob, 'group_rides/ride_fake'), { ...rideData, @@ -431,6 +447,7 @@ describe('Social Architecture Security Rules', () => { scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, + accessUids: ['alice'], visibility: 'private', meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, }); @@ -441,6 +458,7 @@ describe('Social Architecture Security Rules', () => { scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, + accessUids: ['alice'], visibility: 'friendsOnly', meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' }, }); @@ -474,6 +492,7 @@ describe('Social Architecture Security Rules', () => { scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, + accessUids: ['alice', 'bob'], visibility: 'friendsOnly', 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 () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); @@ -536,6 +724,7 @@ describe('Social Architecture Security Rules', () => { scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, + accessUids: ['alice', 'bob', 'charlie'], visibility: 'public', meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' }, }); @@ -621,9 +810,21 @@ describe('Social Architecture Security Rules', () => { 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'))); + // 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 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'))); + // 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 await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); @@ -659,6 +881,7 @@ describe('Social Architecture Security Rules', () => { scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, + accessUids: ['alice', 'bob'], visibility: 'public', 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', departureAt: '2026-09-10T09:30:00Z', participantCount: 2, + accessUids: ['alice', 'bob'], visibility: 'public', 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 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(); // Bob deletes his participant record (voluntary leave) 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 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) await resetLiveState(); await testEnv.withSecurityRulesDisabled(async (context) => { @@ -781,6 +1032,17 @@ describe('Social Architecture Security Rules', () => { // Bob CANNOT read Alice's live presence 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) await resetLiveState(); // Both can read initially @@ -792,8 +1054,33 @@ describe('Social Architecture Security Rules', () => { await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Bob CANNOT read Alice's presence await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice'))); + + // 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 () => { const alice = testEnv.authenticatedContext('alice').firestore(); const bob = testEnv.authenticatedContext('bob').firestore(); diff --git a/firestore.indexes.json b/firestore.indexes.json index 2ddb5ce..765a81c 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -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": [] } \ No newline at end of file diff --git a/firestore.rules b/firestore.rules index 0e36435..c81a6de 100644 --- a/firestore.rules +++ b/firestore.rules @@ -194,8 +194,9 @@ service cloud.firestore { return signedIn() && resource.data.organizerUid == request.auth.uid; } - function isParticipant() { - return signedIn() && exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)); + function hasParticipantAccess(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() { @@ -204,10 +205,10 @@ service cloud.firestore { function canReadRide() { return signedIn() && ( - // Organizer or active participant can always read if not mutually blocked - (isOrganizer() || isParticipant()) || + isOrganizer() || ( !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( + hasParticipantAccess(request.auth.uid) || resource.data.visibility == 'public' || (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() && request.resource.data.organizerUid == request.auth.uid && request.resource.data.id == groupRideId - && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) - && request.resource.data.participantCount >= 1 + && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint', 'accessUids']) + && request.resource.data.accessUids == [request.auth.uid] + && request.resource.data.participantCount == 1 && ( !('routeId' in request.resource.data) || 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 ) ); + function validStatusTransition() { let prev = resource.data.status; let next = request.resource.data.status; @@ -250,67 +262,186 @@ service cloud.firestore { ); } - allow update: if signedIn() && ( - // Organizer can update ride authority fields with valid status transitions and owned route attachment - (isOrganizer() && validStatusTransition() && validRouteAttachment()) || - // Participants/invitees can only atomically adjust participantCount during join/leave transactions - ( + function validOrganizerRideUpdate() { + 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 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() && - 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(); // --- PARTICIPANTS SUBCOLLECTION --- match /participants/{participantUid} { 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() { 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 == rideOrganizer() || - exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) || - rideDoc().data.visibility == 'public' || - (rideDoc().data.visibility == 'friendsOnly' && exists(/databases/$(database)/documents/users/$(rideOrganizer())/friends/$(request.auth.uid))) + ( + isAcceptedParticipant(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 allow create: if signedIn() && ( // 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 ( request.auth.uid == rideOrganizer() && request.resource.data.status == 'invited' && + request.resource.data.role == 'participant' && !areMutuallyBlocked(request.auth.uid, participantUid) ) || - // Rider requesting to join or joining open ride + // Rider requesting to join (joinPolicy == requestToJoin) ( 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()) ) ); // State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer allow update: if signedIn() && ( - // Participant responding to invite or leaving + // Invitee accepting own invitation (invited -> accepted) ( request.auth.uid == participantUid && - ( - request.resource.data.status in ['accepted', 'declined', 'left'] || - // Transfer target becoming organizer - (request.resource.data.role == 'organizer' && request.auth.uid == rideOrganizer()) - ) + resource.data.status == 'invited' && + request.resource.data.status == 'accepted' && + request.resource.data.role == resource.data.role ) || - // 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. match /live_presence/{presenceUid} { 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) { @@ -346,17 +481,22 @@ service cloud.firestore { 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, target must be accepted participant, - // ride must be live-eligible, and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target). - allow read: if signedIn() - && isAcceptedParticipant(request.auth.uid) - && isAcceptedParticipant(presenceUid) - && rideIsLiveEligible() - && ( - request.auth.uid == presenceUid || - (resource.data.sharingEnabled == true && !areMutuallyBlocked(request.auth.uid, presenceUid)) - ); + // Collection listing is strictly disallowed + allow list: if false; + + // READ (SINGLE DOCUMENT GET ONLY): + // Self access: permitted as required by own-presence lifecycle / cleanup. + // Peer read: caller accepted, target accepted, ride live-eligible, sharing enabled, not mutually blocked. + allow get: if signedIn() && ( + request.auth.uid == presenceUid || + ( + isAcceptedParticipant(request.auth.uid) && + isAcceptedParticipant(presenceUid) && + rideIsLiveEligible() && + resource.data.sharingEnabled == true && + !areMutuallyBlocked(request.auth.uid, presenceUid) + ) + ); // WRITE (CREATE/UPDATE): // Caller must write only their own document, must be an accepted participant, diff --git a/lib/core/theme/theme.dart b/lib/core/theme/theme.dart index 1f2ca7f..f96c1fc 100644 --- a/lib/core/theme/theme.dart +++ b/lib/core/theme/theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'app_colors.dart'; import 'app_spacing.dart'; import 'app_typography.dart'; @@ -40,8 +41,15 @@ class AppTheme { elevation: 0, centerTitle: true, scrolledUnderElevation: 0, - titleTextStyle: AppTypography.headlineSm, + titleTextStyle: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.darkOnSurface, + ), iconTheme: IconThemeData(color: AppColors.darkOnSurface), + actionsIconTheme: IconThemeData(color: AppColors.darkOnSurface), + systemOverlayStyle: SystemUiOverlayStyle.light, ), cardTheme: CardThemeData( color: AppColors.darkSurfaceContainer, @@ -237,8 +245,15 @@ class AppTheme { elevation: 0, centerTitle: true, scrolledUnderElevation: 0, - titleTextStyle: AppTypography.headlineSm, + titleTextStyle: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.lightOnSurface, + ), iconTheme: IconThemeData(color: AppColors.lightOnSurface), + actionsIconTheme: IconThemeData(color: AppColors.lightOnSurface), + systemOverlayStyle: SystemUiOverlayStyle.dark, ), cardTheme: CardThemeData( color: AppColors.lightSurfaceContainer, diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index db0dda8..028cf83 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -2065,20 +2065,24 @@ class _GroupRideDetailScreenState extends ConsumerState { Icons.radar, color: isCurrentlySharing ? colors.cyberCyan - : Colors.white70, + : Theme.of(context).colorScheme.onSurfaceVariant, size: 20, ), const SizedBox(width: 8), - Text( - 'LIVE RIDE PRESENCE', - style: TextStyle( - color: colors.cyberCyan, - fontWeight: FontWeight.bold, - letterSpacing: 1.0, - fontSize: 12, + Expanded( + child: Text( + 'LIVE RIDE PRESENCE', + style: TextStyle( + color: colors.cyberCyan, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - const Spacer(), + const SizedBox(width: 8), if (isCurrentlySharing) Container( padding: const EdgeInsets.symmetric( diff --git a/lib/features/group_rides/group_rides_hub_screen.dart b/lib/features/group_rides/group_rides_hub_screen.dart index 53f29a2..6045dcc 100644 --- a/lib/features/group_rides/group_rides_hub_screen.dart +++ b/lib/features/group_rides/group_rides_hub_screen.dart @@ -236,11 +236,10 @@ class _GroupRidesHubScreenState extends ConsumerState loading: () => Center( child: CircularProgressIndicator(color: colors.cyberCyan), ), - error: (e, _) => Center( - child: Text( - 'Error loading rides: $e', - style: TextStyle(color: colors.raceRed), - ), + error: (e, _) => _buildErrorState( + colors: colors, + error: e, + onRetry: () => ref.invalidate(upcomingGroupRidesProvider), ), ); } @@ -252,14 +251,10 @@ class _GroupRidesHubScreenState extends ConsumerState ResponsiveSnapshot snapshot, MotorSignalThemeColors colors, ) { - final ridesAsync = ref.watch(upcomingGroupRidesProvider); + final ridesAsync = ref.watch(myGroupRidesProvider); return ridesAsync.when( - data: (rides) { - final myRides = rides - .where((r) => r.organizerUid == currentUid) - .toList(); - + data: (myRides) { if (myRides.isEmpty) { return _buildEmptyState( colors: colors, @@ -305,10 +300,50 @@ class _GroupRidesHubScreenState extends ConsumerState loading: () => Center( child: CircularProgressIndicator(color: colors.cyberCyan), ), - error: (e, _) => Center( - child: Text( - 'Error: $e', - style: TextStyle(color: colors.raceRed), + error: (e, _) => _buildErrorState( + colors: colors, + error: e, + 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'), + ), + ], ), ), ); diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart index 6ddf64e..5dfc25f 100644 --- a/lib/features/group_rides/live_group_ride_screen.dart +++ b/lib/features/group_rides/live_group_ride_screen.dart @@ -338,9 +338,41 @@ class _LiveGroupRideScreenState extends ConsumerState error: (e, _) => Scaffold( backgroundColor: colors.surfaceContainer, body: Center( - child: Text( - 'Error loading live presence: $e', - style: TextStyle(color: colors.raceRed), + child: Padding( + padding: const EdgeInsets.all(24.0), + 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'), + ), + ], + ), ), ), ), diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 625d7ef..3796347 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -526,6 +526,8 @@ class _AccountScreenState extends ConsumerState { fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), Container( diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 5af3cc2..9f6cd32 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -63,12 +63,10 @@ class _SettingsScreenState extends ConsumerState { return Scaffold( appBar: AppBar( - title: const Text( - 'Settings', - style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5), - ), + title: const Text('Settings'), ), body: SafeArea( + top: false, child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context), ), ); @@ -155,7 +153,7 @@ class _SettingsScreenState extends ConsumerState { Widget _buildCompactLayout(BuildContext context) { return ListView( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), children: [ _buildSectionHeader('Membership & Entitlement'), const SizedBox(height: 10), @@ -265,6 +263,8 @@ class _SettingsScreenState extends ConsumerState { fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), diff --git a/lib/services/group_rides/group_ride_models.dart b/lib/services/group_rides/group_ride_models.dart index 6b09ed3..d46e12e 100644 --- a/lib/services/group_rides/group_ride_models.dart +++ b/lib/services/group_rides/group_ride_models.dart @@ -363,6 +363,7 @@ class GroupRide { final String? routeTitle; final double? routeDistanceMeters; final int? routeStopCount; + final List accessUids; final DateTime createdAt; final DateTime updatedAt; final DateTime? canceledAt; @@ -382,6 +383,7 @@ class GroupRide { required this.departureAt, this.participantLimit, this.participantCount = 1, + this.accessUids = const [], required this.meetingPoint, this.destination, this.stops = const [], @@ -419,6 +421,7 @@ class GroupRide { 'departureAt': departureAt.toIso8601String(), if (participantLimit != null) 'participantLimit': participantLimit, 'participantCount': participantCount, + 'accessUids': accessUids, 'meetingPoint': meetingPoint.toJson(), if (destination != null) 'destination': destination!.toJson(), 'stops': stops.map((s) => s.toJson()).toList(), @@ -455,6 +458,11 @@ class GroupRide { .toList() ?? const []; + final accessUidsList = (json['accessUids'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [json['organizerUid'] as String? ?? '']; + return GroupRide( id: json['id'] as String? ?? '', organizerUid: json['organizerUid'] as String? ?? '', @@ -478,6 +486,7 @@ class GroupRide { departureAt: departure, participantLimit: json['participantLimit'] as int?, participantCount: json['participantCount'] as int? ?? 1, + accessUids: accessUidsList, meetingPoint: json['meetingPoint'] != null ? GroupRideLocation.fromJson( json['meetingPoint'] as Map) @@ -516,6 +525,7 @@ class GroupRide { DateTime? departureAt, int? participantLimit, int? participantCount, + List? accessUids, GroupRideLocation? meetingPoint, GroupRideLocation? destination, List? stops, @@ -542,6 +552,7 @@ class GroupRide { departureAt: departureAt ?? this.departureAt, participantLimit: participantLimit ?? this.participantLimit, participantCount: participantCount ?? this.participantCount, + accessUids: accessUids ?? this.accessUids, meetingPoint: meetingPoint ?? this.meetingPoint, destination: destination ?? this.destination, stops: stops ?? this.stops, diff --git a/lib/services/group_rides/group_ride_providers.dart b/lib/services/group_rides/group_ride_providers.dart index 6e7e0d5..f9b9a77 100644 --- a/lib/services/group_rides/group_ride_providers.dart +++ b/lib/services/group_rides/group_ride_providers.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_providers.dart'; import 'group_ride_models.dart'; @@ -16,12 +17,98 @@ final groupRideStreamProvider = }); /// 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 = - StreamProvider.family, String>((ref, rideId) { + StreamProvider.family.autoDispose, String>((ref, rideId) { final service = ref.watch(groupRideServiceProvider); - return service.participantsStream(rideId); + final rideAsync = ref.watch(groupRideStreamProvider(rideId)); + final currentUser = ref.watch(currentUserProvider); + + final controller = StreamController>(); + final participantSubscriptions = >{}; + final activeParticipants = {}; + + 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 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. final upcomingGroupRidesProvider = StreamProvider>((ref) { final user = ref.watch(currentUserProvider); @@ -32,11 +119,171 @@ final upcomingGroupRidesProvider = StreamProvider>((ref) { return service.upcomingRidesStream(callerUid: user.uid); }); -/// Stream of live presence records for all participants in a specific Group Ride. -final groupRideLivePresenceProvider = - StreamProvider.family, String>((ref, rideId) { +/// Stream of Group Rides organized by the current user. +final myGroupRidesProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value([]); + } 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, String>((ref, rideId) { + final service = ref.watch(groupRideServiceProvider); + final rideAsync = ref.watch(groupRideStreamProvider(rideId)); + + final controller = StreamController>(); + final activeSubscriptions = >{}; + final activePresences = {}; + final retryTimers = {}; + final participantSubscriptions = >{}; + final acceptedUids = {}; + + 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 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. diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index 4090a45..711d6d2 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -71,6 +71,7 @@ class GroupRideService { departureAt: departureAt, participantLimit: participantLimit, participantCount: 1, + accessUids: [organizer.uid], meetingPoint: meetingPoint, destination: destination, stops: stops, @@ -270,7 +271,14 @@ class GroupRideService { 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). @@ -325,6 +333,11 @@ class GroupRideService { 'status': ParticipantStatus.declined.name, 'updatedAt': now.toIso8601String(), }); + + transaction.update(rideRef, { + 'accessUids': FieldValue.arrayRemove([callerUid]), + 'updatedAt': now.toIso8601String(), + }); } }); } @@ -439,6 +452,7 @@ class GroupRideService { }); transaction.update(rideRef, { + 'accessUids': FieldValue.arrayUnion([requesterUid]), 'participantCount': FieldValue.increment(1), 'updatedAt': now.toIso8601String(), }); @@ -504,6 +518,7 @@ class GroupRideService { transaction.set(participantRef, participant.toJson()); transaction.update(rideRef, { + 'accessUids': FieldValue.arrayUnion([caller.uid]), 'participantCount': FieldValue.increment(1), 'updatedAt': now.toIso8601String(), }); @@ -555,11 +570,15 @@ class GroupRideService { 'updatedAt': now.toIso8601String(), }); - if (wasAccepted && ride.participantCount > 0) { - transaction.update(rideRef, { - 'participantCount': FieldValue.increment(-1), + if (ride.organizerUid != callerUid) { + final rideUpdates = { + 'accessUids': FieldValue.arrayRemove([callerUid]), '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(), }); + final rideUpdates = { + 'accessUids': FieldValue.arrayRemove([targetUid]), + 'updatedAt': now.toIso8601String(), + }; if (wasAccepted && ride.participantCount > 0) { - transaction.update(rideRef, { - 'participantCount': FieldValue.increment(-1), - 'updatedAt': now.toIso8601String(), - }); + 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. - Stream> livePresenceStream(String groupRideId) { + /// Realtime stream of a single participant's membership document. + Stream participantDocStream({ + required String groupRideId, + required String participantUid, + }) { 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 livePresenceDocStream({ + required String groupRideId, + required String targetUid, + }) { + final firestore = _firestore; + if (firestore == null) return Stream.value(null); return firestore .collection('group_rides') .doc(groupRideId) .collection('live_presence') + .doc(targetUid) .snapshots() .map((snap) { - return snap.docs - .map((d) { - try { - return LiveGroupPresence.fromJson(d.data()); - } catch (_) { - return null; - } - }) - .whereType() - .where((p) => p.sharingEnabled) - .toList(); + if (!snap.exists || snap.data() == null) return null; + try { + final presence = LiveGroupPresence.fromJson(snap.data()!); + return presence.sharingEnabled ? presence : null; + } catch (_) { + return null; + } }); } - /// 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> upcomingRidesStream({required String callerUid}) { final firestore = _firestore; if (firestore == null) return Stream.value([]); return firestore .collection('group_rides') + .where('accessUids', arrayContains: callerUid) .where('status', whereIn: [ GroupRideStatus.scheduled.name, GroupRideStatus.assembling.name, @@ -851,7 +897,38 @@ class GroupRideService { .map((d) { try { return GroupRide.fromJson(d.data()); - } catch (_) { + } catch (e) { + debugPrint('[GroupRideService] upcomingRidesStream parse error: $e'); + return null; + } + }) + .whereType() + .toList(); + }); + } + + /// Stream of Group Rides organized by the caller. + Stream> 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; } }) diff --git a/lib/services/group_rides/live_broadcast_controller.dart b/lib/services/group_rides/live_broadcast_controller.dart index def11b6..3b827c7 100644 --- a/lib/services/group_rides/live_broadcast_controller.dart +++ b/lib/services/group_rides/live_broadcast_controller.dart @@ -121,8 +121,16 @@ class LiveBroadcastController extends StateNotifier { }); // 2. Listen to Participants (authorization check) - final activeParticipantsStream = - participantsStream ?? service.participantsStream(rideId); + // Uses caller point-listener instead of broad collection query to adhere to allow list: if false security rule + final activeParticipantsStream = participantsStream ?? + (callerUid.isNotEmpty + ? service + .participantDocStream( + groupRideId: rideId, + participantUid: callerUid, + ) + .map((p) => p != null ? [p] : []) + : const Stream>.empty()); _participantsSub = activeParticipantsStream.listen(_handleParticipantsUpdate, onError: (e) { debugPrint('[LiveBroadcastController] Participants stream error: $e'); @@ -130,7 +138,7 @@ class LiveBroadcastController extends StateNotifier { // 3. Listen to Live Presence (for regroup awareness) final activePresenceStream = - presenceStream ?? service.livePresenceStream(rideId); + presenceStream ?? const Stream>.empty(); _presenceSub = activePresenceStream.listen(_handlePresenceUpdate, onError: (e) { debugPrint('[LiveBroadcastController] Presence stream error: $e'); }); @@ -486,6 +494,14 @@ final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose rideId: rideId, callerUid: callerUid, service: service, + participantsStream: callerUid.isNotEmpty + ? service + .participantDocStream( + groupRideId: rideId, + participantUid: callerUid, + ) + .map((p) => p != null ? [p] : []) + : null, positionStream: locationService.getFilteredPositionStream(), initialMasterAllowed: masterAllowed, ); diff --git a/lib/services/social/friendship_service.dart b/lib/services/social/friendship_service.dart index f0db338..bb9061c 100644 --- a/lib/services/social/friendship_service.dart +++ b/lib/services/social/friendship_service.dart @@ -361,6 +361,91 @@ class FriendshipService { batch.set(blockRef, block.toJson()); 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 _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 = { + '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 = { + '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. diff --git a/test/canonical_whole_app_regression_matrix_test.dart b/test/canonical_whole_app_regression_matrix_test.dart index 2c66028..3be7d07 100644 --- a/test/canonical_whole_app_regression_matrix_test.dart +++ b/test/canonical_whole_app_regression_matrix_test.dart @@ -289,6 +289,12 @@ class _FakeGroupRideService implements GroupRideService { Stream> watchActiveRides() => Stream.value([]); Stream> watchMyRides() => Stream.value([]); + + @override + Stream> upcomingRidesStream({required String callerUid}) => watchUpcomingRides(); + + @override + Stream> myRidesStream({required String callerUid}) => watchMyRides(); Stream> watchParticipants(String rideId) => Stream.value([ GroupRideParticipant( userId: 'user-1', @@ -302,7 +308,16 @@ class _FakeGroupRideService implements GroupRideService { ]); @override - Stream> livePresenceStream(String rideId) => Stream.value([]); + Stream participantDocStream({ + required String groupRideId, + required String participantUid, + }) => Stream.value(null); + + @override + Stream livePresenceDocStream({ + required String groupRideId, + required String targetUid, + }) => Stream.value(null); @override Future clearLivePresence({ diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart index ed76578..08b6182 100644 --- a/test/group_ride_service_test.dart +++ b/test/group_ride_service_test.dart @@ -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: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_providers.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_service.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 { final service = GroupRideService(); - final stream = service.livePresenceStream('ride_offline'); - expect(await stream.first, isEmpty); + final stream = service.livePresenceDocStream(groupRideId: 'ride_offline', targetUid: 'user_alice'); + 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'); 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.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 = >[]; + container.listen( + groupRideLivePresenceProvider('ride_123'), + (previous, next) { + next.whenData((data) => states.add(data)); + }, + ); + + rideStreamController.add(rideDoc); + await Future.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.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.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.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 = >[]; + container.listen( + groupRideLivePresenceProvider('ride_error_isolation'), + (previous, next) { + next.whenData((data) => states.add(data)); + }, + ); + + rideStreamController.add(rideDoc); + await Future.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.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.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.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 = >[]; + container.listen( + groupRideParticipantsProvider('ride_participants_test'), + (previous, next) { + next.whenData((data) => states.add(data)); + }, + ); + + rideStreamController.add(rideDoc); + await Future.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.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> participantControllers = {}; + final Map> presenceControllers = {}; + + StreamController getParticipantController(String uid) { + return participantControllers.putIfAbsent( + uid, + () => StreamController.broadcast(), + ); + } + + StreamController getPresenceController(String uid) { + return presenceControllers.putIfAbsent( + uid, + () => StreamController.broadcast(), + ); + } + + @override + Stream participantDocStream({ + required String groupRideId, + required String participantUid, + }) { + return getParticipantController(participantUid).stream; + } + + @override + Stream livePresenceDocStream({ + required String groupRideId, + required String targetUid, + }) { + return getPresenceController(targetUid).stream; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }