fix: harden group ride privacy and live presence
This commit is contained in:
parent
ea3650f43d
commit
2319222f22
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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": []
|
||||||
}
|
}
|
||||||
218
firestore.rules
218
firestore.rules
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue