fix: harden group ride privacy and live presence
This commit is contained in:
parent
ea3650f43d
commit
2319222f22
|
|
@ -4,7 +4,7 @@ import {
|
|||
assertSucceeds,
|
||||
initializeTestEnvironment,
|
||||
} from '@firebase/rules-unit-testing';
|
||||
import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc } from 'firebase/firestore';
|
||||
import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } from 'firebase/firestore';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
|
|
@ -386,6 +386,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice'],
|
||||
visibility: 'friendsOnly',
|
||||
joinPolicy: 'inviteOnly',
|
||||
meetingPoint: {
|
||||
|
|
@ -408,6 +409,21 @@ describe('Social Architecture Security Rules', () => {
|
|||
riderDisplayName: 'Alice Apex',
|
||||
}));
|
||||
|
||||
// Alice can create ride AND organizer participant in an atomic WriteBatch (as done by client app)
|
||||
const batch = writeBatch(alice);
|
||||
batch.set(doc(alice, 'group_rides/ride_batch_101'), {
|
||||
...rideData,
|
||||
id: 'ride_batch_101',
|
||||
});
|
||||
batch.set(doc(alice, 'group_rides/ride_batch_101/participants/alice'), {
|
||||
userId: 'alice',
|
||||
role: 'organizer',
|
||||
status: 'accepted',
|
||||
riderHandle: 'alice_apex',
|
||||
riderDisplayName: 'Alice Apex',
|
||||
});
|
||||
await assertSucceeds(batch.commit());
|
||||
|
||||
// Bob CANNOT create a ride claiming Alice is the organizer
|
||||
await assertFails(setDoc(doc(bob, 'group_rides/ride_fake'), {
|
||||
...rideData,
|
||||
|
|
@ -431,6 +447,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice'],
|
||||
visibility: 'private',
|
||||
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
||||
});
|
||||
|
|
@ -441,6 +458,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice'],
|
||||
visibility: 'friendsOnly',
|
||||
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
||||
});
|
||||
|
|
@ -474,6 +492,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice', 'bob'],
|
||||
visibility: 'friendsOnly',
|
||||
meetingPoint: { latitude: 0, longitude: 0, displayName: 'Spot' },
|
||||
});
|
||||
|
|
@ -519,6 +538,175 @@ describe('Social Architecture Security Rules', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
test('upcoming rides and my rides collection queries enforce accessUids and organizerUid', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
const unauth = testEnv.unauthenticatedContext().firestore();
|
||||
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
// Alice's ride with Bob invited
|
||||
await setDoc(doc(db, 'group_rides/ride_query_test'), {
|
||||
id: 'ride_query_test',
|
||||
organizerUid: 'alice',
|
||||
title: 'Query Verification Ride',
|
||||
status: 'scheduled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice', 'bob'],
|
||||
visibility: 'friendsOnly',
|
||||
joinPolicy: 'inviteOnly',
|
||||
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' },
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Alice queries My Rides (organizerUid == 'alice') -> SUCCEEDS
|
||||
const myRidesQuery = query(
|
||||
collection(alice, 'group_rides'),
|
||||
where('organizerUid', '==', 'alice'),
|
||||
where('status', 'in', ['scheduled', 'assembling', 'underway']),
|
||||
);
|
||||
const aliceSnap = await assertSucceeds(getDocs(myRidesQuery));
|
||||
expect(aliceSnap.docs.length).toBe(1);
|
||||
|
||||
// 2. Bob queries Upcoming Rides (accessUids array-contains 'bob') -> SUCCEEDS
|
||||
const bobUpcomingQuery = query(
|
||||
collection(bob, 'group_rides'),
|
||||
where('accessUids', 'array-contains', 'bob'),
|
||||
where('status', 'in', ['scheduled', 'assembling', 'underway']),
|
||||
);
|
||||
const bobSnap = await assertSucceeds(getDocs(bobUpcomingQuery));
|
||||
expect(bobSnap.docs.length).toBe(1);
|
||||
|
||||
// 3. Charlie queries Upcoming Rides (accessUids array-contains 'charlie') -> SUCCEEDS (0 results)
|
||||
const charlieUpcomingQuery = query(
|
||||
collection(charlie, 'group_rides'),
|
||||
where('accessUids', 'array-contains', 'charlie'),
|
||||
where('status', 'in', ['scheduled', 'assembling', 'underway']),
|
||||
);
|
||||
const charlieSnap = await assertSucceeds(getDocs(charlieUpcomingQuery));
|
||||
expect(charlieSnap.docs.length).toBe(0);
|
||||
|
||||
// 4. Broad query WITHOUT accessUids or organizerUid -> DENIED
|
||||
const broadQuery = query(
|
||||
collection(bob, 'group_rides'),
|
||||
where('status', '==', 'scheduled'),
|
||||
);
|
||||
await assertFails(getDocs(broadQuery));
|
||||
|
||||
// 5. Unauthenticated user CANNOT query group_rides
|
||||
const unauthQuery = query(
|
||||
collection(unauth, 'group_rides'),
|
||||
where('organizerUid', '==', 'alice'),
|
||||
where('status', 'in', ['scheduled', 'assembling', 'underway']),
|
||||
);
|
||||
await assertFails(getDocs(unauthQuery));
|
||||
});
|
||||
|
||||
test('status-aware participant authorization and privilege escalation prevention', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
// Ride with requestToJoin policy
|
||||
await setDoc(doc(db, 'group_rides/ride_request_only'), {
|
||||
id: 'ride_request_only',
|
||||
organizerUid: 'alice',
|
||||
title: 'Request Only Ride',
|
||||
status: 'scheduled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice'],
|
||||
visibility: 'public',
|
||||
joinPolicy: 'requestToJoin',
|
||||
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point R' },
|
||||
});
|
||||
// Ride with open join policy
|
||||
await setDoc(doc(db, 'group_rides/ride_open'), {
|
||||
id: 'ride_open',
|
||||
organizerUid: 'alice',
|
||||
title: 'Open Ride',
|
||||
status: 'scheduled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice'],
|
||||
visibility: 'public',
|
||||
joinPolicy: 'open',
|
||||
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point O' },
|
||||
});
|
||||
// Ride with inviteOnly policy
|
||||
await setDoc(doc(db, 'group_rides/ride_invite_strict'), {
|
||||
id: 'ride_invite_strict',
|
||||
organizerUid: 'alice',
|
||||
title: 'Invite Only Ride',
|
||||
status: 'scheduled',
|
||||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 1,
|
||||
accessUids: ['alice'],
|
||||
visibility: 'public',
|
||||
joinPolicy: 'inviteOnly',
|
||||
meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point I' },
|
||||
});
|
||||
});
|
||||
|
||||
// 1. On requestToJoin ride, Charlie CANNOT self-join with 'accepted'
|
||||
await assertFails(setDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), {
|
||||
userId: 'charlie',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
}));
|
||||
|
||||
// 2. Charlie CAN create 'requested' participant record
|
||||
await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), {
|
||||
userId: 'charlie',
|
||||
role: 'participant',
|
||||
status: 'requested',
|
||||
}));
|
||||
|
||||
// 3. Charlie CANNOT self-promote from 'requested' to 'accepted'
|
||||
await assertFails(updateDoc(doc(charlie, 'group_rides/ride_request_only/participants/charlie'), {
|
||||
status: 'accepted',
|
||||
}));
|
||||
|
||||
// 4. Organizer Alice CAN approve Charlie's request
|
||||
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_request_only/participants/charlie'), {
|
||||
status: 'accepted',
|
||||
}));
|
||||
|
||||
// 5. On inviteOnly ride, stranger Dave CANNOT request or join
|
||||
await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), {
|
||||
userId: 'dave',
|
||||
role: 'participant',
|
||||
status: 'requested',
|
||||
}));
|
||||
await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), {
|
||||
userId: 'dave',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
}));
|
||||
|
||||
// 6. On open ride, Dave CAN directly join with 'accepted'
|
||||
await assertSucceeds(setDoc(doc(dave, 'group_rides/ride_open/participants/dave'), {
|
||||
userId: 'dave',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
}));
|
||||
|
||||
// 7. Dave can update accessUids and increment participantCount on open ride
|
||||
await assertSucceeds(updateDoc(doc(dave, 'group_rides/ride_open'), {
|
||||
accessUids: ['alice', 'dave'],
|
||||
participantCount: 2,
|
||||
updatedAt: '2026-09-10T09:05:00Z',
|
||||
}));
|
||||
});
|
||||
|
||||
test('live group presence security rules', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
|
@ -536,6 +724,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 2,
|
||||
accessUids: ['alice', 'bob', 'charlie'],
|
||||
visibility: 'public',
|
||||
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' },
|
||||
});
|
||||
|
|
@ -621,9 +810,21 @@ describe('Social Architecture Security Rules', () => {
|
|||
latitude: 95.0,
|
||||
}));
|
||||
|
||||
// 8. Accepted participant Alice CAN read Bob's presence
|
||||
// 8. Accepted participant Alice CAN read Bob's presence via point-get
|
||||
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 8b. Collection listing is STRICTLY DENIED for everyone (including organizer and accepted participants)
|
||||
await assertFails(getDocs(collection(alice, 'group_rides/ride_live/live_presence')));
|
||||
await assertFails(getDocs(collection(bob, 'group_rides/ride_live/live_presence')));
|
||||
await assertFails(getDocs(collection(dave, 'group_rides/ride_live/live_presence')));
|
||||
await assertFails(getDocs(collection(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_live/live_presence')));
|
||||
|
||||
// 8c. Signed-out point-get is DENIED
|
||||
await assertFails(getDoc(doc(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 8d. Invited Charlie CANNOT point-get Bob's presence
|
||||
await assertFails(getDoc(doc(charlie, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 9. Stranger Dave CANNOT read Bob's presence even on public ride
|
||||
await assertFails(getDoc(doc(dave, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
|
|
@ -647,6 +848,27 @@ describe('Social Architecture Security Rules', () => {
|
|||
});
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_live/live_presence/bob')));
|
||||
|
||||
// 11b. Participant roster block privacy: Peer Charlie blocks Peer Bob
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'group_rides/ride_live/participants/charlie'), {
|
||||
userId: 'charlie',
|
||||
role: 'participant',
|
||||
status: 'accepted',
|
||||
riderHandle: 'charlie_rider',
|
||||
riderDisplayName: 'Charlie Rider',
|
||||
});
|
||||
await setDoc(doc(db, 'users/charlie/blocks/bob'), { blockedUid: 'bob' });
|
||||
});
|
||||
// Charlie CANNOT read Bob's participant document due to mutual block
|
||||
await assertFails(getDoc(doc(charlie, 'group_rides/ride_live/participants/bob')));
|
||||
// Bob CANNOT read Charlie's participant document due to mutual block
|
||||
await assertFails(getDoc(doc(bob, 'group_rides/ride_live/participants/charlie')));
|
||||
// But Organizer Alice CAN still read Bob's participant document
|
||||
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_live/participants/bob')));
|
||||
// And Bob CAN read his own participant document
|
||||
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_live/participants/bob')));
|
||||
|
||||
// 12. If ride is canceled, publishing is denied
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
|
|
@ -659,6 +881,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 2,
|
||||
accessUids: ['alice', 'bob'],
|
||||
visibility: 'public',
|
||||
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'San Francisco' },
|
||||
});
|
||||
|
|
@ -712,6 +935,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||
departureAt: '2026-09-10T09:30:00Z',
|
||||
participantCount: 2,
|
||||
accessUids: ['alice', 'bob'],
|
||||
visibility: 'public',
|
||||
meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' },
|
||||
});
|
||||
|
|
@ -748,7 +972,7 @@ describe('Social Architecture Security Rules', () => {
|
|||
// Alice CANNOT read Bob's old presence
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
|
||||
// Scenario B: Voluntary Leave (Bob leaves the ride)
|
||||
// Scenario B: Voluntary Leave (Bob deletes his participant record)
|
||||
await resetLiveState();
|
||||
// Bob deletes his participant record (voluntary leave)
|
||||
await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob')));
|
||||
|
|
@ -760,6 +984,33 @@ describe('Social Architecture Security Rules', () => {
|
|||
// Alice CANNOT read Bob's presence
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
|
||||
// Scenario B2: Voluntary Leave via status transition (status: 'accepted' -> 'left', presence doc temporarily remains)
|
||||
await resetLiveState();
|
||||
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'), {
|
||||
status: 'left',
|
||||
}));
|
||||
// Stale presence document still exists in database, but Alice direct get is DENIED
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
// Bob self get for cleanup verification remains ALLOWED
|
||||
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
|
||||
// Scenario B3: Organizer removal via status transition (status: 'accepted' -> 'removed', presence doc temporarily remains)
|
||||
await resetLiveState();
|
||||
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'), {
|
||||
status: 'removed',
|
||||
}));
|
||||
// Stale presence document still exists in database, but Alice direct get is DENIED
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
|
||||
// Scenario B4: Participant status transition to 'declined' (presence doc temporarily remains)
|
||||
await resetLiveState();
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await updateDoc(doc(db, 'group_rides/ride_lifecycle/participants/bob'), { status: 'declined' });
|
||||
});
|
||||
// Stale presence document still exists in database, but Alice direct get is DENIED
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
|
||||
// Scenario C: Completed Ride (Ride marked completed)
|
||||
await resetLiveState();
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
|
|
@ -781,6 +1032,17 @@ describe('Social Architecture Security Rules', () => {
|
|||
// Bob CANNOT read Alice's live presence
|
||||
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice')));
|
||||
|
||||
// Scenario C2: Canceled Ride (Ride marked canceled, presence doc temporarily remains)
|
||||
await resetLiveState();
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await updateDoc(doc(db, 'group_rides/ride_lifecycle'), { status: 'canceled' });
|
||||
});
|
||||
// Alice CANNOT read Bob's live presence
|
||||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
// Bob CANNOT read Alice's live presence
|
||||
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice')));
|
||||
|
||||
// Scenario D: Mid-Session Block (Alice blocks Bob during active ride)
|
||||
await resetLiveState();
|
||||
// Both can read initially
|
||||
|
|
@ -792,7 +1054,32 @@ describe('Social Architecture Security Rules', () => {
|
|||
await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob')));
|
||||
// Bob CANNOT read Alice's presence
|
||||
await assertFails(getDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/alice')));
|
||||
|
||||
// Scenario E: Participants Collection Listing is STRICTLY DENIED for everyone
|
||||
await resetLiveState();
|
||||
await assertFails(getDocs(collection(alice, 'group_rides/ride_lifecycle/participants')));
|
||||
await assertFails(getDocs(collection(bob, 'group_rides/ride_lifecycle/participants')));
|
||||
await assertFails(getDocs(collection(testEnv.authenticatedContext('stranger').firestore(), 'group_rides/ride_lifecycle/participants')));
|
||||
await assertFails(getDocs(collection(testEnv.unauthenticatedContext().firestore(), 'group_rides/ride_lifecycle/participants')));
|
||||
|
||||
// Scenario F: Participant single-doc get authorization
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||
const db = context.firestore();
|
||||
await setDoc(doc(db, 'group_rides/ride_lifecycle/participants/charlie'), {
|
||||
userId: 'charlie',
|
||||
role: 'participant',
|
||||
status: 'invited',
|
||||
});
|
||||
});
|
||||
// Charlie (invited) CAN read his own participant document
|
||||
await assertSucceeds(getDoc(doc(charlie, 'group_rides/ride_lifecycle/participants/charlie')));
|
||||
// Charlie (invited) CANNOT read peer Bob's participant document
|
||||
await assertFails(getDoc(doc(charlie, 'group_rides/ride_lifecycle/participants/bob')));
|
||||
// Organizer Alice CAN read invited Charlie's participant document
|
||||
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/participants/charlie')));
|
||||
});
|
||||
|
||||
|
||||
test('quick status security: organizer cannot impersonate or modify another rider status', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
218
firestore.rules
218
firestore.rules
|
|
@ -194,8 +194,9 @@ service cloud.firestore {
|
|||
return signedIn() && resource.data.organizerUid == request.auth.uid;
|
||||
}
|
||||
|
||||
function isParticipant() {
|
||||
return signedIn() && exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid));
|
||||
function hasParticipantAccess(uid) {
|
||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
||||
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status in ['invited', 'accepted'];
|
||||
}
|
||||
|
||||
function isFriendOfOrganizer() {
|
||||
|
|
@ -204,10 +205,10 @@ service cloud.firestore {
|
|||
|
||||
function canReadRide() {
|
||||
return signedIn() && (
|
||||
// Organizer or active participant can always read if not mutually blocked
|
||||
(isOrganizer() || isParticipant()) ||
|
||||
isOrganizer() ||
|
||||
(
|
||||
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
||||
hasParticipantAccess(request.auth.uid) ||
|
||||
resource.data.visibility == 'public' ||
|
||||
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
|
||||
)
|
||||
|
|
@ -215,12 +216,22 @@ service cloud.firestore {
|
|||
);
|
||||
}
|
||||
|
||||
allow read: if canReadRide();
|
||||
allow get: if canReadRide();
|
||||
|
||||
allow list: if signedIn() && (
|
||||
resource.data.organizerUid == request.auth.uid ||
|
||||
(
|
||||
'accessUids' in resource.data &&
|
||||
request.auth.uid in resource.data.accessUids
|
||||
)
|
||||
);
|
||||
|
||||
allow create: if signedIn()
|
||||
&& request.resource.data.organizerUid == request.auth.uid
|
||||
&& request.resource.data.id == groupRideId
|
||||
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
|
||||
&& request.resource.data.participantCount >= 1
|
||||
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint', 'accessUids'])
|
||||
&& request.resource.data.accessUids == [request.auth.uid]
|
||||
&& request.resource.data.participantCount == 1
|
||||
&& (
|
||||
!('routeId' in request.resource.data) ||
|
||||
request.resource.data.routeId == null ||
|
||||
|
|
@ -229,6 +240,7 @@ service cloud.firestore {
|
|||
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
|
||||
)
|
||||
);
|
||||
|
||||
function validStatusTransition() {
|
||||
let prev = resource.data.status;
|
||||
let next = request.resource.data.status;
|
||||
|
|
@ -250,67 +262,186 @@ service cloud.firestore {
|
|||
);
|
||||
}
|
||||
|
||||
allow update: if signedIn() && (
|
||||
// Organizer can update ride authority fields with valid status transitions and owned route attachment
|
||||
(isOrganizer() && validStatusTransition() && validRouteAttachment()) ||
|
||||
// Participants/invitees can only atomically adjust participantCount during join/leave transactions
|
||||
(
|
||||
function validOrganizerRideUpdate() {
|
||||
let hasPrevAccess = 'accessUids' in resource.data;
|
||||
let hasNextAccess = 'accessUids' in request.resource.data;
|
||||
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
|
||||
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
|
||||
|
||||
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
|
||||
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
|
||||
|
||||
let added = nextAccess.removeAll(prevAccess);
|
||||
let removed = prevAccess.removeAll(nextAccess);
|
||||
let countDelta = nextCount - prevCount;
|
||||
|
||||
return validStatusTransition() && validRouteAttachment() && (
|
||||
(added.size() == 0 && removed.size() == 0 && countDelta == 0) ||
|
||||
(added.size() == 1 && removed.size() == 0 && countDelta == 0) ||
|
||||
(added.size() == 1 && removed.size() == 0 && countDelta == 1) ||
|
||||
(added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0))
|
||||
);
|
||||
}
|
||||
|
||||
function validParticipantRideUpdate() {
|
||||
let hasPrevAccess = 'accessUids' in resource.data;
|
||||
let hasNextAccess = 'accessUids' in request.resource.data;
|
||||
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
|
||||
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
|
||||
|
||||
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
|
||||
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
|
||||
|
||||
let added = nextAccess.removeAll(prevAccess);
|
||||
let removed = prevAccess.removeAll(nextAccess);
|
||||
let countDelta = nextCount - prevCount;
|
||||
|
||||
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
|
||||
canReadRide() &&
|
||||
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt'])
|
||||
(
|
||||
// Join open ride: caller added to accessUids, count +1
|
||||
(
|
||||
resource.data.joinPolicy == 'open' &&
|
||||
added == [request.auth.uid] &&
|
||||
removed.size() == 0 &&
|
||||
countDelta == 1
|
||||
) ||
|
||||
// Accept invite: caller was already in accessUids, count +1
|
||||
(
|
||||
request.auth.uid in prevAccess &&
|
||||
added.size() == 0 &&
|
||||
removed.size() == 0 &&
|
||||
countDelta == 1
|
||||
) ||
|
||||
// Decline invite or leave: caller removed from accessUids, count unchanged (declined) or count -1 (left accepted)
|
||||
(
|
||||
request.auth.uid in prevAccess &&
|
||||
removed == [request.auth.uid] &&
|
||||
added.size() == 0 &&
|
||||
(countDelta == -1 || countDelta == 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
allow update: if signedIn() && (
|
||||
(isOrganizer() && validOrganizerRideUpdate()) ||
|
||||
validParticipantRideUpdate()
|
||||
);
|
||||
|
||||
allow delete: if isOrganizer();
|
||||
|
||||
// --- PARTICIPANTS SUBCOLLECTION ---
|
||||
match /participants/{participantUid} {
|
||||
function rideDoc() {
|
||||
return get(/databases/$(database)/documents/group_rides/$(groupRideId));
|
||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
? get(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
: (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
: null);
|
||||
}
|
||||
|
||||
function rideOrganizer() {
|
||||
return rideDoc().data.organizerUid;
|
||||
}
|
||||
|
||||
allow read: if signedIn() && (
|
||||
function isAcceptedParticipant(uid) {
|
||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
||||
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
|
||||
}
|
||||
|
||||
// Collection listing is strictly disallowed (point-read architecture over known accessUids)
|
||||
allow list: if false;
|
||||
|
||||
// Single-document read: self, organizer, or accepted peer (if not mutually blocked)
|
||||
allow get: if signedIn() && (
|
||||
request.auth.uid == participantUid ||
|
||||
request.auth.uid == rideOrganizer() ||
|
||||
exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) ||
|
||||
rideDoc().data.visibility == 'public' ||
|
||||
(rideDoc().data.visibility == 'friendsOnly' && exists(/databases/$(database)/documents/users/$(rideOrganizer())/friends/$(request.auth.uid)))
|
||||
(
|
||||
isAcceptedParticipant(request.auth.uid) &&
|
||||
!areMutuallyBlocked(request.auth.uid, participantUid)
|
||||
)
|
||||
);
|
||||
|
||||
// Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create
|
||||
allow create: if signedIn() && (
|
||||
// Self-organizer initialization on ride create
|
||||
(request.auth.uid == participantUid && request.resource.data.role == 'organizer') ||
|
||||
(
|
||||
request.auth.uid == participantUid &&
|
||||
request.auth.uid == rideOrganizer() &&
|
||||
request.resource.data.role == 'organizer' &&
|
||||
request.resource.data.status == 'accepted'
|
||||
) ||
|
||||
// Organizer inviting a non-blocked rider
|
||||
(
|
||||
request.auth.uid == rideOrganizer() &&
|
||||
request.resource.data.status == 'invited' &&
|
||||
request.resource.data.role == 'participant' &&
|
||||
!areMutuallyBlocked(request.auth.uid, participantUid)
|
||||
) ||
|
||||
// Rider requesting to join or joining open ride
|
||||
// Rider requesting to join (joinPolicy == requestToJoin)
|
||||
(
|
||||
request.auth.uid == participantUid &&
|
||||
(request.resource.data.status == 'requested' || request.resource.data.status == 'accepted') &&
|
||||
request.auth.uid != rideOrganizer() &&
|
||||
rideDoc().data.joinPolicy == 'requestToJoin' &&
|
||||
request.resource.data.status == 'requested' &&
|
||||
request.resource.data.role == 'participant' &&
|
||||
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
||||
) ||
|
||||
// Rider joining open ride (joinPolicy == open)
|
||||
(
|
||||
request.auth.uid == participantUid &&
|
||||
request.auth.uid != rideOrganizer() &&
|
||||
rideDoc().data.joinPolicy == 'open' &&
|
||||
request.resource.data.status == 'accepted' &&
|
||||
request.resource.data.role == 'participant' &&
|
||||
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
||||
)
|
||||
);
|
||||
|
||||
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
|
||||
allow update: if signedIn() && (
|
||||
// Participant responding to invite or leaving
|
||||
// Invitee accepting own invitation (invited -> accepted)
|
||||
(
|
||||
request.auth.uid == participantUid &&
|
||||
(
|
||||
request.resource.data.status in ['accepted', 'declined', 'left'] ||
|
||||
// Transfer target becoming organizer
|
||||
(request.resource.data.role == 'organizer' && request.auth.uid == rideOrganizer())
|
||||
)
|
||||
resource.data.status == 'invited' &&
|
||||
request.resource.data.status == 'accepted' &&
|
||||
request.resource.data.role == resource.data.role
|
||||
) ||
|
||||
// Organizer approving, removing, or transferring
|
||||
// Invitee declining own invitation (invited -> declined)
|
||||
(
|
||||
request.auth.uid == rideOrganizer()
|
||||
request.auth.uid == participantUid &&
|
||||
resource.data.status == 'invited' &&
|
||||
request.resource.data.status == 'declined' &&
|
||||
request.resource.data.role == resource.data.role
|
||||
) ||
|
||||
// Participant leaving (accepted -> left)
|
||||
(
|
||||
request.auth.uid == participantUid &&
|
||||
resource.data.status == 'accepted' &&
|
||||
request.resource.data.status == 'left' &&
|
||||
request.resource.data.role == resource.data.role
|
||||
) ||
|
||||
// Organizer approving request (requested -> accepted)
|
||||
(
|
||||
request.auth.uid == rideOrganizer() &&
|
||||
resource.data.status == 'requested' &&
|
||||
request.resource.data.status == 'accepted'
|
||||
) ||
|
||||
// Organizer declining request (requested -> declined)
|
||||
(
|
||||
request.auth.uid == rideOrganizer() &&
|
||||
resource.data.status == 'requested' &&
|
||||
request.resource.data.status == 'declined'
|
||||
) ||
|
||||
// Organizer removing participant (status -> removed)
|
||||
(
|
||||
request.auth.uid == rideOrganizer() &&
|
||||
request.resource.data.status == 'removed'
|
||||
) ||
|
||||
// Organizer role transfer
|
||||
(
|
||||
request.auth.uid == rideOrganizer() &&
|
||||
request.resource.data.role in ['organizer', 'participant']
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -324,7 +455,11 @@ service cloud.firestore {
|
|||
// Ephemeral location and status sharing during an active or assembling ride.
|
||||
match /live_presence/{presenceUid} {
|
||||
function rideDoc() {
|
||||
return get(/databases/$(database)/documents/group_rides/$(groupRideId));
|
||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
? get(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
: (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
|
||||
: null);
|
||||
}
|
||||
|
||||
function isAcceptedParticipant(uid) {
|
||||
|
|
@ -346,16 +481,21 @@ service cloud.firestore {
|
|||
data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']);
|
||||
}
|
||||
|
||||
// READ:
|
||||
// Caller must be signed in, be an accepted participant of this ride, target must be accepted participant,
|
||||
// ride must be live-eligible, and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target).
|
||||
allow read: if signedIn()
|
||||
&& isAcceptedParticipant(request.auth.uid)
|
||||
&& isAcceptedParticipant(presenceUid)
|
||||
&& rideIsLiveEligible()
|
||||
&& (
|
||||
// Collection listing is strictly disallowed
|
||||
allow list: if false;
|
||||
|
||||
// READ (SINGLE DOCUMENT GET ONLY):
|
||||
// Self access: permitted as required by own-presence lifecycle / cleanup.
|
||||
// Peer read: caller accepted, target accepted, ride live-eligible, sharing enabled, not mutually blocked.
|
||||
allow get: if signedIn() && (
|
||||
request.auth.uid == presenceUid ||
|
||||
(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):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_spacing.dart';
|
||||
import 'app_typography.dart';
|
||||
|
|
@ -40,8 +41,15 @@ class AppTheme {
|
|||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
titleTextStyle: AppTypography.headlineSm,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkOnSurface,
|
||||
),
|
||||
iconTheme: IconThemeData(color: AppColors.darkOnSurface),
|
||||
actionsIconTheme: IconThemeData(color: AppColors.darkOnSurface),
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.darkSurfaceContainer,
|
||||
|
|
@ -237,8 +245,15 @@ class AppTheme {
|
|||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
titleTextStyle: AppTypography.headlineSm,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.lightOnSurface,
|
||||
),
|
||||
iconTheme: IconThemeData(color: AppColors.lightOnSurface),
|
||||
actionsIconTheme: IconThemeData(color: AppColors.lightOnSurface),
|
||||
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.lightSurfaceContainer,
|
||||
|
|
|
|||
|
|
@ -2065,11 +2065,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
Icons.radar,
|
||||
color: isCurrentlySharing
|
||||
? colors.cyberCyan
|
||||
: Colors.white70,
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
Expanded(
|
||||
child: Text(
|
||||
'LIVE RIDE PRESENCE',
|
||||
style: TextStyle(
|
||||
color: colors.cyberCyan,
|
||||
|
|
@ -2077,8 +2078,11 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
|||
letterSpacing: 1.0,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Spacer(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isCurrentlySharing)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
|
|
|||
|
|
@ -236,11 +236,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
loading: () => Center(
|
||||
child: CircularProgressIndicator(color: colors.cyberCyan),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text(
|
||||
'Error loading rides: $e',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
),
|
||||
error: (e, _) => _buildErrorState(
|
||||
colors: colors,
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(upcomingGroupRidesProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -252,14 +251,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
ResponsiveSnapshot snapshot,
|
||||
MotorSignalThemeColors colors,
|
||||
) {
|
||||
final ridesAsync = ref.watch(upcomingGroupRidesProvider);
|
||||
final ridesAsync = ref.watch(myGroupRidesProvider);
|
||||
|
||||
return ridesAsync.when(
|
||||
data: (rides) {
|
||||
final myRides = rides
|
||||
.where((r) => r.organizerUid == currentUid)
|
||||
.toList();
|
||||
|
||||
data: (myRides) {
|
||||
if (myRides.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
colors: colors,
|
||||
|
|
@ -305,10 +300,50 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
loading: () => Center(
|
||||
child: CircularProgressIndicator(color: colors.cyberCyan),
|
||||
),
|
||||
error: (e, _) => Center(
|
||||
child: Text(
|
||||
'Error: $e',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
error: (e, _) => _buildErrorState(
|
||||
colors: colors,
|
||||
error: e,
|
||||
onRetry: () => ref.invalidate(myGroupRidesProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState({
|
||||
required MotorSignalThemeColors colors,
|
||||
required Object error,
|
||||
required VoidCallback onRetry,
|
||||
}) {
|
||||
debugPrint('[GroupRidesHubScreen] Load error: $error');
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 44, color: colors.raceRed),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Unable to load group rides. Try again.',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.surfaceContainerHigh,
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: colors.outline),
|
||||
),
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('RETRY'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -338,9 +338,41 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
|||
error: (e, _) => Scaffold(
|
||||
backgroundColor: colors.surfaceContainer,
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Error loading live presence: $e',
|
||||
style: TextStyle(color: colors.raceRed),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.radar, size: 48, color: colors.warningAmber),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Unable to load live ride locations.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Try again.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
onPressed: () => ref.invalidate(groupRideLivePresenceProvider(widget.rideId)),
|
||||
child: const Text('RETRY'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -526,6 +526,8 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
|||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
|
|
|
|||
|
|
@ -63,12 +63,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Settings',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||
),
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: isWide ? _buildWideLayout(context) : _buildCompactLayout(context),
|
||||
),
|
||||
);
|
||||
|
|
@ -155,7 +153,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
|
||||
Widget _buildCompactLayout(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
children: [
|
||||
_buildSectionHeader('Membership & Entitlement'),
|
||||
const SizedBox(height: 10),
|
||||
|
|
@ -265,6 +263,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ class GroupRide {
|
|||
final String? routeTitle;
|
||||
final double? routeDistanceMeters;
|
||||
final int? routeStopCount;
|
||||
final List<String> accessUids;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? canceledAt;
|
||||
|
|
@ -382,6 +383,7 @@ class GroupRide {
|
|||
required this.departureAt,
|
||||
this.participantLimit,
|
||||
this.participantCount = 1,
|
||||
this.accessUids = const [],
|
||||
required this.meetingPoint,
|
||||
this.destination,
|
||||
this.stops = const [],
|
||||
|
|
@ -419,6 +421,7 @@ class GroupRide {
|
|||
'departureAt': departureAt.toIso8601String(),
|
||||
if (participantLimit != null) 'participantLimit': participantLimit,
|
||||
'participantCount': participantCount,
|
||||
'accessUids': accessUids,
|
||||
'meetingPoint': meetingPoint.toJson(),
|
||||
if (destination != null) 'destination': destination!.toJson(),
|
||||
'stops': stops.map((s) => s.toJson()).toList(),
|
||||
|
|
@ -455,6 +458,11 @@ class GroupRide {
|
|||
.toList() ??
|
||||
const [];
|
||||
|
||||
final accessUidsList = (json['accessUids'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[json['organizerUid'] as String? ?? ''];
|
||||
|
||||
return GroupRide(
|
||||
id: json['id'] as String? ?? '',
|
||||
organizerUid: json['organizerUid'] as String? ?? '',
|
||||
|
|
@ -478,6 +486,7 @@ class GroupRide {
|
|||
departureAt: departure,
|
||||
participantLimit: json['participantLimit'] as int?,
|
||||
participantCount: json['participantCount'] as int? ?? 1,
|
||||
accessUids: accessUidsList,
|
||||
meetingPoint: json['meetingPoint'] != null
|
||||
? GroupRideLocation.fromJson(
|
||||
json['meetingPoint'] as Map<String, dynamic>)
|
||||
|
|
@ -516,6 +525,7 @@ class GroupRide {
|
|||
DateTime? departureAt,
|
||||
int? participantLimit,
|
||||
int? participantCount,
|
||||
List<String>? accessUids,
|
||||
GroupRideLocation? meetingPoint,
|
||||
GroupRideLocation? destination,
|
||||
List<GroupRideLocation>? stops,
|
||||
|
|
@ -542,6 +552,7 @@ class GroupRide {
|
|||
departureAt: departureAt ?? this.departureAt,
|
||||
participantLimit: participantLimit ?? this.participantLimit,
|
||||
participantCount: participantCount ?? this.participantCount,
|
||||
accessUids: accessUids ?? this.accessUids,
|
||||
meetingPoint: meetingPoint ?? this.meetingPoint,
|
||||
destination: destination ?? this.destination,
|
||||
stops: stops ?? this.stops,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_providers.dart';
|
||||
import 'group_ride_models.dart';
|
||||
|
|
@ -16,11 +17,97 @@ final groupRideStreamProvider =
|
|||
});
|
||||
|
||||
/// Stream of participants for a specific Group Ride.
|
||||
/// Uses bounded point-document listeners per UID discovered via parent GroupRide.accessUids
|
||||
/// (plus the signed-in user's own UID if available) to ensure zero collection list queries
|
||||
/// and complete peer block isolation.
|
||||
final groupRideParticipantsProvider =
|
||||
StreamProvider.family<List<GroupRideParticipant>, String>((ref, rideId) {
|
||||
StreamProvider.family.autoDispose<List<GroupRideParticipant>, String>((ref, rideId) {
|
||||
final service = ref.watch(groupRideServiceProvider);
|
||||
return service.participantsStream(rideId);
|
||||
final rideAsync = ref.watch(groupRideStreamProvider(rideId));
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
final controller = StreamController<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.
|
||||
final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
||||
|
|
@ -32,11 +119,171 @@ final upcomingGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
|||
return service.upcomingRidesStream(callerUid: user.uid);
|
||||
});
|
||||
|
||||
/// Stream of live presence records for all participants in a specific Group Ride.
|
||||
final groupRideLivePresenceProvider =
|
||||
StreamProvider.family<List<LiveGroupPresence>, String>((ref, rideId) {
|
||||
/// Stream of Group Rides organized by the current user.
|
||||
final myGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null || user.isAnonymous) {
|
||||
return Stream.value([]);
|
||||
}
|
||||
final service = ref.watch(groupRideServiceProvider);
|
||||
return service.livePresenceStream(rideId);
|
||||
return service.myRidesStream(callerUid: user.uid);
|
||||
});
|
||||
|
||||
/// Stream of live presence records for all accepted participants in a specific Group Ride.
|
||||
/// Uses bounded point-document listeners per candidate UID discovered via parent GroupRide.accessUids.
|
||||
/// Isolates peer permission-denied / sharing-off events to the specific peer and recovers cleanly.
|
||||
final groupRideLivePresenceProvider =
|
||||
StreamProvider.family.autoDispose<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.
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class GroupRideService {
|
|||
departureAt: departureAt,
|
||||
participantLimit: participantLimit,
|
||||
participantCount: 1,
|
||||
accessUids: [organizer.uid],
|
||||
meetingPoint: meetingPoint,
|
||||
destination: destination,
|
||||
stops: stops,
|
||||
|
|
@ -270,7 +271,14 @@ class GroupRideService {
|
|||
updatedAt: now,
|
||||
);
|
||||
|
||||
await participantRef.set(invite.toJson());
|
||||
final batch = firestore.batch();
|
||||
batch.set(participantRef, invite.toJson());
|
||||
batch.update(rideRef, {
|
||||
'accessUids': FieldValue.arrayUnion([friendProfile.uid]),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
/// Responds to a Group Ride invitation (Invitee only).
|
||||
|
|
@ -325,6 +333,11 @@ class GroupRideService {
|
|||
'status': ParticipantStatus.declined.name,
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
transaction.update(rideRef, {
|
||||
'accessUids': FieldValue.arrayRemove([callerUid]),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -439,6 +452,7 @@ class GroupRideService {
|
|||
});
|
||||
|
||||
transaction.update(rideRef, {
|
||||
'accessUids': FieldValue.arrayUnion([requesterUid]),
|
||||
'participantCount': FieldValue.increment(1),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
|
@ -504,6 +518,7 @@ class GroupRideService {
|
|||
|
||||
transaction.set(participantRef, participant.toJson());
|
||||
transaction.update(rideRef, {
|
||||
'accessUids': FieldValue.arrayUnion([caller.uid]),
|
||||
'participantCount': FieldValue.increment(1),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
|
@ -555,11 +570,15 @@ class GroupRideService {
|
|||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
if (wasAccepted && ride.participantCount > 0) {
|
||||
transaction.update(rideRef, {
|
||||
'participantCount': FieldValue.increment(-1),
|
||||
if (ride.organizerUid != callerUid) {
|
||||
final rideUpdates = <String, dynamic>{
|
||||
'accessUids': FieldValue.arrayRemove([callerUid]),
|
||||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
};
|
||||
if (wasAccepted && ride.participantCount > 0) {
|
||||
rideUpdates['participantCount'] = FieldValue.increment(-1);
|
||||
}
|
||||
transaction.update(rideRef, rideUpdates);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -600,12 +619,14 @@ class GroupRideService {
|
|||
'updatedAt': now.toIso8601String(),
|
||||
});
|
||||
|
||||
if (wasAccepted && ride.participantCount > 0) {
|
||||
transaction.update(rideRef, {
|
||||
'participantCount': FieldValue.increment(-1),
|
||||
final rideUpdates = <String, dynamic>{
|
||||
'accessUids': FieldValue.arrayRemove([targetUid]),
|
||||
'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.
|
||||
Stream<List<LiveGroupPresence>> livePresenceStream(String groupRideId) {
|
||||
/// Realtime stream of a single participant's membership document.
|
||||
Stream<GroupRideParticipant?> participantDocStream({
|
||||
required String groupRideId,
|
||||
required String participantUid,
|
||||
}) {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return Stream.value([]);
|
||||
if (firestore == null) return Stream.value(null);
|
||||
|
||||
return firestore
|
||||
.collection('group_rides')
|
||||
.doc(groupRideId)
|
||||
.collection('participants')
|
||||
.doc(participantUid)
|
||||
.snapshots()
|
||||
.map((snap) {
|
||||
if (!snap.exists || snap.data() == null) return null;
|
||||
try {
|
||||
return GroupRideParticipant.fromJson(snap.data()!);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Realtime single-document stream for a specific participant's live presence.
|
||||
Stream<LiveGroupPresence?> livePresenceDocStream({
|
||||
required String groupRideId,
|
||||
required String targetUid,
|
||||
}) {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return Stream.value(null);
|
||||
|
||||
return firestore
|
||||
.collection('group_rides')
|
||||
.doc(groupRideId)
|
||||
.collection('live_presence')
|
||||
.doc(targetUid)
|
||||
.snapshots()
|
||||
.map((snap) {
|
||||
return snap.docs
|
||||
.map((d) {
|
||||
if (!snap.exists || snap.data() == null) return null;
|
||||
try {
|
||||
return LiveGroupPresence.fromJson(d.data());
|
||||
final presence = LiveGroupPresence.fromJson(snap.data()!);
|
||||
return presence.sharingEnabled ? presence : null;
|
||||
} catch (_) {
|
||||
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}) {
|
||||
final firestore = _firestore;
|
||||
if (firestore == null) return Stream.value([]);
|
||||
|
||||
return firestore
|
||||
.collection('group_rides')
|
||||
.where('accessUids', arrayContains: callerUid)
|
||||
.where('status', whereIn: [
|
||||
GroupRideStatus.scheduled.name,
|
||||
GroupRideStatus.assembling.name,
|
||||
|
|
@ -851,7 +897,38 @@ class GroupRideService {
|
|||
.map((d) {
|
||||
try {
|
||||
return GroupRide.fromJson(d.data());
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
debugPrint('[GroupRideService] upcomingRidesStream parse error: $e');
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<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;
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -121,8 +121,16 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
|
|||
});
|
||||
|
||||
// 2. Listen to Participants (authorization check)
|
||||
final activeParticipantsStream =
|
||||
participantsStream ?? service.participantsStream(rideId);
|
||||
// Uses caller point-listener instead of broad collection query to adhere to allow list: if false security rule
|
||||
final activeParticipantsStream = participantsStream ??
|
||||
(callerUid.isNotEmpty
|
||||
? service
|
||||
.participantDocStream(
|
||||
groupRideId: rideId,
|
||||
participantUid: callerUid,
|
||||
)
|
||||
.map((p) => p != null ? [p] : <GroupRideParticipant>[])
|
||||
: const Stream<List<GroupRideParticipant>>.empty());
|
||||
_participantsSub =
|
||||
activeParticipantsStream.listen(_handleParticipantsUpdate, onError: (e) {
|
||||
debugPrint('[LiveBroadcastController] Participants stream error: $e');
|
||||
|
|
@ -130,7 +138,7 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
|
|||
|
||||
// 3. Listen to Live Presence (for regroup awareness)
|
||||
final activePresenceStream =
|
||||
presenceStream ?? service.livePresenceStream(rideId);
|
||||
presenceStream ?? const Stream<List<LiveGroupPresence>>.empty();
|
||||
_presenceSub = activePresenceStream.listen(_handlePresenceUpdate, onError: (e) {
|
||||
debugPrint('[LiveBroadcastController] Presence stream error: $e');
|
||||
});
|
||||
|
|
@ -486,6 +494,14 @@ final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose
|
|||
rideId: rideId,
|
||||
callerUid: callerUid,
|
||||
service: service,
|
||||
participantsStream: callerUid.isNotEmpty
|
||||
? service
|
||||
.participantDocStream(
|
||||
groupRideId: rideId,
|
||||
participantUid: callerUid,
|
||||
)
|
||||
.map((p) => p != null ? [p] : <GroupRideParticipant>[])
|
||||
: null,
|
||||
positionStream: locationService.getFilteredPositionStream(),
|
||||
initialMasterAllowed: masterAllowed,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -361,6 +361,91 @@ class FriendshipService {
|
|||
batch.set(blockRef, block.toJson());
|
||||
|
||||
await batch.commit();
|
||||
|
||||
// Clean up active shared group rides
|
||||
try {
|
||||
await _cleanupSharedGroupRidesOnBlock(
|
||||
firestore: firestore,
|
||||
myUid: myUid,
|
||||
targetUid: targetUid,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FriendshipService] Error cleaning up shared group rides on block: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<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.
|
||||
|
|
|
|||
|
|
@ -289,6 +289,12 @@ class _FakeGroupRideService implements GroupRideService {
|
|||
|
||||
Stream<List<GroupRide>> watchActiveRides() => 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([
|
||||
GroupRideParticipant(
|
||||
userId: 'user-1',
|
||||
|
|
@ -302,7 +308,16 @@ class _FakeGroupRideService implements GroupRideService {
|
|||
]);
|
||||
|
||||
@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
|
||||
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:motorcycle_dash/core/theme/app_colors.dart';
|
||||
import 'package:motorcycle_dash/core/theme/theme.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart';
|
||||
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||
|
||||
|
|
@ -130,11 +136,384 @@ void main() {
|
|||
|
||||
test('offline live presence and upcoming streams return safely', () async {
|
||||
final service = GroupRideService();
|
||||
final stream = service.livePresenceStream('ride_offline');
|
||||
expect(await stream.first, isEmpty);
|
||||
final stream = service.livePresenceDocStream(groupRideId: 'ride_offline', targetUid: 'user_alice');
|
||||
expect(await stream.first, isNull);
|
||||
|
||||
final pStream = service.participantDocStream(groupRideId: 'ride_offline', participantUid: 'user_alice');
|
||||
expect(await pStream.first, isNull);
|
||||
|
||||
final upcoming = service.upcomingRidesStream(callerUid: 'user_alice');
|
||||
expect(await upcoming.first, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('Live Presence Point Listener Lifecycle Tests', () {
|
||||
test('reconciles candidates from accessUids and only subscribes to accepted participants', () async {
|
||||
final fakeService = _FakeGroupRideLifecycleService();
|
||||
final rideStreamController = StreamController<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