feat(group-rides): add club-affiliated rides (#10)
Add optional immutable Club affiliation, authorized Club Owner/Admin ride planning, privacy-preserving point access, and narrowly scoped Club admin ride controls. Club-wide ride discovery remains deferred pending a privacy-preserving architecture.
This commit is contained in:
parent
e784c4895d
commit
68d4539f90
|
|
@ -4,7 +4,7 @@ import {
|
||||||
assertSucceeds,
|
assertSucceeds,
|
||||||
initializeTestEnvironment,
|
initializeTestEnvironment,
|
||||||
} from '@firebase/rules-unit-testing';
|
} from '@firebase/rules-unit-testing';
|
||||||
import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } from 'firebase/firestore';
|
import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
|
@ -4362,5 +4362,588 @@ describe('Social Architecture Security Rules', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
describe('Club GroupRide Security Rules & Attack Matrix', () => {
|
||||||
|
async function seedClubGroupRideEnv() {
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
||||||
|
const db = context.firestore();
|
||||||
|
// Users
|
||||||
|
await setDoc(doc(db, 'users/alice'), { id: 'alice' });
|
||||||
|
await setDoc(doc(db, 'users/bob'), { id: 'bob' });
|
||||||
|
await setDoc(doc(db, 'users/charlie'), { id: 'charlie' });
|
||||||
|
await setDoc(doc(db, 'users/dave'), { id: 'dave' });
|
||||||
|
await setDoc(doc(db, 'users/eve_invited'), { id: 'eve_invited' });
|
||||||
|
await setDoc(doc(db, 'users/eve_requested'), { id: 'eve_requested' });
|
||||||
|
await setDoc(doc(db, 'users/eve_declined'), { id: 'eve_declined' });
|
||||||
|
await setDoc(doc(db, 'users/eve_left'), { id: 'eve_left' });
|
||||||
|
await setDoc(doc(db, 'users/eve_removed'), { id: 'eve_removed' });
|
||||||
|
await setDoc(doc(db, 'users/frank'), { id: 'frank' });
|
||||||
|
|
||||||
|
// Club Alpha (Active)
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha'), {
|
||||||
|
id: 'club_alpha',
|
||||||
|
name: 'Club Alpha',
|
||||||
|
slug: 'club-alpha',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
memberCount: 3,
|
||||||
|
visibility: 'public',
|
||||||
|
admissionPolicy: 'open',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/alice'), {
|
||||||
|
uid: 'alice',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/bob'), {
|
||||||
|
uid: 'bob',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/charlie'), {
|
||||||
|
uid: 'charlie',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/eve_invited'), {
|
||||||
|
uid: 'eve_invited',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'member',
|
||||||
|
status: 'invited',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/eve_requested'), {
|
||||||
|
uid: 'eve_requested',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'member',
|
||||||
|
status: 'requested',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/eve_declined'), {
|
||||||
|
uid: 'eve_declined',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'member',
|
||||||
|
status: 'declined',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/eve_left'), {
|
||||||
|
uid: 'eve_left',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'member',
|
||||||
|
status: 'left',
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_alpha/members/eve_removed'), {
|
||||||
|
uid: 'eve_removed',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
role: 'member',
|
||||||
|
status: 'removed',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Club Beta (Active, Frank owner)
|
||||||
|
await setDoc(doc(db, 'clubs/club_beta'), {
|
||||||
|
id: 'club_beta',
|
||||||
|
name: 'Club Beta',
|
||||||
|
slug: 'club-beta',
|
||||||
|
ownerUid: 'frank',
|
||||||
|
memberCount: 1,
|
||||||
|
visibility: 'public',
|
||||||
|
admissionPolicy: 'open',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_beta/members/frank'), {
|
||||||
|
uid: 'frank',
|
||||||
|
clubId: 'club_beta',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Club Archived
|
||||||
|
await setDoc(doc(db, 'clubs/club_archived'), {
|
||||||
|
id: 'club_archived',
|
||||||
|
name: 'Club Archived',
|
||||||
|
slug: 'club-archived',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
memberCount: 1,
|
||||||
|
visibility: 'public',
|
||||||
|
admissionPolicy: 'open',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
archivedAt: new Date(),
|
||||||
|
});
|
||||||
|
await setDoc(doc(db, 'clubs/club_archived/members/alice'), {
|
||||||
|
uid: 'alice',
|
||||||
|
clubId: 'club_archived',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRideData = (id, organizerUid, clubId = undefined) => ({
|
||||||
|
id,
|
||||||
|
organizerUid,
|
||||||
|
title: 'Scenic Mountain Run',
|
||||||
|
status: 'scheduled',
|
||||||
|
scheduledStartAt: '2026-09-10T09:00:00Z',
|
||||||
|
departureAt: '2026-09-10T09:30:00Z',
|
||||||
|
participantCount: 1,
|
||||||
|
accessUids: [organizerUid],
|
||||||
|
visibility: 'friendsOnly',
|
||||||
|
joinPolicy: 'inviteOnly',
|
||||||
|
meetingPoint: {
|
||||||
|
latitude: 34.6,
|
||||||
|
longitude: -83.9,
|
||||||
|
displayName: 'Dahlonega Square',
|
||||||
|
locationType: 'meetup',
|
||||||
|
},
|
||||||
|
...(clubId !== undefined ? { clubId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. LEGACY GROUPRIDES
|
||||||
|
test('1. Legacy GroupRides: create and update without clubId or with explicit null', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
|
||||||
|
// 1a. Create legacy ride with missing clubId
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_legacy_missing'), baseRideData('ride_legacy_missing', 'alice')));
|
||||||
|
|
||||||
|
// 1b. Create legacy ride with explicit null clubId
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_legacy_null'), baseRideData('ride_legacy_null', 'alice', null)));
|
||||||
|
|
||||||
|
// 1c. Update legacy ride (missing clubId) without introducing clubId
|
||||||
|
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_missing'), {
|
||||||
|
title: 'Updated Legacy Title',
|
||||||
|
updatedAt: '2026-09-10T10:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1d. Update legacy ride (explicit null clubId) keeping clubId null
|
||||||
|
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_null'), {
|
||||||
|
title: 'Updated Legacy Null Title',
|
||||||
|
clubId: null,
|
||||||
|
updatedAt: '2026-09-10T10:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1e. Update legacy ride (missing clubId) with explicit null clubId succeeds (equivalent legacy state)
|
||||||
|
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_missing'), {
|
||||||
|
title: 'Updated Legacy Missing to Null Title',
|
||||||
|
clubId: null,
|
||||||
|
updatedAt: '2026-09-10T10:05:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1f. Update legacy ride (explicit null clubId) without mentioning clubId succeeds (equivalent legacy state)
|
||||||
|
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_legacy_null'), {
|
||||||
|
title: 'Updated Legacy Null to Missing Title',
|
||||||
|
updatedAt: '2026-09-10T10:05:00Z',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. CREATE AUTHORIZATION
|
||||||
|
test('2. Club affiliation create authorization: owner/admin allowed; members, non-members, archived clubs denied', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||||
|
const frank = testEnv.authenticatedContext('frank').firestore();
|
||||||
|
|
||||||
|
// 2a. Active Club Owner (Alice) can create affiliated ride
|
||||||
|
await assertSucceeds(setDoc(doc(alice, 'group_rides/ride_club_alice'), baseRideData('ride_club_alice', 'alice', 'club_alpha')));
|
||||||
|
|
||||||
|
// 2b. Active Club Admin (Bob) can create affiliated ride
|
||||||
|
await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_club_bob'), baseRideData('ride_club_bob', 'bob', 'club_alpha')));
|
||||||
|
|
||||||
|
// 2c. Active ordinary Member (Charlie) CANNOT create affiliated ride
|
||||||
|
await assertFails(setDoc(doc(charlie, 'group_rides/ride_club_charlie'), baseRideData('ride_club_charlie', 'charlie', 'club_alpha')));
|
||||||
|
|
||||||
|
// 2d. Inactive/non-active memberships CANNOT create affiliated ride
|
||||||
|
for (const status of ['invited', 'requested', 'declined', 'left', 'removed']) {
|
||||||
|
const eve = testEnv.authenticatedContext(`eve_${status}`).firestore();
|
||||||
|
await assertFails(setDoc(doc(eve, `group_rides/ride_club_eve_${status}`), baseRideData(`group_rides/ride_club_eve_${status}`, `eve_${status}`, 'club_alpha')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2e. Non-member (Dave) CANNOT create affiliated ride
|
||||||
|
await assertFails(setDoc(doc(dave, 'group_rides/ride_club_dave'), baseRideData('ride_club_dave', 'dave', 'club_alpha')));
|
||||||
|
|
||||||
|
// 2f. Admin of Club B (Frank) CANNOT create ride affiliated with Club A
|
||||||
|
await assertFails(setDoc(doc(frank, 'group_rides/ride_club_frank_wrong'), baseRideData('ride_club_frank_wrong', 'frank', 'club_alpha')));
|
||||||
|
|
||||||
|
// 2g. Cannot create ride for non-existent club
|
||||||
|
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_missing'), baseRideData('ride_club_missing', 'alice', 'club_nonexistent')));
|
||||||
|
|
||||||
|
// 2h. Cannot create ride for archived club
|
||||||
|
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_archived'), baseRideData('ride_club_archived', 'alice', 'club_archived')));
|
||||||
|
|
||||||
|
// 2i. Blank, whitespace, or malformed clubId is denied
|
||||||
|
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_empty'), baseRideData('ride_club_empty', 'alice', '')));
|
||||||
|
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_whitespace'), baseRideData('ride_club_whitespace', 'alice', ' ')));
|
||||||
|
await assertFails(setDoc(doc(alice, 'group_rides/ride_club_spaces'), baseRideData('ride_club_spaces', 'alice', 'club alpha with space')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. AFFILIATION IMMUTABILITY
|
||||||
|
test('3. Club affiliation immutability: cannot add, clear, or change clubId on existing rides', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_immut_personal'), baseRideData('ride_immut_personal', 'alice'));
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_immut_club'), baseRideData('ride_immut_club', 'alice', 'club_alpha'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3a. Personal ride cannot later gain clubId
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_personal'), {
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3b. Affiliated ride cannot clear clubId (set to null)
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
|
||||||
|
clubId: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3c. Affiliated ride cannot change Club A -> Club B
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
|
||||||
|
clubId: 'club_beta',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3d. Unrelated update cannot smuggle clubId change
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
|
||||||
|
title: 'Attempted Smuggle',
|
||||||
|
clubId: 'club_beta',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3e. Legitimate update preserving same clubId succeeds
|
||||||
|
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_immut_club'), {
|
||||||
|
title: 'Legitimate Title Update',
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. ADMIN MANAGEMENT AUTHORITY & LIFECYCLE
|
||||||
|
test('4. Club Admin management authority: allowed planned metadata when scheduled; dedicated cancellation; underway/terminal restrictions', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
const frank = testEnv.authenticatedContext('frank').firestore();
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
// Scheduled club ride organized by Alice
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_admin_scheduled'), baseRideData('ride_admin_scheduled', 'alice', 'club_alpha'));
|
||||||
|
// Assembling club ride
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_admin_assembling'), {
|
||||||
|
...baseRideData('ride_admin_assembling', 'alice', 'club_alpha'),
|
||||||
|
status: 'assembling',
|
||||||
|
});
|
||||||
|
// Underway club ride
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_admin_underway'), {
|
||||||
|
...baseRideData('ride_admin_underway', 'alice', 'club_alpha'),
|
||||||
|
status: 'underway',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4a. Active Club Admin Bob can edit allowed scheduled metadata
|
||||||
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
title: 'Updated By Club Admin',
|
||||||
|
description: 'New route description',
|
||||||
|
participantLimit: 20,
|
||||||
|
meetingPoint: {
|
||||||
|
latitude: 34.7,
|
||||||
|
longitude: -83.8,
|
||||||
|
displayName: 'New Meetup Point',
|
||||||
|
locationType: 'meetup',
|
||||||
|
},
|
||||||
|
updatedAt: '2026-09-10T10:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4b. Ordinary Member Charlie CANNOT edit scheduled metadata
|
||||||
|
await assertFails(updateDoc(doc(charlie, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
title: 'Hacked by Member',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4c. Admin from another Club (Frank) CANNOT edit scheduled metadata
|
||||||
|
await assertFails(updateDoc(doc(frank, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
title: 'Hacked by Foreign Admin',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4d. Bob CANNOT mutate accessUids
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
accessUids: ['alice', 'bob'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4e. Bob CANNOT mutate participantCount
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
participantCount: 5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4f. Bob CANNOT change organizerUid
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
organizerUid: 'bob',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4g. Bob CANNOT perform arbitrary status transition to 'underway'
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
status: 'underway',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4h. Assembling ride rejects arbitrary admin metadata edit
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_assembling'), {
|
||||||
|
title: 'Attempted Assembling Edit',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4i. Scheduled ride: dedicated admin cancellation succeeds
|
||||||
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_scheduled'), {
|
||||||
|
status: 'canceled',
|
||||||
|
canceledAt: '2026-09-10T11:00:00Z',
|
||||||
|
canceledReason: 'Rain predicted',
|
||||||
|
updatedAt: '2026-09-10T11:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4j. Assembling ride: dedicated admin cancellation succeeds
|
||||||
|
await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_admin_assembling'), {
|
||||||
|
status: 'canceled',
|
||||||
|
canceledAt: '2026-09-10T11:00:00Z',
|
||||||
|
canceledReason: 'Club event canceled',
|
||||||
|
updatedAt: '2026-09-10T11:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4k. Underway ride: non-organizer Club admin cancellation FAILS
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_underway'), {
|
||||||
|
status: 'canceled',
|
||||||
|
canceledAt: '2026-09-10T11:00:00Z',
|
||||||
|
canceledReason: 'Admin stopping ride',
|
||||||
|
updatedAt: '2026-09-10T11:00:00Z',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4l. Underway ride: Organizer Alice CAN cancel
|
||||||
|
await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_admin_underway'), {
|
||||||
|
status: 'canceled',
|
||||||
|
canceledAt: '2026-09-10T11:00:00Z',
|
||||||
|
canceledReason: 'Mechanical issue',
|
||||||
|
updatedAt: '2026-09-10T11:00:00Z',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. TERMINAL-RIDE SECURITY HARDENING
|
||||||
|
test('5. Terminal-ride security hardening: completed and canceled parent rides cannot be mutated', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
// Completed personal ride
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_term_personal_comp'), {
|
||||||
|
...baseRideData('ride_term_personal_comp', 'alice'),
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
// Canceled personal ride
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_term_personal_canc'), {
|
||||||
|
...baseRideData('ride_term_personal_canc', 'alice'),
|
||||||
|
status: 'canceled',
|
||||||
|
});
|
||||||
|
// Completed club ride
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_term_club_comp'), {
|
||||||
|
...baseRideData('ride_term_club_comp', 'alice', 'club_alpha'),
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
// Canceled club ride
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_term_club_canc'), {
|
||||||
|
...baseRideData('ride_term_club_canc', 'alice', 'club_alpha'),
|
||||||
|
status: 'canceled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5a. Completed personal ride cannot be mutated by organizer (even with same status)
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_comp'), {
|
||||||
|
title: 'New Title Post Completion',
|
||||||
|
}));
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_comp'), {
|
||||||
|
status: 'completed',
|
||||||
|
title: 'Same-status mutate attempt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5b. Canceled personal ride cannot be mutated by organizer
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_canc'), {
|
||||||
|
title: 'New Title Post Cancellation',
|
||||||
|
}));
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_personal_canc'), {
|
||||||
|
status: 'canceled',
|
||||||
|
title: 'Same-status mutate attempt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5c. Completed club ride cannot be mutated by organizer or club admin
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_club_comp'), {
|
||||||
|
title: 'Organizer Mutate Attempt',
|
||||||
|
}));
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_term_club_comp'), {
|
||||||
|
title: 'Admin Mutate Attempt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5d. Canceled club ride cannot be mutated by organizer or club admin
|
||||||
|
await assertFails(updateDoc(doc(alice, 'group_rides/ride_term_club_canc'), {
|
||||||
|
title: 'Organizer Mutate Attempt',
|
||||||
|
}));
|
||||||
|
await assertFails(updateDoc(doc(bob, 'group_rides/ride_term_club_canc'), {
|
||||||
|
title: 'Admin Mutate Attempt',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. LIVE-LOCATION PRIVACY (CLUBMEMBER != GROUPRIDEPARTICIPANT)
|
||||||
|
test('6. Live-location privacy: Club membership or admin role NEVER grants live_presence access', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
|
||||||
|
const validAlicePresence = {
|
||||||
|
userId: 'alice',
|
||||||
|
groupRideId: 'ride_privacy_live',
|
||||||
|
latitude: 34.5378,
|
||||||
|
longitude: -83.9849,
|
||||||
|
heading: 90.0,
|
||||||
|
speedMph: 45.0,
|
||||||
|
accuracyMeters: 5.0,
|
||||||
|
updatedAt: '2026-09-10T09:35:00Z',
|
||||||
|
expiresAt: '2026-09-10T09:37:00Z',
|
||||||
|
sharingEnabled: true,
|
||||||
|
status: 'riding',
|
||||||
|
};
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
// Club-affiliated ride that is underway
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_privacy_live'), {
|
||||||
|
...baseRideData('ride_privacy_live', 'alice', 'club_alpha'),
|
||||||
|
status: 'underway',
|
||||||
|
visibility: 'public',
|
||||||
|
});
|
||||||
|
// Alice is accepted participant
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_privacy_live/participants/alice'), {
|
||||||
|
userId: 'alice',
|
||||||
|
role: 'organizer',
|
||||||
|
status: 'accepted',
|
||||||
|
riderHandle: 'alice_rider',
|
||||||
|
riderDisplayName: 'Alice',
|
||||||
|
});
|
||||||
|
// Alice's live presence
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_privacy_live/live_presence/alice'), validAlicePresence);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6a. Alice can read her own live presence
|
||||||
|
await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_privacy_live/live_presence/alice')));
|
||||||
|
|
||||||
|
// 6b. Bob is Club Admin, but NOT an accepted participant -> CANNOT read Alice's live presence
|
||||||
|
await assertFails(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice')));
|
||||||
|
|
||||||
|
// 6c. Charlie is Club Member, but NOT an accepted participant -> CANNOT read Alice's live presence
|
||||||
|
await assertFails(getDoc(doc(charlie, 'group_rides/ride_privacy_live/live_presence/alice')));
|
||||||
|
|
||||||
|
// 6d. Bob cannot write live presence if not an accepted participant
|
||||||
|
await assertFails(setDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/bob'), {
|
||||||
|
...validAlicePresence,
|
||||||
|
userId: 'bob',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6e. When Bob is added as accepted participant, Bob CAN read Alice's live presence
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_privacy_live/participants/bob'), {
|
||||||
|
userId: 'bob',
|
||||||
|
role: 'participant',
|
||||||
|
status: 'accepted',
|
||||||
|
riderHandle: 'bob_rider',
|
||||||
|
riderDisplayName: 'Bob',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice')));
|
||||||
|
|
||||||
|
// 6f. If Alice blocks Bob, Bob CANNOT read Alice's live presence even though both are accepted participants and in the same Club
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' });
|
||||||
|
});
|
||||||
|
await assertFails(getDoc(doc(bob, 'group_rides/ride_privacy_live/live_presence/alice')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. READ & LIST PRIVACY
|
||||||
|
test('7. Point-read authorization and list privacy: active club members can point-read; blocks deny point-read; broad club list query is denied', async () => {
|
||||||
|
await seedClubGroupRideEnv();
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||||
|
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
// Private club ride (not public, not friendsOnly)
|
||||||
|
await setDoc(doc(db, 'group_rides/ride_discovery_private'), {
|
||||||
|
...baseRideData('ride_discovery_private', 'alice', 'club_alpha'),
|
||||||
|
visibility: 'private',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7a. Active Club Member Charlie can point-read private club ride
|
||||||
|
await assertSucceeds(getDoc(doc(charlie, 'group_rides/ride_discovery_private')));
|
||||||
|
|
||||||
|
// 7b. Active Club Admin Bob can point-read private club ride
|
||||||
|
await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_discovery_private')));
|
||||||
|
|
||||||
|
// 7c. Non-member Dave CANNOT read private club ride
|
||||||
|
await assertFails(getDoc(doc(dave, 'group_rides/ride_discovery_private')));
|
||||||
|
|
||||||
|
// 7d-1. Block privacy: If Alice blocks Charlie, Charlie CANNOT read the ride
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await setDoc(doc(db, 'users/alice/blocks/charlie'), { blockedUid: 'charlie' });
|
||||||
|
});
|
||||||
|
await assertFails(getDoc(doc(charlie, 'group_rides/ride_discovery_private')));
|
||||||
|
|
||||||
|
// Clean up Alice's block
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await deleteDoc(doc(db, 'users/alice/blocks/charlie'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7d-2. Block privacy: If Charlie blocks Alice (reverse), Charlie CANNOT read the ride
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await setDoc(doc(db, 'users/charlie/blocks/alice'), { blockedUid: 'alice' });
|
||||||
|
});
|
||||||
|
await assertFails(getDoc(doc(charlie, 'group_rides/ride_discovery_private')));
|
||||||
|
|
||||||
|
// Clean up Charlie's block
|
||||||
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
||||||
|
const db = ctx.firestore();
|
||||||
|
await deleteDoc(doc(db, 'users/charlie/blocks/alice'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7e. Unsafe broad Club-member list query is NOT authorized (rule branch removed)
|
||||||
|
const charlieClubQuery = query(
|
||||||
|
collection(charlie, 'group_rides'),
|
||||||
|
where('clubId', '==', 'club_alpha'),
|
||||||
|
);
|
||||||
|
await assertFails(getDocs(charlieClubQuery));
|
||||||
|
|
||||||
|
// 7f. Non-member Dave CANNOT list club rides
|
||||||
|
const daveClubQuery = query(
|
||||||
|
collection(dave, 'group_rides'),
|
||||||
|
where('clubId', '==', 'club_alpha'),
|
||||||
|
);
|
||||||
|
await assertFails(getDocs(daveClubQuery));
|
||||||
|
|
||||||
|
// 7g. Ordered broad query is also NOT authorized
|
||||||
|
const charlieOrderedQuery = query(
|
||||||
|
collection(charlie, 'group_rides'),
|
||||||
|
where('clubId', '==', 'club_alpha'),
|
||||||
|
orderBy('scheduledStartAt', 'desc'),
|
||||||
|
limit(30),
|
||||||
|
);
|
||||||
|
await assertFails(getDocs(charlieOrderedQuery));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
114
firestore.rules
114
firestore.rules
|
|
@ -198,7 +198,7 @@ service cloud.firestore {
|
||||||
// Top-level social object distinct from personal rides, accounts, and telemetry.
|
// Top-level social object distinct from personal rides, accounts, and telemetry.
|
||||||
match /group_rides/{groupRideId} {
|
match /group_rides/{groupRideId} {
|
||||||
function isOrganizer() {
|
function isOrganizer() {
|
||||||
return signedIn() && resource.data.organizerUid == request.auth.uid;
|
return signedIn() && 'organizerUid' in resource.data && resource.data.organizerUid == request.auth.uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasParticipantAccess(uid) {
|
function hasParticipantAccess(uid) {
|
||||||
|
|
@ -210,6 +210,55 @@ service cloud.firestore {
|
||||||
return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
|
return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasClubAffiliation(data) {
|
||||||
|
return 'clubId' in data && data.clubId != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validClubId(clubId) {
|
||||||
|
return clubId is string && clubId.size() >= 1 && clubId.size() <= 64 && clubId.matches('^\\S+$');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clubExists(clubId) {
|
||||||
|
return exists(/databases/$(database)/documents/clubs/$(clubId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clubIsActive(clubId) {
|
||||||
|
return clubExists(clubId) &&
|
||||||
|
(!('archivedAt' in get(/databases/$(database)/documents/clubs/$(clubId)).data) ||
|
||||||
|
get(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClubMemberDoc(clubId, uid) {
|
||||||
|
return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClubMemberData(clubId, uid) {
|
||||||
|
return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveClubMember(clubId, uid) {
|
||||||
|
return isRegisteredUser() &&
|
||||||
|
hasClubMemberDoc(clubId, uid) &&
|
||||||
|
getClubMemberData(clubId, uid).status == 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveClubAdmin(clubId, uid) {
|
||||||
|
return isActiveClubMember(clubId, uid) &&
|
||||||
|
getClubMemberData(clubId, uid).role in ['owner', 'admin'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameClubAffiliation(prevData, nextData) {
|
||||||
|
let prevHas = hasClubAffiliation(prevData);
|
||||||
|
let nextHas = hasClubAffiliation(nextData);
|
||||||
|
return (!prevHas && !nextHas) ||
|
||||||
|
(prevHas && nextHas && prevData.clubId == nextData.clubId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClubRideMember() {
|
||||||
|
return hasClubAffiliation(resource.data) &&
|
||||||
|
isActiveClubMember(resource.data.clubId, request.auth.uid);
|
||||||
|
}
|
||||||
|
|
||||||
function canReadRide() {
|
function canReadRide() {
|
||||||
return signedIn() && (
|
return signedIn() && (
|
||||||
isOrganizer() ||
|
isOrganizer() ||
|
||||||
|
|
@ -217,7 +266,8 @@ service cloud.firestore {
|
||||||
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
||||||
hasParticipantAccess(request.auth.uid) ||
|
hasParticipantAccess(request.auth.uid) ||
|
||||||
resource.data.visibility == 'public' ||
|
resource.data.visibility == 'public' ||
|
||||||
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
|
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) ||
|
||||||
|
isClubRideMember()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -226,7 +276,10 @@ service cloud.firestore {
|
||||||
allow get: if canReadRide();
|
allow get: if canReadRide();
|
||||||
|
|
||||||
allow list: if signedIn() && (
|
allow list: if signedIn() && (
|
||||||
resource.data.organizerUid == request.auth.uid ||
|
(
|
||||||
|
'organizerUid' in resource.data &&
|
||||||
|
resource.data.organizerUid == request.auth.uid
|
||||||
|
) ||
|
||||||
(
|
(
|
||||||
'accessUids' in resource.data &&
|
'accessUids' in resource.data &&
|
||||||
request.auth.uid in resource.data.accessUids
|
request.auth.uid in resource.data.accessUids
|
||||||
|
|
@ -246,15 +299,25 @@ service cloud.firestore {
|
||||||
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
|
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
|
||||||
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
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
&& (
|
||||||
|
!hasClubAffiliation(request.resource.data) ||
|
||||||
|
(
|
||||||
|
validClubId(request.resource.data.clubId) &&
|
||||||
|
clubIsActive(request.resource.data.clubId) &&
|
||||||
|
isActiveClubAdmin(request.resource.data.clubId, request.auth.uid)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function validStatusTransition() {
|
function validStatusTransition() {
|
||||||
let prev = resource.data.status;
|
let prev = 'status' in resource.data ? resource.data.status : 'scheduled';
|
||||||
let next = request.resource.data.status;
|
let next = 'status' in request.resource.data ? request.resource.data.status : prev;
|
||||||
return prev == next ||
|
return prev in ['scheduled', 'assembling', 'underway'] && (
|
||||||
|
prev == next ||
|
||||||
(prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) ||
|
(prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) ||
|
||||||
(prev == 'assembling' && next in ['underway', 'canceled']) ||
|
(prev == 'assembling' && next in ['underway', 'canceled']) ||
|
||||||
(prev == 'underway' && next in ['completed', 'canceled']);
|
(prev == 'underway' && next in ['completed', 'canceled'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validRouteAttachment() {
|
function validRouteAttachment() {
|
||||||
|
|
@ -330,9 +393,42 @@ service cloud.firestore {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allow update: if signedIn() && (
|
function validClubAdminRideUpdate() {
|
||||||
|
let clubId = resource.data.clubId;
|
||||||
|
let prevStatus = 'status' in resource.data ? resource.data.status : 'scheduled';
|
||||||
|
let nextStatus = 'status' in request.resource.data ? request.resource.data.status : prevStatus;
|
||||||
|
return isActiveClubAdmin(clubId, request.auth.uid) && (
|
||||||
|
// Sub-case A: Scheduled metadata edit
|
||||||
|
(
|
||||||
|
prevStatus == 'scheduled' &&
|
||||||
|
nextStatus == 'scheduled' &&
|
||||||
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||||
|
'title', 'description', 'visibility', 'joinPolicy',
|
||||||
|
'scheduledStartAt', 'departureAt', 'participantLimit',
|
||||||
|
'meetingPoint', 'destination', 'stops',
|
||||||
|
'routeId', 'routeTitle', 'routeDistanceMeters', 'routeStopCount',
|
||||||
|
'updatedAt'
|
||||||
|
]) &&
|
||||||
|
validRouteAttachment()
|
||||||
|
) ||
|
||||||
|
// Sub-case B: Dedicated narrow admin cancellation (scheduled or assembling only)
|
||||||
|
(
|
||||||
|
prevStatus in ['scheduled', 'assembling'] &&
|
||||||
|
nextStatus == 'canceled' &&
|
||||||
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
||||||
|
'status', 'canceledAt', 'canceledReason', 'updatedAt'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
allow update: if signedIn() &&
|
||||||
|
(!('status' in resource.data) || !(resource.data.status in ['completed', 'canceled'])) &&
|
||||||
|
sameClubAffiliation(resource.data, request.resource.data) &&
|
||||||
|
(
|
||||||
(isOrganizer() && validOrganizerRideUpdate()) ||
|
(isOrganizer() && validOrganizerRideUpdate()) ||
|
||||||
validParticipantRideUpdate()
|
validParticipantRideUpdate() ||
|
||||||
|
(hasClubAffiliation(resource.data) && validClubAdminRideUpdate())
|
||||||
);
|
);
|
||||||
|
|
||||||
allow delete: if isOrganizer();
|
allow delete: if isOrganizer();
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,10 @@ final appRouter = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'create',
|
path: 'create',
|
||||||
builder: (context, state) => const CreateGroupRideScreen(),
|
builder: (context, state) {
|
||||||
|
final clubId = state.uri.queryParameters['clubId'];
|
||||||
|
return CreateGroupRideScreen(clubId: clubId);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':id',
|
path: ':id',
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,15 @@ import '../../services/routes/route_models.dart';
|
||||||
import '../../services/routes/route_provider.dart';
|
import '../../services/routes/route_provider.dart';
|
||||||
import '../../services/routes/route_providers.dart';
|
import '../../services/routes/route_providers.dart';
|
||||||
import '../../services/routes/route_service.dart';
|
import '../../services/routes/route_service.dart';
|
||||||
|
import '../../services/clubs/club_models.dart';
|
||||||
|
import '../../services/clubs/club_providers.dart';
|
||||||
import '../routes/widgets/address_autocomplete_field.dart';
|
import '../routes/widgets/address_autocomplete_field.dart';
|
||||||
|
import '../social/clubs/widgets/club_monogram.dart';
|
||||||
|
|
||||||
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
class CreateGroupRideScreen extends ConsumerStatefulWidget {
|
||||||
const CreateGroupRideScreen({super.key});
|
const CreateGroupRideScreen({super.key, this.clubId});
|
||||||
|
|
||||||
|
final String? clubId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<CreateGroupRideScreen> createState() =>
|
ConsumerState<CreateGroupRideScreen> createState() =>
|
||||||
|
|
@ -223,6 +228,15 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (widget.clubId != null) {
|
||||||
|
final club = ref.read(clubDetailProvider(widget.clubId!)).valueOrNull;
|
||||||
|
final member = ref.read(currentMemberProvider(widget.clubId!)).valueOrNull;
|
||||||
|
if (club == null || club.isArchived || member == null || !member.status.isActive || !member.role.isAtLeast(ClubRole.admin)) {
|
||||||
|
setState(() => _errorMessage = 'Cannot create club ride: leadership permission required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
final meetingPoint = GroupRideLocation(
|
final meetingPoint = GroupRideLocation(
|
||||||
|
|
@ -265,6 +279,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
routeTitle: _selectedRouteTitle,
|
routeTitle: _selectedRouteTitle,
|
||||||
routeDistanceMeters: _selectedRouteDistanceMeters,
|
routeDistanceMeters: _selectedRouteDistanceMeters,
|
||||||
routeStopCount: _selectedRouteStopCount,
|
routeStopCount: _selectedRouteStopCount,
|
||||||
|
clubId: widget.clubId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -274,7 +289,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e
|
||||||
|
.toString()
|
||||||
|
.replaceAll('Exception: ', '')
|
||||||
|
.replaceAll('StateError: ', '')
|
||||||
|
.replaceAll('ArgumentError: ', '');
|
||||||
_isSaving = false;
|
_isSaving = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -633,14 +652,52 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
ref.watch(myRiderProfileProvider);
|
ref.watch(myRiderProfileProvider);
|
||||||
|
|
||||||
|
final clubAsync = widget.clubId != null
|
||||||
|
? ref.watch(clubDetailProvider(widget.clubId!))
|
||||||
|
: null;
|
||||||
|
final memberAsync = widget.clubId != null
|
||||||
|
? ref.watch(currentMemberProvider(widget.clubId!))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
bool canSubmit = !_isSaving;
|
||||||
|
String? clubValidationError;
|
||||||
|
|
||||||
|
if (widget.clubId != null) {
|
||||||
|
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
|
||||||
|
canSubmit = false;
|
||||||
|
} else if (clubAsync.hasError) {
|
||||||
|
canSubmit = false;
|
||||||
|
clubValidationError = 'Failed to load club: ${clubAsync.error}';
|
||||||
|
} else if (memberAsync.hasError) {
|
||||||
|
canSubmit = false;
|
||||||
|
clubValidationError = 'Failed to verify club membership: ${memberAsync.error}';
|
||||||
|
} else {
|
||||||
|
final club = clubAsync.value;
|
||||||
|
final member = memberAsync.value;
|
||||||
|
if (club == null) {
|
||||||
|
canSubmit = false;
|
||||||
|
clubValidationError = 'Club not found or does not exist.';
|
||||||
|
} else if (club.isArchived) {
|
||||||
|
canSubmit = false;
|
||||||
|
clubValidationError = 'This club has been archived and cannot organize rides.';
|
||||||
|
} else if (member == null || !member.status.isActive) {
|
||||||
|
canSubmit = false;
|
||||||
|
clubValidationError = 'You are not an active member of this club.';
|
||||||
|
} else if (!member.role.isAtLeast(ClubRole.admin)) {
|
||||||
|
canSubmit = false;
|
||||||
|
clubValidationError = 'Only club owners and administrators can create club rides.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.surfaceContainer,
|
backgroundColor: colors.surfaceContainer,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: const Text(
|
title: Text(
|
||||||
'CREATE GROUP RIDE',
|
widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|
@ -648,7 +705,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _isSaving ? null : _submit,
|
onPressed: (_isSaving || !canSubmit) ? null : _submit,
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
|
|
@ -661,7 +718,12 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
: Text(
|
: Text(
|
||||||
'PUBLISH',
|
'PUBLISH',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.cyberCyan,
|
color: canSubmit
|
||||||
|
? colors.cyberCyan
|
||||||
|
: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -676,9 +738,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
(snapshot.isLandscape && snapshot.width >= 720);
|
(snapshot.isLandscape && snapshot.width >= 720);
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
return _buildWideForm(context, colors, snapshot);
|
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
||||||
}
|
}
|
||||||
return _buildCompactForm(context, colors, snapshot);
|
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -689,12 +751,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
ResponsiveSnapshot snapshot,
|
ResponsiveSnapshot snapshot,
|
||||||
|
bool canSubmit,
|
||||||
|
AsyncValue<Club?>? clubAsync,
|
||||||
|
AsyncValue<ClubMember?>? memberAsync,
|
||||||
|
String? clubValidationError,
|
||||||
) {
|
) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.clubId != null)
|
||||||
|
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -726,7 +794,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildMapPreviewSection(colors, snapshot),
|
_buildMapPreviewSection(colors, snapshot),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildSubmitButton(colors),
|
_buildSubmitButton(colors, canSubmit),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -742,10 +810,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
ResponsiveSnapshot snapshot,
|
ResponsiveSnapshot snapshot,
|
||||||
|
bool canSubmit,
|
||||||
|
AsyncValue<Club?>? clubAsync,
|
||||||
|
AsyncValue<ClubMember?>? memberAsync,
|
||||||
|
String? clubValidationError,
|
||||||
) {
|
) {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.clubId != null)
|
||||||
|
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
_buildRideDetailsSection(colors),
|
_buildRideDetailsSection(colors),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
@ -759,12 +833,180 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildPrivacySection(colors),
|
_buildPrivacySection(colors),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_buildSubmitButton(colors),
|
_buildSubmitButton(colors, canSubmit),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildClubAffiliationHeader(
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
AsyncValue<Club?>? clubAsync,
|
||||||
|
AsyncValue<ClubMember?>? memberAsync,
|
||||||
|
String? validationError,
|
||||||
|
) {
|
||||||
|
if (widget.clubId == null) return const SizedBox.shrink();
|
||||||
|
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Verifying club leadership permissions...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationError != null) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.raceRed.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.raceRed, width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colors.raceRed, size: 22),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Club Affiliation Error',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colors.raceRed,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
validationError,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colors.raceRed,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final club = clubAsync.value;
|
||||||
|
final member = memberAsync.value;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.cyberCyan.withValues(alpha: 0.5), width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ClubMonogram(name: club?.name ?? '', size: 40, fontSize: 16),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.cyberCyan.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: colors.cyberCyan, width: 0.6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'CLUB RIDE',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (member != null)
|
||||||
|
Text(
|
||||||
|
member.role.label.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Planning for ${club?.name ?? 'Club'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: onSurfaceVariant,
|
||||||
|
fontSize: 11.5,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
|
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
|
@ -1488,24 +1730,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSubmitButton(MotorSignalThemeColors colors) {
|
Widget _buildSubmitButton(MotorSignalThemeColors colors, bool canSubmit) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 48,
|
height: 48,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colors.cyberCyan,
|
backgroundColor: canSubmit ? colors.cyberCyan : colors.surfaceContainerHighest,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: canSubmit ? Colors.black : Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: _isSaving ? null : _submit,
|
onPressed: (_isSaving || !canSubmit) ? null : _submit,
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
? const CircularProgressIndicator(color: Colors.black)
|
? const CircularProgressIndicator(color: Colors.black)
|
||||||
: const Text(
|
: Text(
|
||||||
'PUBLISH GROUP RIDE',
|
widget.clubId != null ? 'PUBLISH CLUB RIDE' : 'PUBLISH GROUP RIDE',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import '../../services/group_rides/group_ride_models.dart';
|
||||||
import '../../services/group_rides/group_ride_providers.dart';
|
import '../../services/group_rides/group_ride_providers.dart';
|
||||||
import '../../services/social/social_providers.dart';
|
import '../../services/social/social_providers.dart';
|
||||||
import '../../services/routes/route_providers.dart';
|
import '../../services/routes/route_providers.dart';
|
||||||
|
import '../../services/clubs/club_models.dart';
|
||||||
|
import '../../services/clubs/club_providers.dart';
|
||||||
|
|
||||||
class GroupRideDetailScreen extends ConsumerStatefulWidget {
|
class GroupRideDetailScreen extends ConsumerStatefulWidget {
|
||||||
final String rideId;
|
final String rideId;
|
||||||
|
|
@ -201,6 +203,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
|
|
||||||
final isOrganizer =
|
final isOrganizer =
|
||||||
currentUid != null && ride.organizerUid == currentUid;
|
currentUid != null && ride.organizerUid == currentUid;
|
||||||
|
final clubMember = ride.clubId != null
|
||||||
|
? ref.watch(currentMemberProvider(ride.clubId!)).asData?.value
|
||||||
|
: null;
|
||||||
|
final isClubAdmin = clubMember != null &&
|
||||||
|
clubMember.status.isActive &&
|
||||||
|
clubMember.role.isAtLeast(ClubRole.admin);
|
||||||
|
|
||||||
return participantsAsync.when(
|
return participantsAsync.when(
|
||||||
data: (participants) {
|
data: (participants) {
|
||||||
|
|
@ -224,6 +232,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
participants,
|
participants,
|
||||||
myParticipant,
|
myParticipant,
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
|
isClubAdmin,
|
||||||
currentUid,
|
currentUid,
|
||||||
colors,
|
colors,
|
||||||
);
|
);
|
||||||
|
|
@ -235,6 +244,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
participants,
|
participants,
|
||||||
myParticipant,
|
myParticipant,
|
||||||
isOrganizer,
|
isOrganizer,
|
||||||
|
isClubAdmin,
|
||||||
currentUid,
|
currentUid,
|
||||||
colors,
|
colors,
|
||||||
);
|
);
|
||||||
|
|
@ -257,7 +267,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
),
|
),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error loading ride: $e',
|
'Error loading group ride: $e',
|
||||||
style: TextStyle(color: colors.raceRed),
|
style: TextStyle(color: colors.raceRed),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -271,6 +281,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
List<GroupRideParticipant> participants,
|
List<GroupRideParticipant> participants,
|
||||||
GroupRideParticipant? myParticipant,
|
GroupRideParticipant? myParticipant,
|
||||||
bool isOrganizer,
|
bool isOrganizer,
|
||||||
|
bool isClubAdmin,
|
||||||
String? currentUid,
|
String? currentUid,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
|
|
@ -289,7 +300,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
_buildStatusMessageBanner(colors),
|
_buildStatusMessageBanner(colors),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
_buildHeaderCard(context, ride, isOrganizer, colors),
|
_buildHeaderCard(context, ride, isOrganizer, isClubAdmin, colors),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildActionBanner(
|
_buildActionBanner(
|
||||||
context,
|
context,
|
||||||
|
|
@ -330,6 +341,13 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
||||||
_buildOrganizerControlCard(context, ride, colors),
|
_buildOrganizerControlCard(context, ride, colors),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
] else if (isClubAdmin &&
|
||||||
|
!ride.isCanceled &&
|
||||||
|
!ride.isCompleted &&
|
||||||
|
(ride.status == GroupRideStatus.scheduled ||
|
||||||
|
ride.status == GroupRideStatus.assembling)) ...[
|
||||||
|
_buildClubAdminControlCard(context, ride, colors),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
_buildParticipantsCard(
|
_buildParticipantsCard(
|
||||||
ride,
|
ride,
|
||||||
|
|
@ -356,6 +374,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
List<GroupRideParticipant> participants,
|
List<GroupRideParticipant> participants,
|
||||||
GroupRideParticipant? myParticipant,
|
GroupRideParticipant? myParticipant,
|
||||||
bool isOrganizer,
|
bool isOrganizer,
|
||||||
|
bool isClubAdmin,
|
||||||
String? currentUid,
|
String? currentUid,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
|
|
@ -366,11 +385,18 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
_buildStatusMessageBanner(colors),
|
_buildStatusMessageBanner(colors),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
_buildHeaderCard(context, ride, isOrganizer, colors),
|
_buildHeaderCard(context, ride, isOrganizer, isClubAdmin, colors),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
if (isOrganizer && !ride.isCanceled && !ride.isCompleted) ...[
|
||||||
_buildOrganizerControlCard(context, ride, colors),
|
_buildOrganizerControlCard(context, ride, colors),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
] else if (isClubAdmin &&
|
||||||
|
!ride.isCanceled &&
|
||||||
|
!ride.isCompleted &&
|
||||||
|
(ride.status == GroupRideStatus.scheduled ||
|
||||||
|
ride.status == GroupRideStatus.assembling)) ...[
|
||||||
|
_buildClubAdminControlCard(context, ride, colors),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
_buildActionBanner(
|
_buildActionBanner(
|
||||||
context,
|
context,
|
||||||
|
|
@ -437,6 +463,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
GroupRide ride,
|
GroupRide ride,
|
||||||
bool isOrganizer,
|
bool isOrganizer,
|
||||||
|
bool isClubAdmin,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors,
|
||||||
) {
|
) {
|
||||||
return Card(
|
return Card(
|
||||||
|
|
@ -501,18 +528,25 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOrganizer && !ride.isCanceled)
|
if ((isOrganizer && !ride.isCanceled) ||
|
||||||
|
(isClubAdmin &&
|
||||||
|
!ride.isCanceled &&
|
||||||
|
(ride.status == GroupRideStatus.scheduled ||
|
||||||
|
ride.status == GroupRideStatus.assembling)))
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white70),
|
icon: const Icon(Icons.more_vert, color: Colors.white70),
|
||||||
color: colors.surfaceContainerHigh,
|
color: colors.surfaceContainerHigh,
|
||||||
onSelected: (val) async {
|
onSelected: (val) async {
|
||||||
if (val == 'invite') {
|
if (val == 'invite') {
|
||||||
_showInviteFriendsSheet(context, ride, colors);
|
_showInviteFriendsSheet(context, ride, colors);
|
||||||
|
} else if (val == 'edit') {
|
||||||
|
_showEditDetailsDialog(context, ride, colors);
|
||||||
} else if (val == 'cancel') {
|
} else if (val == 'cancel') {
|
||||||
_confirmCancelRide(ride, colors);
|
_confirmCancelRide(ride, colors);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
|
if (isOrganizer)
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'invite',
|
value: 'invite',
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -520,6 +554,14 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (ride.status == GroupRideStatus.scheduled)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'edit',
|
||||||
|
child: Text(
|
||||||
|
'Edit Details',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'cancel',
|
value: 'cancel',
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -2030,6 +2072,181 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildClubAdminControlCard(
|
||||||
|
BuildContext context,
|
||||||
|
GroupRide ride,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
) {
|
||||||
|
return Card(
|
||||||
|
color: colors.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: colors.warningAmber, width: 0.8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.shield,
|
||||||
|
color: colors.warningAmber,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'CLUB ADMIN CONTROLS',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colors.warningAmber,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.0,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'Current Status: ${ride.status.label.toUpperCase()}',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (ride.status == GroupRideStatus.scheduled)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colors.cyberCyan,
|
||||||
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.edit, size: 16),
|
||||||
|
label: const Text('EDIT DETAILS'),
|
||||||
|
onPressed: () =>
|
||||||
|
_showEditDetailsDialog(context, ride, colors),
|
||||||
|
),
|
||||||
|
if (ride.status == GroupRideStatus.scheduled ||
|
||||||
|
ride.status == GroupRideStatus.assembling)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.raceRed.withValues(alpha: 0.2),
|
||||||
|
foregroundColor: colors.raceRed,
|
||||||
|
side: BorderSide(color: colors.raceRed),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.cancel, size: 16),
|
||||||
|
label: const Text('CANCEL RIDE'),
|
||||||
|
onPressed: () => _confirmCancelRide(ride, colors),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditDetailsDialog(
|
||||||
|
BuildContext context,
|
||||||
|
GroupRide ride,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
) {
|
||||||
|
final titleController = TextEditingController(text: ride.title);
|
||||||
|
final descController = TextEditingController(text: ride.description);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
backgroundColor: colors.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Edit Ride Details',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: titleController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Title',
|
||||||
|
labelStyle: TextStyle(color: colors.cyberCyan),
|
||||||
|
enabledBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: descController,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Description',
|
||||||
|
labelStyle: TextStyle(color: colors.cyberCyan),
|
||||||
|
enabledBorder: UnderlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: colors.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('CANCEL', style: TextStyle(color: Colors.white60)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.cyberCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final newTitle = titleController.text.trim();
|
||||||
|
if (newTitle.length < 3) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Title must be at least 3 characters.'),
|
||||||
|
backgroundColor: colors.raceRed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final currentUid = ref.read(currentUserProvider)?.uid;
|
||||||
|
if (currentUid == null) return;
|
||||||
|
try {
|
||||||
|
await ref.read(groupRideServiceProvider).updateGroupRide(
|
||||||
|
callerUid: currentUid,
|
||||||
|
groupRideId: ride.id,
|
||||||
|
title: newTitle,
|
||||||
|
description: descController.text.trim(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update ride: $e'),
|
||||||
|
backgroundColor: colors.raceRed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('SAVE'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLivePresenceSection(
|
Widget _buildLivePresenceSection(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
GroupRide ride,
|
GroupRide ride,
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,17 @@ class ClubDetailScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
|
// Club Rides Card
|
||||||
|
_buildClubRidesCard(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
club,
|
||||||
|
myMembership,
|
||||||
|
colors,
|
||||||
|
theme,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// Description Card
|
// Description Card
|
||||||
_buildDescriptionCard(context, club, colors, theme),
|
_buildDescriptionCard(context, club, colors, theme),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
@ -936,4 +947,79 @@ class ClubDetailScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildClubRidesCard(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Club club,
|
||||||
|
ClubMember? member,
|
||||||
|
MotorSignalThemeColors colors,
|
||||||
|
ThemeData theme,
|
||||||
|
) {
|
||||||
|
final isActiveMember = member != null && member.status.isActive;
|
||||||
|
final canPlanRides =
|
||||||
|
isActiveMember && member.role.isAtLeast(ClubRole.admin);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colors.outlineVariant, width: 0.8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'CLUB RIDES',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: colors.cyberCyan,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canPlanRides)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => context.push('/group-rides/create?clubId=${club.id}'),
|
||||||
|
icon: const Icon(Icons.add_road, size: 14),
|
||||||
|
label: const Text('Plan Club Ride'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.cyberCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: Text(
|
||||||
|
isActiveMember
|
||||||
|
? 'Club ride discovery is coming soon.'
|
||||||
|
: 'Club rides are visible to active members only.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontBody,
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -369,7 +369,12 @@ class GroupRide {
|
||||||
final DateTime? canceledAt;
|
final DateTime? canceledAt;
|
||||||
final String? canceledReason;
|
final String? canceledReason;
|
||||||
|
|
||||||
const GroupRide({
|
/// Club affiliation identifier if created for an explicit Club.
|
||||||
|
/// Null for personal/friend rides. Blank/whitespace-only is invalid.
|
||||||
|
/// Immutable after creation.
|
||||||
|
final String? clubId;
|
||||||
|
|
||||||
|
GroupRide({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.organizerUid,
|
required this.organizerUid,
|
||||||
required this.organizerHandle,
|
required this.organizerHandle,
|
||||||
|
|
@ -395,7 +400,18 @@ class GroupRide {
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
this.canceledAt,
|
this.canceledAt,
|
||||||
this.canceledReason,
|
this.canceledReason,
|
||||||
});
|
this.clubId,
|
||||||
|
}) {
|
||||||
|
if (clubId != null) {
|
||||||
|
if (clubId!.trim().isEmpty || clubId!.contains(RegExp(r'\s')) || clubId!.length > 64) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
clubId,
|
||||||
|
'clubId',
|
||||||
|
'Club ID cannot be blank, contain whitespace, or exceed 64 characters.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool get isCanceled => status == GroupRideStatus.canceled;
|
bool get isCanceled => status == GroupRideStatus.canceled;
|
||||||
bool get isCompleted => status == GroupRideStatus.completed;
|
bool get isCompleted => status == GroupRideStatus.completed;
|
||||||
|
|
@ -434,6 +450,7 @@ class GroupRide {
|
||||||
'updatedAt': updatedAt.toIso8601String(),
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(),
|
if (canceledAt != null) 'canceledAt': canceledAt!.toIso8601String(),
|
||||||
if (canceledReason != null) 'canceledReason': canceledReason,
|
if (canceledReason != null) 'canceledReason': canceledReason,
|
||||||
|
if (clubId != null) 'clubId': clubId,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory GroupRide.fromJson(Map<String, dynamic> json) {
|
factory GroupRide.fromJson(Map<String, dynamic> json) {
|
||||||
|
|
@ -508,6 +525,7 @@ class GroupRide {
|
||||||
updatedAt: updated,
|
updatedAt: updated,
|
||||||
canceledAt: canceled,
|
canceledAt: canceled,
|
||||||
canceledReason: json['canceledReason'] as String?,
|
canceledReason: json['canceledReason'] as String?,
|
||||||
|
clubId: json['clubId'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,6 +582,7 @@ class GroupRide {
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
canceledAt: canceledAt ?? this.canceledAt,
|
canceledAt: canceledAt ?? this.canceledAt,
|
||||||
canceledReason: canceledReason ?? this.canceledReason,
|
canceledReason: canceledReason ?? this.canceledReason,
|
||||||
|
clubId: clubId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,7 +599,8 @@ class GroupRide {
|
||||||
departureAt == other.departureAt &&
|
departureAt == other.departureAt &&
|
||||||
participantCount == other.participantCount &&
|
participantCount == other.participantCount &&
|
||||||
participantLimit == other.participantLimit &&
|
participantLimit == other.participantLimit &&
|
||||||
routeId == other.routeId;
|
routeId == other.routeId &&
|
||||||
|
clubId == other.clubId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
|
|
@ -592,7 +612,8 @@ class GroupRide {
|
||||||
departureAt.hashCode ^
|
departureAt.hashCode ^
|
||||||
participantCount.hashCode ^
|
participantCount.hashCode ^
|
||||||
participantLimit.hashCode ^
|
participantLimit.hashCode ^
|
||||||
routeId.hashCode;
|
routeId.hashCode ^
|
||||||
|
clubId.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of rider in live group ride presence.
|
/// Status of rider in live group ride presence.
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ final myGroupRidesProvider = StreamProvider<List<GroupRide>>((ref) {
|
||||||
return service.myRidesStream(callerUid: user.uid);
|
return service.myRidesStream(callerUid: user.uid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/// Stream of live presence records for all accepted participants in a specific Group Ride.
|
/// 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.
|
/// 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.
|
/// Isolates peer permission-denied / sharing-off events to the specific peer and recovers cleanly.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class GroupRideService {
|
||||||
String? routeTitle,
|
String? routeTitle,
|
||||||
double? routeDistanceMeters,
|
double? routeDistanceMeters,
|
||||||
int? routeStopCount,
|
int? routeStopCount,
|
||||||
|
String? clubId,
|
||||||
}) async {
|
}) async {
|
||||||
final cleanTitle = title.trim();
|
final cleanTitle = title.trim();
|
||||||
if (cleanTitle.length < 3) {
|
if (cleanTitle.length < 3) {
|
||||||
|
|
@ -54,6 +55,44 @@ class GroupRideService {
|
||||||
throw ArgumentError('Participant limit must be at least 2 riders.');
|
throw ArgumentError('Participant limit must be at least 2 riders.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clubId != null) {
|
||||||
|
if (clubId.trim().isEmpty || clubId.contains(RegExp(r'\s')) || clubId.length > 64) {
|
||||||
|
throw ArgumentError('Club ID cannot be blank, contain whitespace, or exceed 64 characters.');
|
||||||
|
}
|
||||||
|
final firestore = _firestore;
|
||||||
|
if (firestore == null) {
|
||||||
|
throw StateError('Cannot create a club ride while offline.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final clubDoc = await firestore.collection('clubs').doc(clubId).get();
|
||||||
|
if (!clubDoc.exists || clubDoc.data() == null) {
|
||||||
|
throw StateError('Club not found.');
|
||||||
|
}
|
||||||
|
final clubData = clubDoc.data()!;
|
||||||
|
if (clubData['archivedAt'] != null) {
|
||||||
|
throw StateError('Cannot create a ride for an archived club.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final memberDoc = await firestore
|
||||||
|
.collection('clubs')
|
||||||
|
.doc(clubId)
|
||||||
|
.collection('members')
|
||||||
|
.doc(organizer.uid)
|
||||||
|
.get();
|
||||||
|
if (!memberDoc.exists || memberDoc.data() == null) {
|
||||||
|
throw StateError('You are not a member of this club.');
|
||||||
|
}
|
||||||
|
final memberData = memberDoc.data()!;
|
||||||
|
final memberStatus = memberData['status'] as String?;
|
||||||
|
if (memberStatus != 'active') {
|
||||||
|
throw StateError('Active club membership is required to organize club rides.');
|
||||||
|
}
|
||||||
|
final memberRole = memberData['role'] as String?;
|
||||||
|
if (memberRole != 'owner' && memberRole != 'admin') {
|
||||||
|
throw StateError('Only club owners or administrators can organize club rides.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final rideId = 'ride_${now.millisecondsSinceEpoch}_${organizer.uid.substring(0, organizer.uid.length.clamp(0, 6))}';
|
final rideId = 'ride_${now.millisecondsSinceEpoch}_${organizer.uid.substring(0, organizer.uid.length.clamp(0, 6))}';
|
||||||
|
|
||||||
|
|
@ -81,6 +120,7 @@ class GroupRideService {
|
||||||
routeStopCount: routeStopCount,
|
routeStopCount: routeStopCount,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
clubId: clubId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final organizerParticipant = GroupRideParticipant(
|
final organizerParticipant = GroupRideParticipant(
|
||||||
|
|
@ -112,7 +152,32 @@ class GroupRideService {
|
||||||
return groupRide;
|
return groupRide;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates editable Group Ride details (organizer only).
|
Future<bool> _isActiveClubAdmin({
|
||||||
|
required String clubId,
|
||||||
|
required String uid,
|
||||||
|
}) async {
|
||||||
|
final firestore = _firestore;
|
||||||
|
if (firestore == null) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final memberDoc = await firestore
|
||||||
|
.collection('clubs')
|
||||||
|
.doc(clubId)
|
||||||
|
.collection('members')
|
||||||
|
.doc(uid)
|
||||||
|
.get();
|
||||||
|
if (!memberDoc.exists || memberDoc.data() == null) return false;
|
||||||
|
final memberData = memberDoc.data()!;
|
||||||
|
final memberStatus = memberData['status'] as String?;
|
||||||
|
final memberRole = memberData['role'] as String?;
|
||||||
|
return memberStatus == 'active' &&
|
||||||
|
(memberRole == 'owner' || memberRole == 'admin');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates editable Group Ride details (organizer or club admin).
|
||||||
Future<void> updateGroupRide({
|
Future<void> updateGroupRide({
|
||||||
required String callerUid,
|
required String callerUid,
|
||||||
required String groupRideId,
|
required String groupRideId,
|
||||||
|
|
@ -137,12 +202,20 @@ class GroupRideService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final current = GroupRide.fromJson(snapshot.data()!);
|
final current = GroupRide.fromJson(snapshot.data()!);
|
||||||
if (current.organizerUid != callerUid) {
|
final isOrganizer = current.organizerUid == callerUid;
|
||||||
throw StateError('Only the organizer can modify this ride.');
|
final isClubAdmin = !isOrganizer &&
|
||||||
|
current.clubId != null &&
|
||||||
|
await _isActiveClubAdmin(clubId: current.clubId!, uid: callerUid);
|
||||||
|
|
||||||
|
if (!isOrganizer && !isClubAdmin) {
|
||||||
|
throw StateError('Only the organizer or a club administrator can modify this ride.');
|
||||||
}
|
}
|
||||||
if (current.isCanceled || current.isCompleted) {
|
if (current.isCanceled || current.isCompleted) {
|
||||||
throw StateError('Cannot modify a canceled or completed ride.');
|
throw StateError('Cannot modify a canceled or completed ride.');
|
||||||
}
|
}
|
||||||
|
if (isClubAdmin && current.status != GroupRideStatus.scheduled) {
|
||||||
|
throw StateError('Club administrators can only modify scheduled rides.');
|
||||||
|
}
|
||||||
|
|
||||||
final newStart = scheduledStartAt ?? current.scheduledStartAt;
|
final newStart = scheduledStartAt ?? current.scheduledStartAt;
|
||||||
final newDep = departureAt ?? current.departureAt;
|
final newDep = departureAt ?? current.departureAt;
|
||||||
|
|
@ -169,7 +242,7 @@ class GroupRideService {
|
||||||
await rideRef.update(updates);
|
await rideRef.update(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels a Group Ride with an optional reason (organizer only).
|
/// Cancels a Group Ride with an optional reason (organizer or club admin).
|
||||||
Future<void> cancelGroupRide({
|
Future<void> cancelGroupRide({
|
||||||
required String callerUid,
|
required String callerUid,
|
||||||
required String groupRideId,
|
required String groupRideId,
|
||||||
|
|
@ -185,10 +258,25 @@ class GroupRideService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final current = GroupRide.fromJson(snapshot.data()!);
|
final current = GroupRide.fromJson(snapshot.data()!);
|
||||||
if (current.organizerUid != callerUid) {
|
final isOrganizer = current.organizerUid == callerUid;
|
||||||
throw StateError('Only the organizer can cancel this ride.');
|
final isClubAdmin = !isOrganizer &&
|
||||||
|
current.clubId != null &&
|
||||||
|
await _isActiveClubAdmin(clubId: current.clubId!, uid: callerUid);
|
||||||
|
|
||||||
|
if (!isOrganizer && !isClubAdmin) {
|
||||||
|
throw StateError('Only the organizer or a club administrator can cancel this ride.');
|
||||||
}
|
}
|
||||||
if (current.isCanceled) return;
|
if (current.isCanceled) return;
|
||||||
|
if (current.isCompleted) {
|
||||||
|
throw StateError('Cannot cancel a completed ride.');
|
||||||
|
}
|
||||||
|
if (isClubAdmin &&
|
||||||
|
current.status != GroupRideStatus.scheduled &&
|
||||||
|
current.status != GroupRideStatus.assembling) {
|
||||||
|
throw StateError(
|
||||||
|
'Club administrators can only cancel scheduled or assembling rides.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
await rideRef.update({
|
await rideRef.update({
|
||||||
|
|
|
||||||
|
|
@ -498,5 +498,95 @@ void main() {
|
||||||
expect(find.text('Leave Open Track Riders?'), findsOneWidget);
|
expect(find.text('Leave Open Track Riders?'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('non-member sees member-only informational card for club rides', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => testUser),
|
||||||
|
clubServiceProvider.overrideWithValue(mockService),
|
||||||
|
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
|
||||||
|
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const ClubDetailScreen(clubId: 'club_open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('CLUB RIDES'), findsOneWidget);
|
||||||
|
expect(find.text('Club rides are visible to active members only.'), findsOneWidget);
|
||||||
|
expect(find.text('Plan Club Ride'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('active member sees discovery coming soon and admin sees Plan Club Ride button', (tester) async {
|
||||||
|
final activeAdmin = ClubMember(
|
||||||
|
uid: 'rider_1',
|
||||||
|
clubId: 'club_open',
|
||||||
|
status: ClubMemberStatus.active,
|
||||||
|
role: ClubRole.admin,
|
||||||
|
joinedAt: DateTime(2026, 1, 1),
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => testUser),
|
||||||
|
clubServiceProvider.overrideWithValue(mockService),
|
||||||
|
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
|
||||||
|
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeAdmin)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const ClubDetailScreen(clubId: 'club_open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('CLUB RIDES'), findsOneWidget);
|
||||||
|
expect(find.text('Club ride discovery is coming soon.'), findsOneWidget);
|
||||||
|
expect(find.text('Plan Club Ride'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('active ordinary member sees discovery coming soon and no Plan button', (tester) async {
|
||||||
|
final activeMember = ClubMember(
|
||||||
|
uid: 'rider_2',
|
||||||
|
clubId: 'club_open',
|
||||||
|
status: ClubMemberStatus.active,
|
||||||
|
role: ClubRole.member,
|
||||||
|
joinedAt: DateTime(2026, 1, 1),
|
||||||
|
updatedAt: DateTime(2026, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWith((ref) => testUser),
|
||||||
|
clubServiceProvider.overrideWithValue(mockService),
|
||||||
|
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
|
||||||
|
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: ThemeData.dark().copyWith(
|
||||||
|
extensions: [MotorSignalThemeColors.dark],
|
||||||
|
),
|
||||||
|
home: const ClubDetailScreen(clubId: 'club_open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('CLUB RIDES'), findsOneWidget);
|
||||||
|
expect(find.text('Club ride discovery is coming soon.'), findsOneWidget);
|
||||||
|
expect(find.text('Plan Club Ride'), findsNothing);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import 'package:motorcycle_dash/services/routes/route_providers.dart';
|
||||||
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
|
||||||
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
import 'package:motorcycle_dash/services/social/social_providers.dart';
|
||||||
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
|
import 'package:motorcycle_dash/features/routes/widgets/address_autocomplete_field.dart';
|
||||||
|
import 'package:motorcycle_dash/services/clubs/club_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
|
||||||
import 'fake_route_provider.dart';
|
import 'fake_route_provider.dart';
|
||||||
|
|
||||||
class _MockGroupRideService extends GroupRideService {
|
class _MockGroupRideService extends GroupRideService {
|
||||||
|
|
@ -38,6 +40,7 @@ class _MockGroupRideService extends GroupRideService {
|
||||||
String? routeTitle,
|
String? routeTitle,
|
||||||
double? routeDistanceMeters,
|
double? routeDistanceMeters,
|
||||||
int? routeStopCount,
|
int? routeStopCount,
|
||||||
|
String? clubId,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
createdRide = GroupRide(
|
createdRide = GroupRide(
|
||||||
|
|
@ -57,6 +60,7 @@ class _MockGroupRideService extends GroupRideService {
|
||||||
stops: stops,
|
stops: stops,
|
||||||
routeId: routeId,
|
routeId: routeId,
|
||||||
routeTitle: routeTitle,
|
routeTitle: routeTitle,
|
||||||
|
clubId: clubId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
);
|
);
|
||||||
|
|
@ -951,13 +955,180 @@ void main() {
|
||||||
expect(find.textContaining('miles'), findsNothing);
|
expect(find.textContaining('miles'), findsNothing);
|
||||||
expect(find.textContaining('km'), findsNothing);
|
expect(find.textContaining('km'), findsNothing);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CreateGroupRideScreen Club Ride Creation Tests', () {
|
||||||
|
late RiderProfile profile;
|
||||||
|
late AuthUser user;
|
||||||
|
late Club testClub;
|
||||||
|
late ClubMember adminMember;
|
||||||
|
late ClubMember regularMember;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
profile = RiderProfile(
|
||||||
|
uid: 'alice',
|
||||||
|
handle: 'alice_apex',
|
||||||
|
displayName: 'Alice Apex',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
user = const AuthUser(
|
||||||
|
uid: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
);
|
||||||
|
testClub = Club(
|
||||||
|
id: 'club_alpha',
|
||||||
|
name: 'Alpha Moto Club',
|
||||||
|
slug: 'alpha-moto',
|
||||||
|
description: 'Elite riders club',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
adminMember = ClubMember(
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
uid: 'alice',
|
||||||
|
role: ClubRole.admin,
|
||||||
|
status: ClubMemberStatus.active,
|
||||||
|
joinedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
regularMember = ClubMember(
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
uid: 'alice',
|
||||||
|
role: ClubRole.member,
|
||||||
|
status: ClubMemberStatus.active,
|
||||||
|
joinedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Personal ride creation flow: no club header and creates with null clubId', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('CREATE GROUP RIDE'), findsOneWidget);
|
||||||
|
expect(find.text('CLUB RIDE'), findsNothing);
|
||||||
|
expect(find.textContaining('Planning for'), findsNothing);
|
||||||
|
expect(find.text('PUBLISH GROUP RIDE'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Club ride creation: active admin sees club header and creates with clubId', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)),
|
||||||
|
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(clubId: 'club_alpha'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('CREATE CLUB RIDE'), findsOneWidget);
|
||||||
|
expect(find.text('CLUB RIDE'), findsOneWidget);
|
||||||
|
expect(find.text('Planning for Alpha Moto Club'), findsOneWidget);
|
||||||
|
expect(find.text('PUBLISH CLUB RIDE'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Club ride creation: ordinary member is blocked with permission error', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)),
|
||||||
|
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(regularMember)),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(clubId: 'club_alpha'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('CREATE CLUB RIDE'), findsOneWidget);
|
||||||
|
expect(find.text('Club Affiliation Error'), findsOneWidget);
|
||||||
|
expect(find.text('Only club owners and administrators can create club rides.'), findsOneWidget);
|
||||||
|
|
||||||
|
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
|
||||||
|
expect(publishBtn.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Club ride creation: archived club is blocked with error', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(800, 2400));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
final mockService = _MockGroupRideService();
|
||||||
|
|
||||||
|
final archivedClub = Club(
|
||||||
|
id: 'club_alpha',
|
||||||
|
name: 'Archived Moto Club',
|
||||||
|
slug: 'archived-moto',
|
||||||
|
ownerUid: 'alice',
|
||||||
|
archivedAt: DateTime.now(),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
currentUserProvider.overrideWithValue(user),
|
||||||
|
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
|
||||||
|
groupRideServiceProvider.overrideWithValue(mockService),
|
||||||
|
clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(archivedClub)),
|
||||||
|
currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: CreateGroupRideScreen(clubId: 'club_alpha'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Club Affiliation Error'), findsOneWidget);
|
||||||
|
expect(find.text('This club has been archived and cannot organize rides.'), findsOneWidget);
|
||||||
|
|
||||||
|
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
|
||||||
|
expect(publishBtn.onPressed, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,305 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('GroupRide clubId Domain Tests', () {
|
||||||
|
final now = DateTime(2026, 9, 20, 10, 0);
|
||||||
|
final baseRide = GroupRide(
|
||||||
|
id: 'ride_base',
|
||||||
|
organizerUid: 'organizer_1',
|
||||||
|
organizerHandle: 'organizer',
|
||||||
|
organizerDisplayName: 'Organizer',
|
||||||
|
title: 'Sunday Morning Run',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now.add(const Duration(minutes: 30)),
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Base Meetup',
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('1. missing clubId deserializes to null', () {
|
||||||
|
final json = baseRide.toJson();
|
||||||
|
expect(json.containsKey('clubId'), isFalse);
|
||||||
|
|
||||||
|
final restored = GroupRide.fromJson(json);
|
||||||
|
expect(restored.clubId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. explicit null clubId deserializes to null', () {
|
||||||
|
final json = baseRide.toJson();
|
||||||
|
json['clubId'] = null;
|
||||||
|
|
||||||
|
final restored = GroupRide.fromJson(json);
|
||||||
|
expect(restored.clubId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. non-null clubId round-trips exactly', () {
|
||||||
|
final clubRide = GroupRide(
|
||||||
|
id: 'ride_club_1',
|
||||||
|
organizerUid: 'organizer_1',
|
||||||
|
organizerHandle: 'organizer',
|
||||||
|
organizerDisplayName: 'Organizer',
|
||||||
|
title: 'Club Mountain Loop',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now.add(const Duration(minutes: 30)),
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Club Meetup',
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'club_north_georgia_riders',
|
||||||
|
);
|
||||||
|
|
||||||
|
final json = clubRide.toJson();
|
||||||
|
expect(json['clubId'], equals('club_north_georgia_riders'));
|
||||||
|
|
||||||
|
final restored = GroupRide.fromJson(json);
|
||||||
|
expect(restored.clubId, equals('club_north_georgia_riders'));
|
||||||
|
expect(restored, equals(clubRide));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4. null clubId serialization preserves legacy document shape by omitting clubId', () {
|
||||||
|
expect(baseRide.clubId, isNull);
|
||||||
|
final json = baseRide.toJson();
|
||||||
|
expect(json.containsKey('clubId'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('5. blank or whitespace-only clubId is rejected at domain boundary', () {
|
||||||
|
// Direct constructor validation
|
||||||
|
expect(
|
||||||
|
() => GroupRide(
|
||||||
|
id: 'r1',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: '',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => GroupRide(
|
||||||
|
id: 'r2',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: ' ',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Internal, leading, or trailing whitespace rejected
|
||||||
|
expect(
|
||||||
|
() => GroupRide(
|
||||||
|
id: 'r3',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'club 123',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => GroupRide(
|
||||||
|
id: 'r4',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: ' club_123',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exceeds 64 characters rejected
|
||||||
|
expect(
|
||||||
|
() => GroupRide(
|
||||||
|
id: 'r5',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'c' * 65,
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deserialization validation
|
||||||
|
final jsonEmpty = baseRide.toJson()..['clubId'] = '';
|
||||||
|
expect(
|
||||||
|
() => GroupRide.fromJson(jsonEmpty),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final jsonWhitespace = baseRide.toJson()..['clubId'] = ' \t\n ';
|
||||||
|
expect(
|
||||||
|
() => GroupRide.fromJson(jsonWhitespace),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final jsonInternalSpace = baseRide.toJson()..['clubId'] = 'club alpha';
|
||||||
|
expect(
|
||||||
|
() => GroupRide.fromJson(jsonInternalSpace),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final jsonTooLong = baseRide.toJson()..['clubId'] = 'c' * 65;
|
||||||
|
expect(
|
||||||
|
() => GroupRide.fromJson(jsonTooLong),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('7. copyWith and object reconstruction preserves clubId affiliation immutably', () {
|
||||||
|
final clubRide = GroupRide(
|
||||||
|
id: 'ride_club_1',
|
||||||
|
organizerUid: 'organizer_1',
|
||||||
|
organizerHandle: 'organizer',
|
||||||
|
organizerDisplayName: 'Organizer',
|
||||||
|
title: 'Club Mountain Loop',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now.add(const Duration(minutes: 30)),
|
||||||
|
meetingPoint: const GroupRideLocation(
|
||||||
|
latitude: 34.5,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Club Meetup',
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'club_north_georgia_riders',
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = clubRide.copyWith(title: 'Updated Loop Title', status: GroupRideStatus.underway);
|
||||||
|
expect(updated.clubId, equals('club_north_georgia_riders'));
|
||||||
|
expect(updated.title, equals('Updated Loop Title'));
|
||||||
|
expect(updated.status, equals(GroupRideStatus.underway));
|
||||||
|
|
||||||
|
// Personal ride copyWith retains null clubId
|
||||||
|
final personalCopy = baseRide.copyWith(title: 'Updated Personal');
|
||||||
|
expect(personalCopy.clubId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('8. equality and hashCode include clubId', () {
|
||||||
|
final ridePersonal1 = GroupRide(
|
||||||
|
id: 'r1',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ridePersonal2 = GroupRide(
|
||||||
|
id: 'r1',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final rideClubA = GroupRide(
|
||||||
|
id: 'r1',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
);
|
||||||
|
|
||||||
|
final rideClubA2 = GroupRide(
|
||||||
|
id: 'r1',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'club_alpha',
|
||||||
|
);
|
||||||
|
|
||||||
|
final rideClubB = GroupRide(
|
||||||
|
id: 'r1',
|
||||||
|
organizerUid: 'u1',
|
||||||
|
organizerHandle: 'h1',
|
||||||
|
organizerDisplayName: 'd1',
|
||||||
|
title: 'Ride',
|
||||||
|
scheduledStartAt: now,
|
||||||
|
departureAt: now,
|
||||||
|
meetingPoint: const GroupRideLocation(latitude: 0, longitude: 0, displayName: 'Point'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
clubId: 'club_bravo',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ridePersonal1, equals(ridePersonal2));
|
||||||
|
expect(ridePersonal1.hashCode, equals(ridePersonal2.hashCode));
|
||||||
|
|
||||||
|
expect(rideClubA, equals(rideClubA2));
|
||||||
|
expect(rideClubA.hashCode, equals(rideClubA2.hashCode));
|
||||||
|
|
||||||
|
// Personal vs Club are not equal
|
||||||
|
expect(ridePersonal1 == rideClubA, isFalse);
|
||||||
|
expect(ridePersonal1.hashCode == rideClubA.hashCode, isFalse);
|
||||||
|
|
||||||
|
// Club A vs Club B are not equal
|
||||||
|
expect(rideClubA == rideClubB, isFalse);
|
||||||
|
expect(rideClubA.hashCode == rideClubB.hashCode, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('GroupRideParticipant Model Tests', () {
|
group('GroupRideParticipant Model Tests', () {
|
||||||
test('serializes and deserializes participant', () {
|
test('serializes and deserializes participant', () {
|
||||||
final now = DateTime(2026, 9, 3, 12, 0);
|
final now = DateTime(2026, 9, 3, 12, 0);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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/app_colors.dart';
|
||||||
import 'package:motorcycle_dash/core/theme/theme.dart';
|
import 'package:motorcycle_dash/core/theme/theme.dart';
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.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_providers.dart';
|
||||||
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart';
|
||||||
|
|
@ -478,6 +479,579 @@ void main() {
|
||||||
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
|
expect(lightTheme.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, Brightness.dark);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('GroupRideService Club Affiliation & Discovery Tests', () {
|
||||||
|
late FakeFirebaseFirestore fakeFirestore;
|
||||||
|
late GroupRideService service;
|
||||||
|
late RiderProfile organizer;
|
||||||
|
const meetup = GroupRideLocation(
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -84.0,
|
||||||
|
displayName: 'Starting Spot',
|
||||||
|
);
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
fakeFirestore = FakeFirebaseFirestore();
|
||||||
|
service = GroupRideService(firestore: fakeFirestore);
|
||||||
|
organizer = RiderProfile(
|
||||||
|
uid: 'user_alice',
|
||||||
|
handle: 'alice_apex',
|
||||||
|
displayName: 'Alice Apex',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('personal ride creation omits clubId and does not perform club reads', () async {
|
||||||
|
final ride = await service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Sunday Morning Cruise',
|
||||||
|
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ride.clubId, isNull);
|
||||||
|
|
||||||
|
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
|
||||||
|
expect(doc.exists, isTrue);
|
||||||
|
expect(doc.data()!.containsKey('clubId'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active club admin creates club-affiliated ride with persisted clubId', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
// Seed club document
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
||||||
|
'id': 'club_123',
|
||||||
|
'name': 'Alpha Club',
|
||||||
|
'slug': 'alpha-club',
|
||||||
|
'ownerUid': 'user_bob',
|
||||||
|
'visibility': 'public',
|
||||||
|
'admissionPolicy': 'open',
|
||||||
|
'memberCount': 2,
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
// Seed membership document for organizer as admin
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
|
||||||
|
'clubId': 'club_123',
|
||||||
|
'uid': 'user_alice',
|
||||||
|
'role': 'admin',
|
||||||
|
'status': 'active',
|
||||||
|
'joinedAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final ride = await service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Alpha Club Mountain Run',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ride.clubId, 'club_123');
|
||||||
|
|
||||||
|
final doc = await fakeFirestore.collection('group_rides').doc(ride.id).get();
|
||||||
|
expect(doc.exists, isTrue);
|
||||||
|
expect(doc.data()!['clubId'], 'club_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active club owner creates club-affiliated ride with persisted clubId', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
||||||
|
'id': 'club_123',
|
||||||
|
'name': 'Alpha Club',
|
||||||
|
'slug': 'alpha-club',
|
||||||
|
'ownerUid': 'user_alice',
|
||||||
|
'visibility': 'public',
|
||||||
|
'admissionPolicy': 'open',
|
||||||
|
'memberCount': 1,
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
|
||||||
|
'clubId': 'club_123',
|
||||||
|
'uid': 'user_alice',
|
||||||
|
'role': 'owner',
|
||||||
|
'status': 'active',
|
||||||
|
'joinedAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final ride = await service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Alpha Club Mountain Run',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club_123',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ride.clubId, 'club_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed clubId with ArgumentError', () async {
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Malformed Club Ride',
|
||||||
|
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club with spaces',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Empty Club Ride',
|
||||||
|
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: ' ',
|
||||||
|
),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects creation when club does not exist (StateError)', () async {
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Nonexistent Club Ride',
|
||||||
|
scheduledStartAt: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
departureAt: DateTime.now().add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'nonexistent_club',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects creation when club is archived (StateError)', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_archived').set({
|
||||||
|
'id': 'club_archived',
|
||||||
|
'name': 'Archived Club',
|
||||||
|
'slug': 'archived-club',
|
||||||
|
'ownerUid': 'user_alice',
|
||||||
|
'archivedAt': now.toIso8601String(),
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_archived').collection('members').doc('user_alice').set({
|
||||||
|
'clubId': 'club_archived',
|
||||||
|
'uid': 'user_alice',
|
||||||
|
'role': 'owner',
|
||||||
|
'status': 'active',
|
||||||
|
'joinedAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Archived Club Ride',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club_archived',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects creation when organizer is ordinary member (StateError)', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
||||||
|
'id': 'club_123',
|
||||||
|
'name': 'Alpha Club',
|
||||||
|
'slug': 'alpha-club',
|
||||||
|
'ownerUid': 'user_bob',
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
|
||||||
|
'clubId': 'club_123',
|
||||||
|
'uid': 'user_alice',
|
||||||
|
'role': 'member',
|
||||||
|
'status': 'active',
|
||||||
|
'joinedAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Ordinary Member Club Ride',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club_123',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects creation when organizer membership is not active (StateError)', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
||||||
|
'id': 'club_123',
|
||||||
|
'name': 'Alpha Club',
|
||||||
|
'slug': 'alpha-club',
|
||||||
|
'ownerUid': 'user_bob',
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').collection('members').doc('user_alice').set({
|
||||||
|
'clubId': 'club_123',
|
||||||
|
'uid': 'user_alice',
|
||||||
|
'role': 'admin',
|
||||||
|
'status': 'invited',
|
||||||
|
'joinedAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Invited Member Club Ride',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club_123',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects creation when organizer is not a member (StateError)', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('clubs').doc('club_123').set({
|
||||||
|
'id': 'club_123',
|
||||||
|
'name': 'Alpha Club',
|
||||||
|
'slug': 'alpha-club',
|
||||||
|
'ownerUid': 'user_bob',
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.createGroupRide(
|
||||||
|
organizer: organizer,
|
||||||
|
title: 'Non-Member Club Ride',
|
||||||
|
scheduledStartAt: now.add(const Duration(days: 1)),
|
||||||
|
departureAt: now.add(const Duration(days: 1, minutes: 30)),
|
||||||
|
meetingPoint: meetup,
|
||||||
|
clubId: 'club_123',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Club Admin Group Ride Management Tests', () {
|
||||||
|
late FakeFirebaseFirestore fakeFirestore;
|
||||||
|
late GroupRideService service;
|
||||||
|
late GroupRideLocation meetup;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
fakeFirestore = FakeFirebaseFirestore();
|
||||||
|
service = GroupRideService(firestore: fakeFirestore);
|
||||||
|
meetup = const GroupRideLocation(
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
displayName: 'Twin Peaks SF',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> seedClubAndMember({
|
||||||
|
required String clubId,
|
||||||
|
required String uid,
|
||||||
|
required String role,
|
||||||
|
required String status,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('clubs').doc(clubId).set({
|
||||||
|
'id': clubId,
|
||||||
|
'name': 'Alpha Club',
|
||||||
|
'slug': 'alpha-club',
|
||||||
|
'ownerUid': 'user_owner',
|
||||||
|
'visibility': 'public',
|
||||||
|
'admissionPolicy': 'open',
|
||||||
|
'memberCount': 2,
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('clubs')
|
||||||
|
.doc(clubId)
|
||||||
|
.collection('members')
|
||||||
|
.doc(uid)
|
||||||
|
.set({
|
||||||
|
'clubId': clubId,
|
||||||
|
'uid': uid,
|
||||||
|
'role': role,
|
||||||
|
'status': status,
|
||||||
|
'joinedAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seedRide({
|
||||||
|
required String rideId,
|
||||||
|
required String organizerUid,
|
||||||
|
String? clubId,
|
||||||
|
GroupRideStatus status = GroupRideStatus.scheduled,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
await fakeFirestore.collection('group_rides').doc(rideId).set({
|
||||||
|
'id': rideId,
|
||||||
|
'organizerUid': organizerUid,
|
||||||
|
'title': 'Original Ride Title',
|
||||||
|
'description': 'Original description',
|
||||||
|
'status': status.name,
|
||||||
|
'visibility': 'public',
|
||||||
|
'joinPolicy': 'open',
|
||||||
|
'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(),
|
||||||
|
'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(),
|
||||||
|
'meetingPoint': meetup.toJson(),
|
||||||
|
'stops': [],
|
||||||
|
'accessUids': [organizerUid],
|
||||||
|
'clubId': ?clubId,
|
||||||
|
'createdAt': now.toIso8601String(),
|
||||||
|
'updatedAt': now.toIso8601String(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('active club admin can update scheduled club ride metadata', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_123',
|
||||||
|
uid: 'user_admin',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_1',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.scheduled,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.updateGroupRide(
|
||||||
|
callerUid: 'user_admin',
|
||||||
|
groupRideId: 'ride_1',
|
||||||
|
title: 'Updated Title by Club Admin',
|
||||||
|
description: 'Updated description by Club Admin',
|
||||||
|
);
|
||||||
|
|
||||||
|
final doc = await fakeFirestore.collection('group_rides').doc('ride_1').get();
|
||||||
|
expect(doc.data()!['title'], 'Updated Title by Club Admin');
|
||||||
|
expect(doc.data()!['description'], 'Updated description by Club Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active club owner can update scheduled club ride metadata', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_123',
|
||||||
|
uid: 'user_owner',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_1',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.scheduled,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.updateGroupRide(
|
||||||
|
callerUid: 'user_owner',
|
||||||
|
groupRideId: 'ride_1',
|
||||||
|
title: 'Updated Title by Club Owner',
|
||||||
|
);
|
||||||
|
|
||||||
|
final doc = await fakeFirestore.collection('group_rides').doc('ride_1').get();
|
||||||
|
expect(doc.data()!['title'], 'Updated Title by Club Owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('club admin cannot update assembling, underway, completed, or canceled club ride', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_123',
|
||||||
|
uid: 'user_admin',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final s in [
|
||||||
|
GroupRideStatus.assembling,
|
||||||
|
GroupRideStatus.underway,
|
||||||
|
GroupRideStatus.completed,
|
||||||
|
GroupRideStatus.canceled,
|
||||||
|
]) {
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_${s.name}',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: s,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.updateGroupRide(
|
||||||
|
callerUid: 'user_admin',
|
||||||
|
groupRideId: 'ride_${s.name}',
|
||||||
|
title: 'Attempted Update',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active club admin can cancel scheduled or assembling club ride', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_123',
|
||||||
|
uid: 'user_admin',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scheduled
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_sched',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.scheduled,
|
||||||
|
);
|
||||||
|
await service.cancelGroupRide(
|
||||||
|
callerUid: 'user_admin',
|
||||||
|
groupRideId: 'ride_sched',
|
||||||
|
reason: 'Weather cancellation by Club Admin',
|
||||||
|
);
|
||||||
|
final docSched = await fakeFirestore.collection('group_rides').doc('ride_sched').get();
|
||||||
|
expect(docSched.data()!['status'], 'canceled');
|
||||||
|
expect(docSched.data()!['canceledReason'], 'Weather cancellation by Club Admin');
|
||||||
|
|
||||||
|
// Assembling
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_assemb',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.assembling,
|
||||||
|
);
|
||||||
|
await service.cancelGroupRide(
|
||||||
|
callerUid: 'user_admin',
|
||||||
|
groupRideId: 'ride_assemb',
|
||||||
|
reason: 'Emergency cancellation by Club Admin',
|
||||||
|
);
|
||||||
|
final docAssemb = await fakeFirestore.collection('group_rides').doc('ride_assemb').get();
|
||||||
|
expect(docAssemb.data()!['status'], 'canceled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('club admin cannot cancel underway or completed club ride', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_123',
|
||||||
|
uid: 'user_admin',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_underway',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.underway,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.cancelGroupRide(
|
||||||
|
callerUid: 'user_admin',
|
||||||
|
groupRideId: 'ride_underway',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_completed',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.completed,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.cancelGroupRide(
|
||||||
|
callerUid: 'user_admin',
|
||||||
|
groupRideId: 'ride_completed',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-admin club member cannot update or cancel club ride', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_123',
|
||||||
|
uid: 'user_member',
|
||||||
|
role: 'member',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_sched',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.scheduled,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.updateGroupRide(
|
||||||
|
callerUid: 'user_member',
|
||||||
|
groupRideId: 'ride_sched',
|
||||||
|
title: 'Unauthorized Member Update',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.cancelGroupRide(
|
||||||
|
callerUid: 'user_member',
|
||||||
|
groupRideId: 'ride_sched',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin from different club cannot update or cancel club ride', () async {
|
||||||
|
await seedClubAndMember(
|
||||||
|
clubId: 'club_456',
|
||||||
|
uid: 'user_other_admin',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
);
|
||||||
|
await seedRide(
|
||||||
|
rideId: 'ride_sched',
|
||||||
|
organizerUid: 'user_organizer',
|
||||||
|
clubId: 'club_123',
|
||||||
|
status: GroupRideStatus.scheduled,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.updateGroupRide(
|
||||||
|
callerUid: 'user_other_admin',
|
||||||
|
groupRideId: 'ride_sched',
|
||||||
|
title: 'Cross-Club Update',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.cancelGroupRide(
|
||||||
|
callerUid: 'user_other_admin',
|
||||||
|
groupRideId: 'ride_sched',
|
||||||
|
),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeGroupRideLifecycleService implements GroupRideService {
|
class _FakeGroupRideLifecycleService implements GroupRideService {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue