From 2e76561f6721dfd048082ff681eb1ef38c2d3720 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 6 Sep 2026 21:46:06 -0400 Subject: [PATCH] feat: add secure club membership lifecycle --- .../emulator-tests/firestore.rules.test.js | 1050 +++++++++++++++++ firestore.rules | 38 + lib/core/router.dart | 4 +- .../social/clubs/club_detail_screen.dart | 939 +++++++++++++++ .../clubs/club_membership_controller.dart | 242 ++++ .../clubs/club_membership_presentation.dart | 244 ++++ .../social/clubs/club_preview_screen.dart | 435 +------ .../social/clubs/clubs_hub_screen.dart | 124 +- .../clubs/widgets/club_invitation_card.dart | 203 ++++ test/club_detail_screen_test.dart | 502 ++++++++ test/club_membership_controller_test.dart | 282 +++++ test/club_membership_presentation_test.dart | 265 +++++ test/clubs_hub_screen_test.dart | 55 + 13 files changed, 3923 insertions(+), 460 deletions(-) create mode 100644 lib/features/social/clubs/club_detail_screen.dart create mode 100644 lib/features/social/clubs/club_membership_controller.dart create mode 100644 lib/features/social/clubs/club_membership_presentation.dart create mode 100644 lib/features/social/clubs/widgets/club_invitation_card.dart create mode 100644 test/club_detail_screen_test.dart create mode 100644 test/club_membership_controller_test.dart create mode 100644 test/club_membership_presentation_test.dart diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 204c469..c705dfd 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -2849,6 +2849,1056 @@ describe('Social Architecture Security Rules', () => { updatedAt: new Date(), })); }); + + describe('CLUB-2C.2: Membership Re-Entry Attack Matrix (Cases A - AF)', () => { + async function setupClub(db, clubId, admissionPolicy, archived = false) { + await setDoc(doc(db, `clubs/${clubId}`), { + id: clubId, + name: `${clubId} Name`, + slug: `${clubId}-slug`, + visibility: 'public', + admissionPolicy, + ownerUid: 'alice', + memberCount: 1, + archivedAt: archived ? new Date() : null, + }); + await setDoc(doc(db, `clubs/${clubId}/members/alice`), { + uid: 'alice', + clubId, + status: 'active', + role: 'owner', + }); + } + + async function seedTombstone(db, clubId, uid, status) { + await setDoc(doc(db, `clubs/${clubId}/members/${uid}`), { + uid, + clubId, + status, + role: 'member', + }); + } + + async function seedBan(db, clubId, uid) { + await setDoc(doc(db, `clubs/${clubId}/bans/${uid}`), { + uid, + bannedAt: new Date(), + }); + } + + // A. left -> active OPEN succeeds + test('Case A: left -> active in OPEN club succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_a', 'open'); + await seedTombstone(db, 'club_reentry_a', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_a/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_a', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_a'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(batch.commit()); + }); + + // B. declined -> active OPEN succeeds + test('Case B: declined -> active in OPEN club succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_b', 'open'); + await seedTombstone(db, 'club_reentry_b', 'bob', 'declined'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_b/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_b', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_b'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(batch.commit()); + }); + + // C. removed -> active OPEN succeeds + test('Case C: removed -> active in OPEN club succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_c', 'open'); + await seedTombstone(db, 'club_reentry_c', 'bob', 'removed'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_c/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_c', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_c'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(batch.commit()); + }); + + // D. left -> requested REQUEST succeeds + test('Case D: left -> requested in REQUEST club succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_d', 'request'); + await seedTombstone(db, 'club_reentry_d', 'bob', 'left'); + }); + + await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_d/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_d', + status: 'requested', + role: 'member', + })); + }); + + // E. declined -> requested REQUEST succeeds + test('Case E: declined -> requested in REQUEST club succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_e', 'request'); + await seedTombstone(db, 'club_reentry_e', 'bob', 'declined'); + }); + + await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_e/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_e', + status: 'requested', + role: 'member', + })); + }); + + // F. removed -> requested REQUEST succeeds + test('Case F: removed -> requested in REQUEST club succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_f', 'request'); + await seedTombstone(db, 'club_reentry_f', 'bob', 'removed'); + }); + + await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_f/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_f', + status: 'requested', + role: 'member', + })); + }); + + // G. left -> active REQUEST denied + test('Case G: left -> active in REQUEST club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_g', 'request'); + await seedTombstone(db, 'club_reentry_g', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_g/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_g', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_g'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // H. declined -> active REQUEST denied + test('Case H: declined -> active in REQUEST club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_h', 'request'); + await seedTombstone(db, 'club_reentry_h', 'bob', 'declined'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_h/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_h', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_h'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // I. removed -> active REQUEST denied + test('Case I: removed -> active in REQUEST club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_i', 'request'); + await seedTombstone(db, 'club_reentry_i', 'bob', 'removed'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_i/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_i', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_i'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // J. left -> requested OPEN denied + test('Case J: left -> requested in OPEN club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_j', 'open'); + await seedTombstone(db, 'club_reentry_j', 'bob', 'left'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_j/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_j', + status: 'requested', + role: 'member', + })); + }); + + // K. declined -> requested OPEN denied + test('Case K: declined -> requested in OPEN club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_k', 'open'); + await seedTombstone(db, 'club_reentry_k', 'bob', 'declined'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_k/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_k', + status: 'requested', + role: 'member', + })); + }); + + // L. removed -> requested OPEN denied + test('Case L: removed -> requested in OPEN club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_l', 'open'); + await seedTombstone(db, 'club_reentry_l', 'bob', 'removed'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_l/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_l', + status: 'requested', + role: 'member', + })); + }); + + // M. left -> active INVITE_ONLY denied + test('Case M: left -> active in INVITE_ONLY club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_m', 'inviteOnly'); + await seedTombstone(db, 'club_reentry_m', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_m/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_m', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_m'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // N. declined -> active INVITE_ONLY denied + test('Case N: declined -> active in INVITE_ONLY club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_n', 'inviteOnly'); + await seedTombstone(db, 'club_reentry_n', 'bob', 'declined'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_n/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_n', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_n'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // O. removed -> active INVITE_ONLY denied + test('Case O: removed -> active in INVITE_ONLY club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_o', 'inviteOnly'); + await seedTombstone(db, 'club_reentry_o', 'bob', 'removed'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_o/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_o', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_o'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // P. left -> requested INVITE_ONLY denied + test('Case P: left -> requested in INVITE_ONLY club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_p', 'inviteOnly'); + await seedTombstone(db, 'club_reentry_p', 'bob', 'left'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_p/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_p', + status: 'requested', + role: 'member', + })); + }); + + // Q. declined -> requested INVITE_ONLY denied + test('Case Q: declined -> requested in INVITE_ONLY club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_q', 'inviteOnly'); + await seedTombstone(db, 'club_reentry_q', 'bob', 'declined'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_q/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_q', + status: 'requested', + role: 'member', + })); + }); + + // R. removed -> requested INVITE_ONLY denied + test('Case R: removed -> requested in INVITE_ONLY club denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_r', 'inviteOnly'); + await seedTombstone(db, 'club_reentry_r', 'bob', 'removed'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_r/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_r', + status: 'requested', + role: 'member', + })); + }); + + // S. banned left -> active denied + test('Case S: banned left -> active denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_s', 'open'); + await seedTombstone(db, 'club_reentry_s', 'bob', 'left'); + await seedBan(db, 'club_reentry_s', 'bob'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_s/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_s', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_s'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // T. banned declined -> active denied + test('Case T: banned declined -> active denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_t', 'open'); + await seedTombstone(db, 'club_reentry_t', 'bob', 'declined'); + await seedBan(db, 'club_reentry_t', 'bob'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_t/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_t', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_t'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // U. banned removed -> active denied + test('Case U: banned removed -> active denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_u', 'open'); + await seedTombstone(db, 'club_reentry_u', 'bob', 'removed'); + await seedBan(db, 'club_reentry_u', 'bob'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_u/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_u', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_u'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // V. banned left -> requested denied + test('Case V: banned left -> requested denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_v', 'request'); + await seedTombstone(db, 'club_reentry_v', 'bob', 'left'); + await seedBan(db, 'club_reentry_v', 'bob'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_v/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_v', + status: 'requested', + role: 'member', + })); + }); + + // W. banned declined -> requested denied + test('Case W: banned declined -> requested denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_w', 'request'); + await seedTombstone(db, 'club_reentry_w', 'bob', 'declined'); + await seedBan(db, 'club_reentry_w', 'bob'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_w/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_w', + status: 'requested', + role: 'member', + })); + }); + + // X. banned removed -> requested denied + test('Case X: banned removed -> requested denied', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_x', 'request'); + await seedTombstone(db, 'club_reentry_x', 'bob', 'removed'); + await seedBan(db, 'club_reentry_x', 'bob'); + }); + + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_x/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_x', + status: 'requested', + role: 'member', + })); + }); + + // Y. re-entry cannot self-promote role to admin + test('Case Y: re-entry cannot self-promote role to admin', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_y', 'open'); + await seedTombstone(db, 'club_reentry_y', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_y/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_y', + status: 'active', + role: 'admin', + }); + batch.update(doc(bob, 'clubs/club_reentry_y'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // Z. re-entry cannot self-promote role to owner + test('Case Z: re-entry cannot self-promote role to owner', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_z', 'open'); + await seedTombstone(db, 'club_reentry_z', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_z/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_z', + status: 'active', + role: 'owner', + }); + batch.update(doc(bob, 'clubs/club_reentry_z'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // AA. re-entry cannot inject rankId + test('Case AA: re-entry cannot inject rankId, displayRank, or invitedByUid', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_aa', 'open'); + await seedTombstone(db, 'club_reentry_aa', 'bob', 'left'); + }); + + // 1. rankId injection + const rankBatch = writeBatch(bob); + rankBatch.set(doc(bob, 'clubs/club_reentry_aa/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_aa', + status: 'active', + role: 'member', + rankId: 'officer_rank', + }); + rankBatch.update(doc(bob, 'clubs/club_reentry_aa'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(rankBatch.commit()); + + // 2. displayRank injection + const displayRankBatch = writeBatch(bob); + displayRankBatch.set(doc(bob, 'clubs/club_reentry_aa/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_aa', + status: 'active', + role: 'member', + displayRank: 'Road Captain', + }); + displayRankBatch.update(doc(bob, 'clubs/club_reentry_aa'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(displayRankBatch.commit()); + + // 3. invitedByUid injection + const inviteInjectBatch = writeBatch(bob); + inviteInjectBatch.set(doc(bob, 'clubs/club_reentry_aa/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_aa', + status: 'active', + role: 'member', + invitedByUid: 'alice', + }); + inviteInjectBatch.update(doc(bob, 'clubs/club_reentry_aa'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(inviteInjectBatch.commit()); + }); + + // AB. re-entry cannot alter ownerUid + test('Case AB: re-entry cannot alter ownerUid', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ab', 'open'); + await seedTombstone(db, 'club_reentry_ab', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_ab/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ab', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_ab'), { + memberCount: 2, + ownerUid: 'bob', + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // AC. re-entry cannot manipulate memberCount + test('Case AC: re-entry cannot manipulate memberCount (arbitrary jump or missing increment)', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ac', 'open'); + await seedTombstone(db, 'club_reentry_ac', 'bob', 'left'); + }); + + // Arbitrary jump (+5) + const jumpBatch = writeBatch(bob); + jumpBatch.set(doc(bob, 'clubs/club_reentry_ac/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ac', + status: 'active', + role: 'member', + }); + jumpBatch.update(doc(bob, 'clubs/club_reentry_ac'), { + memberCount: 6, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(jumpBatch.commit()); + + // Zero increment (memberCount remains 1) + const zeroBatch = writeBatch(bob); + zeroBatch.set(doc(bob, 'clubs/club_reentry_ac/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ac', + status: 'active', + role: 'member', + }); + zeroBatch.update(doc(bob, 'clubs/club_reentry_ac'), { + memberCount: 1, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(zeroBatch.commit()); + }); + + // AD. re-entry cannot bypass archived Club + test('Case AD: re-entry cannot bypass archived Club', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ad', 'open', true); + await seedTombstone(db, 'club_reentry_ad', 'bob', 'left'); + }); + + const batch = writeBatch(bob); + batch.set(doc(bob, 'clubs/club_reentry_ad/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ad', + status: 'active', + role: 'member', + }); + batch.update(doc(bob, 'clubs/club_reentry_ad'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(batch.commit()); + }); + + // AE. request -> withdraw -> request again succeeds + test('Case AE: request -> withdraw -> request again succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ae', 'request'); + }); + + // 1. Initial request (none -> requested) + await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_ae/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ae', + status: 'requested', + role: 'member', + })); + + // 2. Withdraw request (requested -> left) + await assertSucceeds(updateDoc(doc(bob, 'clubs/club_reentry_ae/members/bob'), { + status: 'left', + updatedAt: new Date(), + })); + + // 3. Request again (left -> requested) + await assertSucceeds(setDoc(doc(bob, 'clubs/club_reentry_ae/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ae', + status: 'requested', + role: 'member', + })); + }); + + // AF. join -> leave -> join again succeeds + test('Case AF: join -> leave -> join again succeeds', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_af', 'open'); + }); + + // 1. Initial join (none -> active) + const joinBatch = writeBatch(bob); + joinBatch.set(doc(bob, 'clubs/club_reentry_af/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_af', + status: 'active', + role: 'member', + }); + joinBatch.update(doc(bob, 'clubs/club_reentry_af'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(joinBatch.commit()); + + // 2. Leave (active -> left) + const leaveBatch = writeBatch(bob); + leaveBatch.update(doc(bob, 'clubs/club_reentry_af/members/bob'), { + status: 'left', + }); + leaveBatch.update(doc(bob, 'clubs/club_reentry_af'), { + memberCount: 1, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(leaveBatch.commit()); + + // 3. Re-join (left -> active) + const rejoinBatch = writeBatch(bob); + rejoinBatch.set(doc(bob, 'clubs/club_reentry_af/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_af', + status: 'active', + role: 'member', + }); + rejoinBatch.update(doc(bob, 'clubs/club_reentry_af'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(rejoinBatch.commit()); + }); + + // AG. Arbitrary field injection rejected on re-entry + test('Case AG: arbitrary field injection rejected on re-entry', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ag_open', 'open'); + await seedTombstone(db, 'club_reentry_ag_open', 'bob', 'left'); + await setupClub(db, 'club_reentry_ag_req', 'request'); + await seedTombstone(db, 'club_reentry_ag_req', 'bob', 'left'); + }); + + // 1. Open re-entry with arbitrary unknown field -> DENY + const openBatch = writeBatch(bob); + openBatch.set(doc(bob, 'clubs/club_reentry_ag_open/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ag_open', + status: 'active', + role: 'member', + arbitraryUnknownField: 'injected_payload', + }); + openBatch.update(doc(bob, 'clubs/club_reentry_ag_open'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(openBatch.commit()); + + // 2. Request re-entry with arbitrary unknown field -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_ag_req/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ag_req', + status: 'requested', + role: 'member', + arbitraryUnknownField: 'injected_payload', + })); + }); + + // AH. Non-timestamp type rejected on re-entry + test('Case AH: non-timestamp type rejected on re-entry', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ah_open', 'open'); + await seedTombstone(db, 'club_reentry_ah_open', 'bob', 'left'); + await setupClub(db, 'club_reentry_ah_req', 'request'); + await seedTombstone(db, 'club_reentry_ah_req', 'bob', 'left'); + }); + + // 1. Open re-entry with string joinedAt -> DENY + const openBatch = writeBatch(bob); + openBatch.set(doc(bob, 'clubs/club_reentry_ah_open/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ah_open', + status: 'active', + role: 'member', + joinedAt: '2026-09-06T12:00:00Z', + }); + openBatch.update(doc(bob, 'clubs/club_reentry_ah_open'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(openBatch.commit()); + + // 2. Request re-entry with integer requestedAt -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_ah_req/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ah_req', + status: 'requested', + role: 'member', + requestedAt: 1725657600, + })); + }); + + // AI. Anonymous and unauthenticated callers cannot re-enter + test('Case AI: anonymous and unauthenticated callers cannot re-enter', async () => { + const anon = testEnv.authenticatedContext('anon', { firebase: { sign_in_provider: 'anonymous' } }).firestore(); + const unauth = testEnv.unauthenticatedContext().firestore(); + + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ai_open', 'open'); + await seedTombstone(db, 'club_reentry_ai_open', 'anon', 'left'); + await seedTombstone(db, 'club_reentry_ai_open', 'unauth_user', 'left'); + }); + + // 1. Anonymous caller attempting open re-entry -> DENY + const anonBatch = writeBatch(anon); + anonBatch.set(doc(anon, 'clubs/club_reentry_ai_open/members/anon'), { + uid: 'anon', + clubId: 'club_reentry_ai_open', + status: 'active', + role: 'member', + }); + anonBatch.update(doc(anon, 'clubs/club_reentry_ai_open'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'anon', + }); + await assertFails(anonBatch.commit()); + + // 2. Unauthenticated caller attempting open re-entry -> DENY + const unauthBatch = writeBatch(unauth); + unauthBatch.set(doc(unauth, 'clubs/club_reentry_ai_open/members/unauth_user'), { + uid: 'unauth_user', + clubId: 'club_reentry_ai_open', + status: 'active', + role: 'member', + }); + unauthBatch.update(doc(unauth, 'clubs/club_reentry_ai_open'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'unauth_user', + }); + await assertFails(unauthBatch.commit()); + }); + + // AJ. Cross-user re-entry rejected (caller cannot re-enter for another rider) + test('Case AJ: cross-user re-entry rejected', async () => { + const charlie = testEnv.authenticatedContext('charlie').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_aj', 'open'); + await seedTombstone(db, 'club_reentry_aj', 'bob', 'left'); + }); + + // Charlie tries to re-enter Bob's tombstone doc + const batch = writeBatch(charlie); + batch.set(doc(charlie, 'clubs/club_reentry_aj/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_aj', + status: 'active', + role: 'member', + }); + batch.update(doc(charlie, 'clubs/club_reentry_aj'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'charlie', + }); + await assertFails(batch.commit()); + }); + + // AK. Root Club piggyback mutation rejected during re-entry + test('Case AK: root Club piggyback mutation rejected during re-entry', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_ak', 'open'); + await seedTombstone(db, 'club_reentry_ak', 'bob', 'left'); + }); + + // 1. Bob attempts to piggyback name change with memberCount increment -> DENY + const nameBatch = writeBatch(bob); + nameBatch.set(doc(bob, 'clubs/club_reentry_ak/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ak', + status: 'active', + role: 'member', + }); + nameBatch.update(doc(bob, 'clubs/club_reentry_ak'), { + name: 'Hijacked Club Name', + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(nameBatch.commit()); + + // 2. Bob attempts to piggyback visibility change with memberCount increment -> DENY + const visBatch = writeBatch(bob); + visBatch.set(doc(bob, 'clubs/club_reentry_ak/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ak', + status: 'active', + role: 'member', + }); + visBatch.update(doc(bob, 'clubs/club_reentry_ak'), { + visibility: 'private', + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(visBatch.commit()); + + // 3. Bob attempts to piggyback admissionPolicy change with memberCount increment -> DENY + const policyBatch = writeBatch(bob); + policyBatch.set(doc(bob, 'clubs/club_reentry_ak/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_ak', + status: 'active', + role: 'member', + }); + policyBatch.update(doc(bob, 'clubs/club_reentry_ak'), { + admissionPolicy: 'inviteOnly', + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(policyBatch.commit()); + }); + + // AL. Blocking parity: mutually blocked rider rejected on re-entry matching initial join + test('Case AL: blocking parity: mutually blocked rider rejected on re-entry matching initial join', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setupClub(db, 'club_reentry_al_open', 'open'); + await seedTombstone(db, 'club_reentry_al_open', 'bob', 'left'); + // Alice (owner) blocks Bob + await setDoc(doc(db, 'users/alice/blocks/bob'), { blockedUid: 'bob' }); + + await setupClub(db, 'club_reentry_al_req', 'request'); + await seedTombstone(db, 'club_reentry_al_req', 'bob', 'left'); + }); + + // 1. Open re-entry when blocked by owner -> DENY + const openBatch = writeBatch(bob); + openBatch.set(doc(bob, 'clubs/club_reentry_al_open/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_al_open', + status: 'active', + role: 'member', + }); + openBatch.update(doc(bob, 'clubs/club_reentry_al_open'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(openBatch.commit()); + + // 2. Request re-entry when blocked by owner -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_reentry_al_req/members/bob'), { + uid: 'bob', + clubId: 'club_reentry_al_req', + status: 'requested', + role: 'member', + })); + }); + }); }); }); diff --git a/firestore.rules b/firestore.rules index cf9e4bb..fb2dc4b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -853,6 +853,40 @@ service cloud.firestore { ) ); + function isValidOpenReentry() { + return request.auth.uid == memberUid + && resource.data.status in ['left', 'declined', 'removed'] + && request.resource.data.status == 'active' + && request.resource.data.role == 'member' + && request.resource.data.uid == memberUid + && request.resource.data.clubId == clubId + && !isBanned(memberUid) + && getClubDoc().data.admissionPolicy == 'open' + && getClubDoc().data.archivedAt == null + && !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid) + && request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'joinedAt', 'updatedAt']) + && (!('joinedAt' in request.resource.data) || request.resource.data.joinedAt is timestamp) + && (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp) + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount + 1; + } + + function isValidRequestReentry() { + return request.auth.uid == memberUid + && resource.data.status in ['left', 'declined', 'removed'] + && request.resource.data.status == 'requested' + && request.resource.data.role == 'member' + && request.resource.data.uid == memberUid + && request.resource.data.clubId == clubId + && !isBanned(memberUid) + && getClubDoc().data.admissionPolicy == 'request' + && getClubDoc().data.archivedAt == null + && !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid) + && request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'requestedAt', 'updatedAt']) + && (!('requestedAt' in request.resource.data) || request.resource.data.requestedAt is timestamp) + && (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp) + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount; + } + // Transitions allow update: if isRegisteredUser() && ( // Invitee accepts invite (invited -> active) @@ -876,6 +910,10 @@ service cloud.firestore { ) || // Requestor cancels request (requested -> left) (request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') || + // Re-entry to open club (left/declined/removed -> active) + isValidOpenReentry() || + // Re-entry request to request-policy club (left/declined/removed -> requested) + isValidRequestReentry() || // Admin/Owner approves request (requested -> active) ( isClubAdmin(request.auth.uid) diff --git a/lib/core/router.dart b/lib/core/router.dart index 8054d1e..e99db9c 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -21,7 +21,7 @@ import '../features/social/rider_profile_screen.dart'; import '../features/social/friends_screen.dart'; import '../features/social/clubs/clubs_hub_screen.dart'; import '../features/social/clubs/create_club_screen.dart'; -import '../features/social/clubs/club_preview_screen.dart'; +import '../features/social/clubs/club_detail_screen.dart'; import '../features/group_rides/group_rides_hub_screen.dart'; import '../features/group_rides/create_group_ride_screen.dart'; import '../features/group_rides/group_ride_detail_screen.dart'; @@ -175,7 +175,7 @@ final appRouter = GoRouter( path: ':id', builder: (context, state) { final id = state.pathParameters['id']!; - return ClubPreviewScreen(clubId: id); + return ClubDetailScreen(clubId: id); }, ), ], diff --git a/lib/features/social/clubs/club_detail_screen.dart b/lib/features/social/clubs/club_detail_screen.dart new file mode 100644 index 0000000..d91eb27 --- /dev/null +++ b/lib/features/social/clubs/club_detail_screen.dart @@ -0,0 +1,939 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/responsive/responsive_layout.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../core/theme/theme_extensions.dart'; +import '../../../services/auth/auth_providers.dart'; +import '../../../services/clubs/club_models.dart'; +import '../../../services/clubs/club_providers.dart'; +import 'club_membership_controller.dart'; +import 'club_membership_presentation.dart'; +import 'widgets/club_monogram.dart'; + +/// Authoritative detail and membership lifecycle screen for a MotorSignal Club. +/// Enables riders to discover, join, request admission, withdraw requests, +/// accept/decline invitations, and leave active memberships. +class ClubDetailScreen extends ConsumerWidget { + final String clubId; + + const ClubDetailScreen({ + super.key, + required this.clubId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.motorColors; + final theme = Theme.of(context); + final user = ref.watch(currentUserProvider); + final isAuthenticated = user != null && !user.isAnonymous; + final currentUserId = user?.uid; + + final clubAsync = ref.watch(clubDetailProvider(clubId)); + final memberAsync = ref.watch(currentMemberProvider(clubId)); + final actionState = ref.watch(clubMembershipControllerProvider(clubId)); + final isMutating = actionState.isSubmitting; + + // Listen for feedback snackbars + ref.listen(clubMembershipControllerProvider(clubId), (prev, next) { + if (next.error != null && next.error != prev?.error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(next.error!), + backgroundColor: colors.raceRed, + behavior: SnackBarBehavior.floating, + ), + ); + } + if (next.success != null && next.success != prev?.success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(next.success!), + backgroundColor: colors.launchGreen, + behavior: SnackBarBehavior.floating, + ), + ); + } + }); + + return Scaffold( + appBar: AppBar( + title: const Text( + 'Club', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SafeArea( + child: clubAsync.when( + data: (club) { + if (club == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield_outlined, size: 48, color: colors.warningAmber), + const SizedBox(height: 16), + const Text( + 'Club Not Found', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'This club may have been archived or does not exist.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => context.go('/social/clubs'), + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('Back to Clubs'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.surfaceContainerHigh, + foregroundColor: colors.cyberCyan, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: colors.outlineVariant), + ), + ), + ), + ], + ), + ), + ); + } + + if (club.isArchived) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.archive_outlined, size: 48, color: colors.warningAmber), + const SizedBox(height: 16), + const Text( + 'Club Archived', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'This club has been archived by leadership and is no longer active.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => context.go('/social/clubs'), + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('Back to Clubs'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.surfaceContainerHigh, + foregroundColor: colors.cyberCyan, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: colors.outlineVariant), + ), + ), + ), + ], + ), + ), + ); + } + + final myMembership = memberAsync.valueOrNull; + + return ResponsiveLayout( + builder: (context, responsive) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 750), + child: SingleChildScrollView( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Club Hero Identity Card + _buildHeroCard(context, club, myMembership, colors, theme), + const SizedBox(height: 16), + + // Interactive Membership Lifecycle Action Card + _buildMembershipActionCard( + context, + ref, + club, + memberAsync, + currentUserId, + isAuthenticated, + isMutating, + colors, + theme, + ), + const SizedBox(height: 18), + + // Description Card + _buildDescriptionCard(context, club, colors, theme), + const SizedBox(height: 18), + + // Metadata Card (Region, Focus, Dates) + _buildMetadataCard(context, club, colors, theme), + ], + ), + ), + ), + ); + }, + ); + }, + loading: () => Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(colors.cyberCyan), + ), + ), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield_outlined, size: 36, color: colors.warningAmber), + const SizedBox(height: 12), + const Text( + 'Club Unavailable', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Unable to display this club at this time.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => context.go('/social/clubs'), + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('Back to Clubs'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.surfaceContainerHigh, + foregroundColor: colors.cyberCyan, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: colors.outlineVariant), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildHeroCard( + BuildContext context, + Club club, + ClubMember? member, + MotorSignalThemeColors colors, + ThemeData theme, + ) { + return Container( + padding: const EdgeInsets.all(20.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colors.outlineVariant, width: 0.8), + ), + child: Column( + children: [ + ClubMonogram(name: club.name, size: 68, fontSize: 26), + const SizedBox(height: 14), + Text( + club.name, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + '@${club.slug}', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 14, + fontWeight: FontWeight.w600, + color: colors.cyberCyan, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 6, + alignment: WrapAlignment.center, + children: [ + _buildPill( + icon: Icons.people_outline, + label: '${club.memberCount} ${club.memberCount == 1 ? 'member' : 'members'}', + colors: colors, + theme: theme, + ), + _buildPill( + icon: club.admissionPolicy == ClubAdmissionPolicy.open + ? Icons.lock_open_outlined + : Icons.how_to_reg_outlined, + label: club.admissionPolicy.label, + colors: colors, + theme: theme, + tintColor: colors.cyberCyan, + ), + _buildPill( + icon: club.visibility == ClubVisibility.public + ? Icons.public + : Icons.visibility_off_outlined, + label: club.visibility.label, + colors: colors, + theme: theme, + ), + if (member != null && + member.isActive && + (member.role == ClubRole.owner || member.role == ClubRole.admin)) + _buildPill( + icon: Icons.verified_user_outlined, + label: 'ROLE: ${member.role.label.toUpperCase()}', + colors: colors, + theme: theme, + tintColor: colors.launchGreen, + ), + ], + ), + ], + ), + ); + } + + Widget _buildMembershipActionCard( + BuildContext context, + WidgetRef ref, + Club club, + AsyncValue memberAsync, + String? currentUserId, + bool isAuthenticated, + bool isMutating, + MotorSignalThemeColors colors, + ThemeData theme, + ) { + if (isAuthenticated && memberAsync.isLoading) { + return Container( + padding: const EdgeInsets.all(18.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.outlineVariant, width: 0.8), + ), + child: Row( + children: [ + SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.cyberCyan), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Checking Membership Status...', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Verifying status with club registry.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } + + if (isAuthenticated && memberAsync.hasError) { + return Container( + padding: const EdgeInsets.all(18.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.warningAmber.withValues(alpha: 0.6), width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning_amber_rounded, size: 18, color: colors.warningAmber), + const SizedBox(width: 8), + const Text( + 'Membership Status Unavailable', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Could not load your membership record. Check connection and retry.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => ref.invalidate(currentMemberProvider(club.id)), + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Retry'), + style: OutlinedButton.styleFrom( + foregroundColor: colors.cyberCyan, + side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + ), + ), + ], + ), + ); + } + + final myMembership = memberAsync.valueOrNull; + final presentation = ClubMembershipPresentation.resolve( + club: club, + member: myMembership, + currentUserId: currentUserId, + isAuthenticated: isAuthenticated, + ); + + Color badgeBgColor; + Color badgeTextColor; + + switch (presentation.badgeType) { + case PresentationBadgeType.green: + badgeBgColor = colors.launchGreen.withValues(alpha: 0.15); + badgeTextColor = colors.launchGreen; + break; + case PresentationBadgeType.amber: + badgeBgColor = colors.warningAmber.withValues(alpha: 0.15); + badgeTextColor = colors.warningAmber; + break; + case PresentationBadgeType.cyan: + badgeBgColor = colors.cyberCyan.withValues(alpha: 0.15); + badgeTextColor = colors.cyberCyan; + break; + case PresentationBadgeType.neutral: + badgeBgColor = colors.surfaceContainerHighest.withValues(alpha: 0.5); + badgeTextColor = theme.colorScheme.onSurfaceVariant; + break; + } + + return Container( + padding: const EdgeInsets.all(18.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: presentation.badgeType == PresentationBadgeType.amber + ? colors.warningAmber.withValues(alpha: 0.6) + : colors.outlineVariant, + width: 0.8, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: badgeBgColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: badgeTextColor.withValues(alpha: 0.6), width: 0.8), + ), + child: Text( + presentation.statusBadge, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 11, + fontWeight: FontWeight.w700, + color: badgeTextColor, + letterSpacing: 0.4, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + presentation.statusHeadline, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + presentation.statusDescription, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12.5, + color: theme.colorScheme.onSurfaceVariant, + height: 1.35, + ), + ), + const SizedBox(height: 14), + if (isMutating) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.cyberCyan), + ), + ), + ), + ) + else ...[ + if (presentation.state == ClubMembershipPresentationState.invited) + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + ref + .read(clubMembershipControllerProvider(club.id).notifier) + .declineInvitation(club.id); + }, + style: OutlinedButton.styleFrom( + foregroundColor: colors.raceRed, + side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text( + 'Decline', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: () { + ref + .read(clubMembershipControllerProvider(club.id).notifier) + .acceptInvitation(club.id); + }, + style: FilledButton.styleFrom( + backgroundColor: colors.launchGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + icon: const Icon(Icons.check, size: 18), + label: const Text( + 'Accept', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ) + else if (presentation.primaryAction != null) + SizedBox( + width: double.infinity, + child: _buildActionButton( + context, + ref, + club, + presentation, + colors, + ), + ), + ], + ], + ), + ); + } + + Widget _buildActionButton( + BuildContext context, + WidgetRef ref, + Club club, + ClubMembershipPresentation presentation, + MotorSignalThemeColors colors, + ) { + final action = presentation.primaryAction!; + final label = presentation.primaryActionLabel ?? ''; + final icon = presentation.primaryActionIcon; + + if (action == ClubMembershipAction.signIn) { + return FilledButton.icon( + onPressed: () => context.push('/settings/account'), + style: FilledButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + icon: Icon(icon, size: 18), + label: Text( + label, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + if (action == ClubMembershipAction.leaveClub) { + return OutlinedButton.icon( + onPressed: () => _confirmLeaveClub(context, ref, club, colors), + style: OutlinedButton.styleFrom( + foregroundColor: colors.raceRed, + side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + icon: Icon(icon, size: 18), + label: Text( + label, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + if (action == ClubMembershipAction.withdrawRequest) { + return OutlinedButton.icon( + onPressed: () { + ref.read(clubMembershipControllerProvider(club.id).notifier).withdrawJoinRequest(club.id); + }, + style: OutlinedButton.styleFrom( + foregroundColor: colors.warningAmber, + side: BorderSide(color: colors.warningAmber.withValues(alpha: 0.6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + icon: Icon(icon, size: 18), + label: Text( + label, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + // Default: FilledButton (Join Club or Request to Join) + return FilledButton.icon( + onPressed: () { + if (action == ClubMembershipAction.joinOpen) { + ref.read(clubMembershipControllerProvider(club.id).notifier).joinOpenClub(club.id); + } else if (action == ClubMembershipAction.requestToJoin) { + ref.read(clubMembershipControllerProvider(club.id).notifier).requestToJoinClub(club.id); + } + }, + style: FilledButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + icon: Icon(icon, size: 18), + label: Text( + label, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + void _confirmLeaveClub( + BuildContext context, + WidgetRef ref, + Club club, + MotorSignalThemeColors colors, + ) { + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + scrollable: true, + title: Text('Leave ${club.name}?'), + content: const Text( + 'Are you sure you want to leave this club? You will lose access to member-only rides and updates.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: colors.raceRed, + foregroundColor: Colors.white, + ), + onPressed: () { + Navigator.of(dialogCtx).pop(); + ref.read(clubMembershipControllerProvider(club.id).notifier).leaveClub(club.id); + }, + child: const Text('Leave Club'), + ), + ], + ), + ); + } + + Widget _buildDescriptionCard( + BuildContext context, + Club club, + MotorSignalThemeColors colors, + ThemeData theme, + ) { + return Container( + padding: const EdgeInsets.all(18.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.outlineVariant, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'About the Club', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: colors.cyberCyan, + ), + ), + const SizedBox(height: 10), + Text( + club.description.isNotEmpty + ? club.description + : 'No club description provided by leadership.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 13.5, + color: club.description.isNotEmpty + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ); + } + + Widget _buildMetadataCard( + BuildContext context, + Club club, + MotorSignalThemeColors colors, + ThemeData theme, + ) { + return Container( + padding: const EdgeInsets.all(18.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.outlineVariant, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Club Details', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: colors.cyberCyan, + ), + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.location_on_outlined, + title: 'Home Region', + value: club.homeRegion?.trim().isNotEmpty == true ? club.homeRegion! : 'Global / Not Specified', + colors: colors, + theme: theme, + ), + const Divider(height: 18), + _buildDetailRow( + icon: Icons.two_wheeler_outlined, + title: 'Motorcycle Focus', + value: club.motorcycleFocus?.trim().isNotEmpty == true + ? club.motorcycleFocus! + : 'All Motorcycles & Disciplines', + colors: colors, + theme: theme, + ), + const Divider(height: 18), + _buildDetailRow( + icon: Icons.calendar_today_outlined, + title: 'Established', + value: '${club.createdAt.year}-${club.createdAt.month.toString().padLeft(2, '0')}-${club.createdAt.day.toString().padLeft(2, '0')}', + colors: colors, + theme: theme, + ), + ], + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String title, + required String value, + required MotorSignalThemeColors colors, + required ThemeData theme, + }) { + return Row( + children: [ + Icon(icon, size: 18, color: colors.cyberCyan), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 11.5, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildPill({ + required IconData icon, + required String label, + required MotorSignalThemeColors colors, + required ThemeData theme, + Color? tintColor, + }) { + final effectiveColor = tintColor ?? theme.colorScheme.onSurfaceVariant; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6), width: 0.6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: effectiveColor), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: effectiveColor, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/social/clubs/club_membership_controller.dart b/lib/features/social/clubs/club_membership_controller.dart new file mode 100644 index 0000000..6f0d80f --- /dev/null +++ b/lib/features/social/clubs/club_membership_controller.dart @@ -0,0 +1,242 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../services/auth/auth_providers.dart'; +import '../../../services/clubs/club_providers.dart'; +import '../../../services/clubs/club_service.dart'; + +/// State of active membership mutations (join, request, withdraw, accept, decline, leave). +@immutable +class ClubMembershipActionState { + final bool isSubmitting; + final String? error; + final String? success; + final String? activeClubId; + + const ClubMembershipActionState({ + this.isSubmitting = false, + this.error, + this.success, + this.activeClubId, + }); + + ClubMembershipActionState copyWith({ + bool? isSubmitting, + String? error, + String? success, + String? activeClubId, + bool clearError = false, + bool clearSuccess = false, + }) { + return ClubMembershipActionState( + isSubmitting: isSubmitting ?? this.isSubmitting, + error: clearError ? null : (error ?? this.error), + success: clearSuccess ? null : (success ?? this.success), + activeClubId: activeClubId ?? this.activeClubId, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClubMembershipActionState && + runtimeType == other.runtimeType && + isSubmitting == other.isSubmitting && + error == other.error && + success == other.success && + activeClubId == other.activeClubId; + + @override + int get hashCode => + isSubmitting.hashCode ^ + error.hashCode ^ + success.hashCode ^ + activeClubId.hashCode; +} + +/// Controller coordinating membership lifecycle operations. +/// Interacts with authoritative ClubService and synchronizes Riverpod streams. +class ClubMembershipController extends StateNotifier { + final Ref _ref; + final String clubId; + + ClubMembershipController(this._ref, this.clubId) + : super(ClubMembershipActionState(activeClubId: clubId)); + + void clearMessages() { + state = state.copyWith(clearError: true, clearSuccess: true); + } + + /// Direct self-join for open admission clubs. + Future joinOpenClub([String? targetClubId]) async { + final effectiveClubId = targetClubId ?? clubId; + return _performMutation( + clubId: effectiveClubId, + mutation: (service, uid) => service.joinOpenClub(effectiveClubId, callerUid: uid), + successMessage: 'Successfully joined club!', + onSuccessInvalidate: () { + _ref.invalidate(currentMemberProvider(effectiveClubId)); + _ref.invalidate(clubDetailProvider(effectiveClubId)); + _ref.invalidate(myClubsProvider); + }, + ); + } + + /// Request admission for request-policy clubs. + Future requestToJoinClub([String? targetClubId]) async { + final effectiveClubId = targetClubId ?? clubId; + return _performMutation( + clubId: effectiveClubId, + mutation: (service, uid) => service.requestToJoinClub(effectiveClubId, callerUid: uid), + successMessage: 'Join request submitted to club leadership.', + onSuccessInvalidate: () { + _ref.invalidate(currentMemberProvider(effectiveClubId)); + }, + ); + } + + /// Withdraw an existing pending request. + Future withdrawJoinRequest([String? targetClubId]) async { + final effectiveClubId = targetClubId ?? clubId; + return _performMutation( + clubId: effectiveClubId, + mutation: (service, uid) => service.withdrawJoinRequest(effectiveClubId, callerUid: uid), + successMessage: 'Join request withdrawn.', + onSuccessInvalidate: () { + _ref.invalidate(currentMemberProvider(effectiveClubId)); + }, + ); + } + + /// Accept an incoming leadership invitation. + Future acceptInvitation([String? targetClubId]) async { + final effectiveClubId = targetClubId ?? clubId; + return _performMutation( + clubId: effectiveClubId, + mutation: (service, uid) => + service.respondToInvitation(effectiveClubId, accept: true, callerUid: uid), + successMessage: 'Invitation accepted! Welcome to the club.', + onSuccessInvalidate: () { + _ref.invalidate(currentMemberProvider(effectiveClubId)); + _ref.invalidate(clubDetailProvider(effectiveClubId)); + _ref.invalidate(myClubsProvider); + _ref.invalidate(myClubInvitesProvider); + }, + ); + } + + /// Decline an incoming leadership invitation. + Future declineInvitation([String? targetClubId]) async { + final effectiveClubId = targetClubId ?? clubId; + return _performMutation( + clubId: effectiveClubId, + mutation: (service, uid) => + service.respondToInvitation(effectiveClubId, accept: false, callerUid: uid), + successMessage: 'Invitation declined.', + onSuccessInvalidate: () { + _ref.invalidate(currentMemberProvider(effectiveClubId)); + _ref.invalidate(myClubInvitesProvider); + }, + ); + } + + /// Voluntarily leave an active club membership. + /// Enforces invariant: Owner cannot leave. + Future leaveClub([String? targetClubId]) async { + final effectiveClubId = targetClubId ?? clubId; + return _performMutation( + clubId: effectiveClubId, + mutation: (service, uid) => service.leaveClub(effectiveClubId, callerUid: uid), + successMessage: 'You have left the club.', + onSuccessInvalidate: () { + _ref.invalidate(currentMemberProvider(effectiveClubId)); + _ref.invalidate(clubDetailProvider(effectiveClubId)); + _ref.invalidate(myClubsProvider); + }, + ); + } + + Future _performMutation({ + required String clubId, + required Future Function(ClubService service, String uid) mutation, + required String successMessage, + required VoidCallback onSuccessInvalidate, + }) async { + // Concurrency guard: reject duplicate or overlapping mutations in-flight + if (state.isSubmitting) { + return false; + } + + final user = _ref.read(currentUserProvider); + if (user == null || user.isAnonymous) { + state = state.copyWith( + error: 'You must be signed in with a registered account to perform this action.', + clearSuccess: true, + ); + return false; + } + + state = ClubMembershipActionState( + isSubmitting: true, + activeClubId: clubId, + ); + + try { + final service = _ref.read(clubServiceProvider); + await mutation(service, user.uid); + + // Perform minimal, surgical invalidations for this exact lifecycle transition + onSuccessInvalidate(); + + state = ClubMembershipActionState( + isSubmitting: false, + success: successMessage, + activeClubId: clubId, + ); + return true; + } on ClubOperationException catch (e) { + String message; + switch (e.code) { + case 'banned': + message = 'Membership is unavailable for this club.'; + break; + case 'owner-cannot-leave': + message = 'Club owner cannot leave without transferring ownership first.'; + break; + case 'policy-mismatch': + message = 'This club does not allow open joining. Please submit a request.'; + break; + case 'not-found': + message = 'Club or membership record was not found.'; + break; + case 'unavailable': + message = 'Club services are temporarily unavailable. Please try again later.'; + break; + default: + message = e.message.isNotEmpty + ? e.message + : 'Unable to update club membership right now. Please try again.'; + break; + } + + state = ClubMembershipActionState( + isSubmitting: false, + error: message, + activeClubId: clubId, + ); + return false; + } catch (e) { + state = ClubMembershipActionState( + isSubmitting: false, + error: 'Unable to update club membership right now. Please try again.', + activeClubId: clubId, + ); + return false; + } + } +} + +/// Family provider for ClubMembershipController scoped per club. +final clubMembershipControllerProvider = StateNotifierProvider.autoDispose + .family((ref, clubId) { + return ClubMembershipController(ref, clubId); +}); diff --git a/lib/features/social/clubs/club_membership_presentation.dart b/lib/features/social/clubs/club_membership_presentation.dart new file mode 100644 index 0000000..be3c965 --- /dev/null +++ b/lib/features/social/clubs/club_membership_presentation.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import '../../../services/clubs/club_models.dart'; + +/// Semantic state representing the viewer's relationship to a Club. +enum ClubMembershipPresentationState { + unauthenticated, + outsiderOpen, + outsiderRequest, + outsiderInviteOnly, + invited, + requested, + activeMember, + activeAdmin, + activeOwner, + archived, + unavailable, +} + +/// Action intent triggered by the rider on a Club. +enum ClubMembershipAction { + joinOpen, + requestToJoin, + withdrawRequest, + acceptInvite, + declineInvite, + leaveClub, + signIn, +} + +/// Badge color categorization for automotive UI styling. +enum PresentationBadgeType { + cyan, + green, + amber, + neutral, +} + +/// Pure presentation model mapping Club metadata, membership state, and auth context +/// into authoritative, unambiguous UX components. +@immutable +class ClubMembershipPresentation { + final ClubMembershipPresentationState state; + final String statusBadge; + final PresentationBadgeType badgeType; + final String statusHeadline; + final String statusDescription; + final ClubMembershipAction? primaryAction; + final String? primaryActionLabel; + final IconData? primaryActionIcon; + final bool isPrimaryDestructive; + final ClubMembershipAction? secondaryAction; + final String? secondaryActionLabel; + final IconData? secondaryActionIcon; + final bool isSecondaryDestructive; + final bool canLeave; + final bool isActionable; + + const ClubMembershipPresentation({ + required this.state, + required this.statusBadge, + required this.badgeType, + required this.statusHeadline, + required this.statusDescription, + this.primaryAction, + this.primaryActionLabel, + this.primaryActionIcon, + this.isPrimaryDestructive = false, + this.secondaryAction, + this.secondaryActionLabel, + this.secondaryActionIcon, + this.isSecondaryDestructive = false, + this.canLeave = false, + this.isActionable = false, + }); + + /// Resolves the pure presentation state from domain models and auth context. + static ClubMembershipPresentation resolve({ + required Club? club, + required ClubMember? member, + required String? currentUserId, + required bool isAuthenticated, + }) { + if (club == null) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.unavailable, + statusBadge: 'UNAVAILABLE', + badgeType: PresentationBadgeType.neutral, + statusHeadline: 'Club Not Found', + statusDescription: 'This club could not be loaded or does not exist.', + isActionable: false, + ); + } + + if (club.isArchived) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.archived, + statusBadge: 'ARCHIVED', + badgeType: PresentationBadgeType.amber, + statusHeadline: 'Club Archived', + statusDescription: + 'This club has been archived by leadership and is no longer accepting members.', + canLeave: false, + isActionable: false, + ); + } + + if (!isAuthenticated || currentUserId == null || currentUserId.isEmpty) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.unauthenticated, + statusBadge: 'GUEST', + badgeType: PresentationBadgeType.neutral, + statusHeadline: 'Sign in to Join', + statusDescription: + 'Sign in with a registered account to join this club and ride with members.', + primaryAction: ClubMembershipAction.signIn, + primaryActionLabel: 'Sign In / Register', + primaryActionIcon: Icons.login, + isActionable: true, + ); + } + + // Authenticated user with an active membership + if (member != null && member.status == ClubMemberStatus.active) { + final isOwner = member.role == ClubRole.owner || club.ownerUid == currentUserId; + if (isOwner) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.activeOwner, + statusBadge: 'OWNER', + badgeType: PresentationBadgeType.green, + statusHeadline: 'Club Owner', + statusDescription: + 'You are the owner of this club. Ownership must be transferred before leaving.', + canLeave: false, + isActionable: false, + ); + } + + if (member.role == ClubRole.admin) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.activeAdmin, + statusBadge: 'ADMIN', + badgeType: PresentationBadgeType.cyan, + statusHeadline: 'Club Administrator', + statusDescription: 'You are an active administrator of this club.', + primaryAction: ClubMembershipAction.leaveClub, + primaryActionLabel: 'Leave Club', + primaryActionIcon: Icons.logout, + isPrimaryDestructive: true, + canLeave: true, + isActionable: true, + ); + } + + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.activeMember, + statusBadge: 'MEMBER', + badgeType: PresentationBadgeType.cyan, + statusHeadline: 'Active Member', + statusDescription: 'You are an active member of this club.', + primaryAction: ClubMembershipAction.leaveClub, + primaryActionLabel: 'Leave Club', + primaryActionIcon: Icons.logout, + isPrimaryDestructive: true, + canLeave: true, + isActionable: true, + ); + } + + // Authenticated user with an incoming invitation + if (member != null && member.status == ClubMemberStatus.invited) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.invited, + statusBadge: 'INVITED', + badgeType: PresentationBadgeType.green, + statusHeadline: 'You Are Invited', + statusDescription: 'You have been invited to join this club by club leadership.', + primaryAction: ClubMembershipAction.acceptInvite, + primaryActionLabel: 'Accept Invitation', + primaryActionIcon: Icons.check_circle_outline, + secondaryAction: ClubMembershipAction.declineInvite, + secondaryActionLabel: 'Decline', + secondaryActionIcon: Icons.close, + isSecondaryDestructive: true, + isActionable: true, + ); + } + + // Authenticated user with a pending join request + if (member != null && member.status == ClubMemberStatus.requested) { + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.requested, + statusBadge: 'REQUEST PENDING', + badgeType: PresentationBadgeType.amber, + statusHeadline: 'Membership Requested', + statusDescription: 'Your request to join is awaiting approval from club leadership.', + primaryAction: ClubMembershipAction.withdrawRequest, + primaryActionLabel: 'Withdraw Request', + primaryActionIcon: Icons.cancel_outlined, + isPrimaryDestructive: true, + isActionable: true, + ); + } + + // Outsider (no member doc or historical doc: left, declined, removed) + switch (club.admissionPolicy) { + case ClubAdmissionPolicy.open: + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.outsiderOpen, + statusBadge: 'OPEN ADMISSION', + badgeType: PresentationBadgeType.cyan, + statusHeadline: 'Open Membership', + statusDescription: 'This club is open to all riders. Join now to participate in club rides.', + primaryAction: ClubMembershipAction.joinOpen, + primaryActionLabel: 'Join Club', + primaryActionIcon: Icons.group_add, + isActionable: true, + ); + + case ClubAdmissionPolicy.request: + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.outsiderRequest, + statusBadge: 'REQUEST REQUIRED', + badgeType: PresentationBadgeType.cyan, + statusHeadline: 'Request Admission', + statusDescription: 'Membership requires review and approval from club leadership.', + primaryAction: ClubMembershipAction.requestToJoin, + primaryActionLabel: 'Request to Join', + primaryActionIcon: Icons.send_outlined, + isActionable: true, + ); + + case ClubAdmissionPolicy.inviteOnly: + return const ClubMembershipPresentation( + state: ClubMembershipPresentationState.outsiderInviteOnly, + statusBadge: 'INVITE ONLY', + badgeType: PresentationBadgeType.neutral, + statusHeadline: 'Invite-Only Club', + statusDescription: + 'This club is invite-only. You must receive an invitation from leadership to join.', + isActionable: false, + ); + } + } +} diff --git a/lib/features/social/clubs/club_preview_screen.dart b/lib/features/social/clubs/club_preview_screen.dart index 236c88b..f1a51dc 100644 --- a/lib/features/social/clubs/club_preview_screen.dart +++ b/lib/features/social/clubs/club_preview_screen.dart @@ -1,433 +1,6 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/responsive/responsive_layout.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../../core/theme/theme_extensions.dart'; -import '../../../services/clubs/club_models.dart'; -import '../../../services/clubs/club_providers.dart'; -import 'widgets/club_monogram.dart'; +import 'club_detail_screen.dart'; -/// Read-only preview destination for a MotorSignal Club. -/// Displays authoritative identity and metadata without CLUB-2C membership mutations. -class ClubPreviewScreen extends ConsumerWidget { - final String clubId; +export 'club_detail_screen.dart'; - const ClubPreviewScreen({ - super.key, - required this.clubId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final colors = context.motorColors; - final theme = Theme.of(context); - final clubAsync = ref.watch(clubDetailProvider(clubId)); - final memberAsync = ref.watch(currentMemberProvider(clubId)); - - return Scaffold( - appBar: AppBar( - title: const Text( - 'Club', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontWeight: FontWeight.w700, - ), - ), - ), - body: SafeArea( - child: clubAsync.when( - data: (club) { - if (club == null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.shield_outlined, size: 48, color: colors.warningAmber), - const SizedBox(height: 16), - const Text( - 'Club Not Found', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'This club may have been archived or does not exist.', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 13, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - if (club.isArchived) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.archive_outlined, size: 48, color: colors.warningAmber), - const SizedBox(height: 16), - const Text( - 'Club Archived', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'This club has been archived by leadership and is no longer active.', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 13, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - final myMembership = memberAsync.value; - - return ResponsiveLayout( - builder: (context, responsive) { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 750), - child: SingleChildScrollView( - padding: const EdgeInsets.all(18.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Club Hero Identity Card - _buildHeroCard(context, club, myMembership, colors, theme), - const SizedBox(height: 18), - - // Description Card - _buildDescriptionCard(context, club, colors, theme), - const SizedBox(height: 18), - - // Metadata Card (Region, Focus, Dates) - _buildMetadataCard(context, club, colors, theme), - ], - ), - ), - ), - ); - }, - ); - }, - loading: () => Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(colors.cyberCyan), - ), - ), - error: (err, _) => Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.shield_outlined, size: 36, color: colors.warningAmber), - const SizedBox(height: 12), - const Text( - 'Club Unavailable', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'Unable to display this club at this time.', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 13, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildHeroCard( - BuildContext context, - Club club, - ClubMember? member, - MotorSignalThemeColors colors, - ThemeData theme, - ) { - return Container( - padding: const EdgeInsets.all(20.0), - decoration: BoxDecoration( - color: colors.surfaceContainer, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colors.outlineVariant, width: 0.8), - ), - child: Column( - children: [ - ClubMonogram(name: club.name, size: 68, fontSize: 26), - const SizedBox(height: 14), - Text( - club.name, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 20, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - Text( - '@${club.slug}', - style: TextStyle( - fontFamily: AppTypography.fontTelemetry, - fontSize: 14, - fontWeight: FontWeight.w600, - color: colors.cyberCyan, - ), - ), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 6, - alignment: WrapAlignment.center, - children: [ - _buildPill( - icon: Icons.people_outline, - label: '${club.memberCount} ${club.memberCount == 1 ? 'member' : 'members'}', - colors: colors, - theme: theme, - ), - _buildPill( - icon: club.admissionPolicy == ClubAdmissionPolicy.open - ? Icons.lock_open_outlined - : Icons.how_to_reg_outlined, - label: club.admissionPolicy.label, - colors: colors, - theme: theme, - tintColor: colors.cyberCyan, - ), - _buildPill( - icon: club.visibility == ClubVisibility.public - ? Icons.public - : Icons.visibility_off_outlined, - label: club.visibility.label, - colors: colors, - theme: theme, - ), - if (member != null && - member.isActive && - (member.role == ClubRole.owner || member.role == ClubRole.admin)) - _buildPill( - icon: Icons.verified_user_outlined, - label: 'ROLE: ${member.role.label.toUpperCase()}', - colors: colors, - theme: theme, - tintColor: colors.launchGreen, - ), - ], - ), - ], - ), - ); - } - - Widget _buildDescriptionCard( - BuildContext context, - Club club, - MotorSignalThemeColors colors, - ThemeData theme, - ) { - return Container( - padding: const EdgeInsets.all(18.0), - decoration: BoxDecoration( - color: colors.surfaceContainer, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: colors.outlineVariant, width: 0.8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'About the Club', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - color: colors.cyberCyan, - ), - ), - const SizedBox(height: 10), - Text( - club.description.isNotEmpty - ? club.description - : 'No club description provided by leadership.', - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 13.5, - color: club.description.isNotEmpty - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, - height: 1.4, - ), - ), - ], - ), - ); - } - - Widget _buildMetadataCard( - BuildContext context, - Club club, - MotorSignalThemeColors colors, - ThemeData theme, - ) { - return Container( - padding: const EdgeInsets.all(18.0), - decoration: BoxDecoration( - color: colors.surfaceContainer, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: colors.outlineVariant, width: 0.8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Club Details', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.2, - color: colors.cyberCyan, - ), - ), - const SizedBox(height: 12), - _buildDetailRow( - icon: Icons.location_on_outlined, - title: 'Home Region', - value: club.homeRegion?.trim().isNotEmpty == true ? club.homeRegion! : 'Global / Not Specified', - colors: colors, - theme: theme, - ), - const Divider(height: 18), - _buildDetailRow( - icon: Icons.two_wheeler_outlined, - title: 'Motorcycle Focus', - value: club.motorcycleFocus?.trim().isNotEmpty == true - ? club.motorcycleFocus! - : 'All Motorcycles & Disciplines', - colors: colors, - theme: theme, - ), - const Divider(height: 18), - _buildDetailRow( - icon: Icons.calendar_today_outlined, - title: 'Established', - value: '${club.createdAt.year}-${club.createdAt.month.toString().padLeft(2, '0')}-${club.createdAt.day.toString().padLeft(2, '0')}', - colors: colors, - theme: theme, - ), - ], - ), - ); - } - - Widget _buildDetailRow({ - required IconData icon, - required String title, - required String value, - required MotorSignalThemeColors colors, - required ThemeData theme, - }) { - return Row( - children: [ - Icon(icon, size: 18, color: colors.cyberCyan), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 11.5, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Text( - value, - style: const TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 13.5, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildPill({ - required IconData icon, - required String label, - required MotorSignalThemeColors colors, - required ThemeData theme, - Color? tintColor, - }) { - final effectiveColor = tintColor ?? theme.colorScheme.onSurfaceVariant; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6), width: 0.6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: effectiveColor), - const SizedBox(width: 5), - Text( - label, - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 11.5, - fontWeight: FontWeight.w600, - color: effectiveColor, - ), - ), - ], - ), - ); - } -} +/// Backwards compatibility alias for ClubDetailScreen. +typedef ClubPreviewScreen = ClubDetailScreen; diff --git a/lib/features/social/clubs/clubs_hub_screen.dart b/lib/features/social/clubs/clubs_hub_screen.dart index 2371496..f07bb0b 100644 --- a/lib/features/social/clubs/clubs_hub_screen.dart +++ b/lib/features/social/clubs/clubs_hub_screen.dart @@ -9,6 +9,7 @@ import '../../../services/clubs/club_models.dart'; import '../../../services/clubs/club_providers.dart'; import '../../../services/clubs/club_slug_validator.dart'; import 'widgets/club_card.dart'; +import 'widgets/club_invitation_card.dart'; import 'widgets/club_member_card.dart'; /// Authoritative Hub for MotorSignal Clubs. @@ -248,36 +249,20 @@ class _ClubsHubScreenState extends ConsumerState Widget _buildMyClubsTab(BuildContext context, MotorSignalThemeColors colors) { final myClubsAsync = ref.watch(myClubsProvider); + final myInvitesAsync = ref.watch(myClubInvitesProvider); - return myClubsAsync.when( - data: (memberships) { - if (memberships.isEmpty) { - return _buildEmptyMyClubs(context, colors); - } - - return RefreshIndicator( - onRefresh: () async { - ref.invalidate(myClubsProvider); - }, - child: ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: memberships.length, - itemBuilder: (context, index) { - final member = memberships[index]; - return ClubMemberCard( - key: ValueKey('my_club_${member.clubId}'), - member: member, - ); - }, - ), - ); - }, - loading: () => Center( + // If both are loading, show loading spinner + if (myClubsAsync.isLoading && myInvitesAsync.isLoading) { + return Center( child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(colors.cyberCyan), ), - ), - error: (err, _) => Center( + ); + } + + // If both failed, show error + if (myClubsAsync.hasError && myInvitesAsync.hasError) { + return Center( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( @@ -294,12 +279,97 @@ class _ClubsHubScreenState extends ConsumerState ), const SizedBox(height: 12), OutlinedButton( - onPressed: () => ref.invalidate(myClubsProvider), + onPressed: () { + ref.invalidate(myClubsProvider); + ref.invalidate(myClubInvitesProvider); + }, child: const Text('Retry'), ), ], ), ), + ); + } + + final memberships = myClubsAsync.value ?? const []; + final invites = myInvitesAsync.value ?? const []; + + if (memberships.isEmpty && invites.isEmpty) { + return _buildEmptyMyClubs(context, colors); + } + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(myClubsProvider); + ref.invalidate(myClubInvitesProvider); + }, + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + if (invites.isNotEmpty) ...[ + Row( + children: [ + Text( + 'CLUB INVITATIONS', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 12.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + color: colors.launchGreen, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colors.launchGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${invites.length}', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 11, + fontWeight: FontWeight.w700, + color: colors.launchGreen, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + for (final invite in invites) + ClubInvitationCard( + key: ValueKey('invite_${invite.clubId}'), + member: invite, + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 12), + ], + if (memberships.isNotEmpty) ...[ + if (invites.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: Text( + 'ACTIVE MEMBERSHIPS', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 12.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + color: colors.cyberCyan, + ), + ), + ), + for (final member in memberships) + ClubMemberCard( + key: ValueKey('my_club_${member.clubId}'), + member: member, + ), + ], + ], ), ); } diff --git a/lib/features/social/clubs/widgets/club_invitation_card.dart b/lib/features/social/clubs/widgets/club_invitation_card.dart new file mode 100644 index 0000000..c649005 --- /dev/null +++ b/lib/features/social/clubs/widgets/club_invitation_card.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/theme/theme_extensions.dart'; +import '../../../../services/clubs/club_models.dart'; +import '../../../../services/clubs/club_providers.dart'; +import '../club_membership_controller.dart'; +import 'club_monogram.dart'; + +/// Automotive card presenting a pending Club invitation in the My Clubs hub. +/// Provides immediate inline accept and decline actions and full preview on tap. +class ClubInvitationCard extends ConsumerWidget { + final ClubMember member; + + const ClubInvitationCard({ + super.key, + required this.member, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.motorColors; + final theme = Theme.of(context); + final clubAsync = ref.watch(clubDetailProvider(member.clubId)); + final actionState = ref.watch(clubMembershipControllerProvider(member.clubId)); + final isMutating = actionState.isSubmitting; + + return Container( + margin: const EdgeInsets.only(bottom: 12.0), + decoration: BoxDecoration( + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colors.launchGreen.withValues(alpha: 0.5), width: 1.0), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => context.push('/social/clubs/${member.clubId}'), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: clubAsync.when( + data: (club) { + if (club == null) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClubMonogram(name: club.name, size: 42, fontSize: 16), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + club.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '@${club.slug}', + style: TextStyle( + fontFamily: AppTypography.fontTelemetry, + fontSize: 12.5, + color: colors.cyberCyan, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colors.launchGreen.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colors.launchGreen, width: 0.8), + ), + child: Text( + 'INVITED', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: colors.launchGreen, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'You were invited to join this motorcycle club.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12.5, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + if (isMutating) + Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.cyberCyan), + ), + ), + ) + else + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + ref + .read(clubMembershipControllerProvider(member.clubId).notifier) + .declineInvitation(member.clubId); + }, + style: OutlinedButton.styleFrom( + foregroundColor: colors.raceRed, + side: BorderSide(color: colors.raceRed.withValues(alpha: 0.6)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + child: const Text( + 'Decline', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w600, + fontSize: 12.5, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: () { + ref + .read(clubMembershipControllerProvider(member.clubId).notifier) + .acceptInvitation(member.clubId); + }, + style: FilledButton.styleFrom( + backgroundColor: colors.launchGreen, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + icon: const Icon(Icons.check, size: 16), + label: const Text( + 'Accept', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontWeight: FontWeight.w700, + fontSize: 12.5, + ), + ), + ), + ), + ], + ), + ], + ); + }, + loading: () => Center( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(colors.cyberCyan), + ), + ), + ), + ), + error: (_, _) => const SizedBox.shrink(), + ), + ), + ), + ), + ); + } +} diff --git a/test/club_detail_screen_test.dart b/test/club_detail_screen_test.dart new file mode 100644 index 0000000..93e3f2a --- /dev/null +++ b/test/club_detail_screen_test.dart @@ -0,0 +1,502 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/clubs/club_models.dart'; +import 'package:motorcycle_dash/services/clubs/club_providers.dart'; +import 'package:motorcycle_dash/services/clubs/club_service.dart'; + +class MockClubServiceForDetail extends ClubService { + String? lastAction; + String? lastClubId; + bool? lastAccept; + + @override + Future joinOpenClub(String clubId, {String? callerUid}) async { + lastAction = 'joinOpen'; + lastClubId = clubId; + } + + @override + Future requestToJoinClub(String clubId, {String? callerUid}) async { + lastAction = 'requestToJoin'; + lastClubId = clubId; + } + + @override + Future withdrawJoinRequest(String clubId, {String? callerUid}) async { + lastAction = 'withdrawRequest'; + lastClubId = clubId; + } + + @override + Future respondToInvitation(String clubId, {required bool accept, String? callerUid}) async { + lastAction = 'respondInvite'; + lastClubId = clubId; + lastAccept = accept; + } + + @override + Future leaveClub(String clubId, {String? callerUid}) async { + lastAction = 'leaveClub'; + lastClubId = clubId; + } +} + +void main() { + group('ClubDetailScreen Widget Lifecycle Tests', () { + late MockClubServiceForDetail mockService; + const testUser = AuthUser(uid: 'rider_1', isAnonymous: false); + + final openClub = Club( + id: 'club_open', + name: 'Open Track Riders', + slug: 'open-track', + description: 'Track day motorcycle community', + ownerUid: 'owner_bob', + memberCount: 12, + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.open, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final requestClub = Club( + id: 'club_request', + name: 'Sierra Twisties', + slug: 'sierra-twisties', + description: 'Mountain pass riding group', + ownerUid: 'owner_charlie', + memberCount: 5, + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.request, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final inviteClub = Club( + id: 'club_invite', + name: 'Elite Racers', + slug: 'elite-racers', + description: 'Strictly invite only', + ownerUid: 'owner_dan', + memberCount: 3, + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.inviteOnly, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + setUp(() { + mockService = MockClubServiceForDetail(); + }); + + testWidgets('renders Open Admission state and executes Join Club', (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('OPEN ADMISSION'), findsOneWidget); + expect(find.text('Open Membership'), findsOneWidget); + final joinButton = find.widgetWithText(FilledButton, 'Join Club'); + expect(joinButton, findsOneWidget); + + await tester.tap(joinButton); + await tester.pumpAndSettle(); + + expect(mockService.lastAction, 'joinOpen'); + expect(mockService.lastClubId, 'club_open'); + }); + + testWidgets('renders Request Admission state and executes Request to Join', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => testUser), + clubServiceProvider.overrideWithValue(mockService), + clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)), + currentMemberProvider('club_request').overrideWith((ref) => Stream.value(null)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_request'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('REQUEST REQUIRED'), findsOneWidget); + expect(find.text('Request Admission'), findsOneWidget); + final requestButton = find.widgetWithText(FilledButton, 'Request to Join'); + expect(requestButton, findsOneWidget); + + await tester.tap(requestButton); + await tester.pumpAndSettle(); + + expect(mockService.lastAction, 'requestToJoin'); + expect(mockService.lastClubId, 'club_request'); + }); + + testWidgets('renders Invite-Only state with no join action', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => testUser), + clubServiceProvider.overrideWithValue(mockService), + clubDetailProvider('club_invite').overrideWith((ref) => Stream.value(inviteClub)), + currentMemberProvider('club_invite').overrideWith((ref) => Stream.value(null)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_invite'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Invite-Only Club'), findsOneWidget); + expect(find.text('Join Club'), findsNothing); + expect(find.text('Request to Join'), findsNothing); + }); + + testWidgets('renders Invited state with Accept and Decline actions', (tester) async { + final inviteMember = ClubMember( + uid: 'rider_1', + clubId: 'club_request', + status: ClubMemberStatus.invited, + role: ClubRole.member, + updatedAt: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => testUser), + clubServiceProvider.overrideWithValue(mockService), + clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)), + currentMemberProvider('club_request').overrideWith((ref) => Stream.value(inviteMember)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_request'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('INVITED'), findsOneWidget); + expect(find.text('You Are Invited'), findsOneWidget); + expect(find.text('Accept'), findsOneWidget); + expect(find.text('Decline'), findsOneWidget); + + await tester.tap(find.text('Accept')); + await tester.pumpAndSettle(); + + expect(mockService.lastAction, 'respondInvite'); + expect(mockService.lastAccept, isTrue); + }); + + testWidgets('renders Requested state with Withdraw Request action', (tester) async { + final requestMember = ClubMember( + uid: 'rider_1', + clubId: 'club_request', + status: ClubMemberStatus.requested, + role: ClubRole.member, + requestedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => testUser), + clubServiceProvider.overrideWithValue(mockService), + clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)), + currentMemberProvider('club_request').overrideWith((ref) => Stream.value(requestMember)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_request'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('REQUEST PENDING'), findsOneWidget); + expect(find.text('Membership Requested'), findsOneWidget); + expect(find.text('Withdraw Request'), findsOneWidget); + + await tester.tap(find.text('Withdraw Request')); + await tester.pumpAndSettle(); + + expect(mockService.lastAction, 'withdrawRequest'); + }); + + testWidgets('renders Active Member state with Leave Club and confirmation dialog', (tester) async { + final activeMember = ClubMember( + uid: 'rider_1', + 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('MEMBER'), findsOneWidget); + expect(find.text('Active Member'), findsOneWidget); + expect(find.text('Leave Club'), findsOneWidget); + + // Tap Leave Club to open confirmation dialog + await tester.tap(find.text('Leave Club')); + await tester.pumpAndSettle(); + + expect(find.text('Leave Open Track Riders?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + + // Confirm leaving inside the dialog + final confirmButtonFinder = find.widgetWithText(FilledButton, 'Leave Club'); + expect(confirmButtonFinder, findsOneWidget); + await tester.tap(confirmButtonFinder); + await tester.pumpAndSettle(); + + expect(mockService.lastAction, 'leaveClub'); + expect(mockService.lastClubId, 'club_open'); + }); + + testWidgets('renders Owner state with ownership transfer constraint note and no leave button', (tester) async { + const ownerUser = AuthUser(uid: 'owner_bob', isAnonymous: false); + final ownerMember = ClubMember( + uid: 'owner_bob', + clubId: 'club_open', + status: ClubMemberStatus.active, + role: ClubRole.owner, + joinedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => ownerUser), + clubServiceProvider.overrideWithValue(mockService), + clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)), + currentMemberProvider('club_open').overrideWith((ref) => Stream.value(ownerMember)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_open'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Club Owner'), findsOneWidget); + expect(find.textContaining('Ownership must be transferred before leaving'), findsOneWidget); + expect(find.text('Leave Club'), findsNothing); + }); + + testWidgets('renders loading state and prevents action button clicks while membership is loading', (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) => const Stream.empty()), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_open'), + ), + ), + ); + + await tester.pump(); + + expect(find.text('Checking Membership Status...'), findsOneWidget); + expect(find.text('Join Club'), findsNothing); + expect(find.text('Request to Join'), findsNothing); + expect(find.text('Leave Club'), findsNothing); + }); + + testWidgets('renders retryable error state when membership stream errors', (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.error(Exception('Network error'))), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_open'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Membership Status Unavailable'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Join Club'), findsNothing); + expect(find.text('Leave Club'), findsNothing); + }); + + testWidgets('renders clean unavailable screen with back navigation on club stream error', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => testUser), + clubServiceProvider.overrideWithValue(mockService), + clubDetailProvider('private_club').overrideWith((ref) => Stream.error(Exception('permission-denied'))), + currentMemberProvider('private_club').overrideWith((ref) => const Stream.empty()), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'private_club'), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Club Unavailable'), findsOneWidget); + expect(find.text('Unable to display this club at this time.'), findsOneWidget); + expect(find.text('Back to Clubs'), findsOneWidget); + }); + + testWidgets('renders without overflow on portrait 412x915', (tester) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + 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(tester.takeException(), isNull); + + // Scroll to bottom + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -400)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders without overflow on landscape 915x412 including dialog', (tester) async { + tester.view.physicalSize = const Size(915, 412); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final activeMember = ClubMember( + uid: 'rider_1', + 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(tester.takeException(), isNull); + + // Open leave confirmation dialog + await tester.ensureVisible(find.text('Leave Club')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave Club')); + await tester.pumpAndSettle(); + + expect(find.text('Leave Open Track Riders?'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/club_membership_controller_test.dart b/test/club_membership_controller_test.dart new file mode 100644 index 0000000..d561b49 --- /dev/null +++ b/test/club_membership_controller_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/social/clubs/club_membership_controller.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/clubs/club_providers.dart'; +import 'package:motorcycle_dash/services/clubs/club_service.dart'; + +class FakeClubServiceForController extends ClubService { + String? lastAction; + String? lastClubId; + String? lastCallerUid; + bool? lastAccept; + bool shouldThrow = false; + String? throwCode; + String? throwMessage; + Future Function()? onJoinAsync; + + @override + Future joinOpenClub(String clubId, {String? callerUid}) async { + if (onJoinAsync != null) { + await onJoinAsync!(); + } + if (shouldThrow) { + throw ClubOperationException(throwMessage ?? 'Error', code: throwCode); + } + lastAction = 'joinOpen'; + lastClubId = clubId; + lastCallerUid = callerUid; + } + + @override + Future requestToJoinClub(String clubId, {String? callerUid}) async { + if (shouldThrow) { + throw ClubOperationException(throwMessage ?? 'Error', code: throwCode); + } + lastAction = 'requestToJoin'; + lastClubId = clubId; + lastCallerUid = callerUid; + } + + @override + Future withdrawJoinRequest(String clubId, {String? callerUid}) async { + if (shouldThrow) { + throw ClubOperationException(throwMessage ?? 'Error', code: throwCode); + } + lastAction = 'withdrawRequest'; + lastClubId = clubId; + lastCallerUid = callerUid; + } + + @override + Future respondToInvitation(String clubId, {required bool accept, String? callerUid}) async { + if (shouldThrow) { + throw ClubOperationException(throwMessage ?? 'Error', code: throwCode); + } + lastAction = 'respondInvite'; + lastClubId = clubId; + lastAccept = accept; + lastCallerUid = callerUid; + } + + @override + Future leaveClub(String clubId, {String? callerUid}) async { + if (shouldThrow) { + throw ClubOperationException(throwMessage ?? 'Error', code: throwCode); + } + lastAction = 'leaveClub'; + lastClubId = clubId; + lastCallerUid = callerUid; + } +} + +void main() { + group('ClubMembershipController Tests', () { + late FakeClubServiceForController fakeService; + late ProviderContainer container; + + setUp(() { + fakeService = FakeClubServiceForController(); + container = ProviderContainer( + overrides: [ + clubServiceProvider.overrideWithValue(fakeService), + currentUserProvider.overrideWith( + (ref) => const AuthUser(uid: 'user_rider_1', isAnonymous: false), + ), + ], + ); + }); + + tearDown(() { + container.dispose(); + }); + + test('joinOpenClub executes and transitions state to success', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.joinOpenClub('club_1'); + + expect(success, isTrue); + expect(fakeService.lastAction, 'joinOpen'); + expect(fakeService.lastClubId, 'club_1'); + expect(fakeService.lastCallerUid, 'user_rider_1'); + + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.isSubmitting, isFalse); + expect(state.success, 'Successfully joined club!'); + expect(state.error, isNull); + }); + + test('requestToJoinClub executes and transitions state to success', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.requestToJoinClub('club_1'); + + expect(success, isTrue); + expect(fakeService.lastAction, 'requestToJoin'); + expect(fakeService.lastClubId, 'club_1'); + + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.isSubmitting, isFalse); + expect(state.success, 'Join request submitted to club leadership.'); + expect(state.error, isNull); + }); + + test('withdrawJoinRequest executes and transitions state to success', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.withdrawJoinRequest('club_1'); + + expect(success, isTrue); + expect(fakeService.lastAction, 'withdrawRequest'); + + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.isSubmitting, isFalse); + expect(state.success, 'Join request withdrawn.'); + }); + + test('acceptInvitation executes and transitions state to success', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.acceptInvitation('club_1'); + + expect(success, isTrue); + expect(fakeService.lastAction, 'respondInvite'); + expect(fakeService.lastAccept, isTrue); + + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.success, contains('Welcome to the club')); + }); + + test('declineInvitation executes and transitions state to success', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.declineInvitation('club_1'); + + expect(success, isTrue); + expect(fakeService.lastAction, 'respondInvite'); + expect(fakeService.lastAccept, isFalse); + + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.success, 'Invitation declined.'); + }); + + test('leaveClub executes and transitions state to success', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.leaveClub('club_1'); + + expect(success, isTrue); + expect(fakeService.lastAction, 'leaveClub'); + + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.success, 'You have left the club.'); + }); + + test('fails gracefully when user is unauthenticated', () async { + final unauthContainer = ProviderContainer( + overrides: [ + clubServiceProvider.overrideWithValue(fakeService), + currentUserProvider.overrideWith((ref) => null), + ], + ); + + final controller = unauthContainer.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.joinOpenClub('club_1'); + + expect(success, isFalse); + final state = unauthContainer.read(clubMembershipControllerProvider('club_1')); + expect(state.error, contains('must be signed in')); + unauthContainer.dispose(); + }); + + test('maps banned error code to friendly generic message without leaking mod info', () async { + fakeService.shouldThrow = true; + fakeService.throwCode = 'banned'; + fakeService.throwMessage = 'User is in bans subcollection.'; + + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.joinOpenClub('club_1'); + + expect(success, isFalse); + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.error, 'Membership is unavailable for this club.'); + }); + + test('maps owner-cannot-leave error code properly', () async { + fakeService.shouldThrow = true; + fakeService.throwCode = 'owner-cannot-leave'; + fakeService.throwMessage = 'Owner cannot leave.'; + + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success = await controller.leaveClub('club_1'); + + expect(success, isFalse); + final state = container.read(clubMembershipControllerProvider('club_1')); + expect(state.error, contains('transferring ownership first')); + }); + + test('maps policy-mismatch, not-found, and unavailable error codes', () async { + fakeService.shouldThrow = true; + fakeService.throwCode = 'policy-mismatch'; + fakeService.throwMessage = 'Open join not allowed.'; + + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final success1 = await controller.joinOpenClub('club_1'); + expect(success1, isFalse); + expect(container.read(clubMembershipControllerProvider('club_1')).error, + 'This club does not allow open joining. Please submit a request.'); + + fakeService.throwCode = 'not-found'; + fakeService.throwMessage = 'Not found'; + final success2 = await controller.joinOpenClub('club_1'); + expect(success2, isFalse); + expect(container.read(clubMembershipControllerProvider('club_1')).error, + 'Club or membership record was not found.'); + + fakeService.throwCode = 'unavailable'; + fakeService.throwMessage = 'Firestore down'; + final success3 = await controller.joinOpenClub('club_1'); + expect(success3, isFalse); + expect(container.read(clubMembershipControllerProvider('club_1')).error, + 'Club services are temporarily unavailable. Please try again later.'); + }); + + test('rejects concurrent duplicate mutation while in-flight', () async { + final completer = Completer(); + fakeService.onJoinAsync = () => completer.future; + + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + final firstFuture = controller.joinOpenClub('club_1'); + expect(container.read(clubMembershipControllerProvider('club_1')).isSubmitting, isTrue); + + // Attempt second rapid mutation while first is still pending + final secondSuccess = await controller.joinOpenClub('club_1'); + expect(secondSuccess, isFalse); + + completer.complete(); + final firstSuccess = await firstFuture; + expect(firstSuccess, isTrue); + expect(container.read(clubMembershipControllerProvider('club_1')).isSubmitting, isFalse); + }); + + test('isolates controller state per club family instance', () async { + final controllerA = container.read(clubMembershipControllerProvider('club_A').notifier); + final controllerB = container.read(clubMembershipControllerProvider('club_B').notifier); + + await controllerA.joinOpenClub('club_A'); + await controllerB.requestToJoinClub('club_B'); + + expect(container.read(clubMembershipControllerProvider('club_A')).success, 'Successfully joined club!'); + expect(container.read(clubMembershipControllerProvider('club_B')).success, 'Join request submitted to club leadership.'); + }); + + test('clearMessages resets state', () async { + final controller = container.read(clubMembershipControllerProvider('club_1').notifier); + await controller.joinOpenClub('club_1'); + + expect(container.read(clubMembershipControllerProvider('club_1')).success, isNotNull); + + controller.clearMessages(); + final cleared = container.read(clubMembershipControllerProvider('club_1')); + expect(cleared.success, isNull); + expect(cleared.error, isNull); + }); + }); +} diff --git a/test/club_membership_presentation_test.dart b/test/club_membership_presentation_test.dart new file mode 100644 index 0000000..a7eede2 --- /dev/null +++ b/test/club_membership_presentation_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/social/clubs/club_membership_presentation.dart'; +import 'package:motorcycle_dash/services/clubs/club_models.dart'; + +void main() { + group('ClubMembershipPresentation Unit Tests', () { + final baseClub = Club( + id: 'test_club_1', + name: 'NorCal Adventurers', + slug: 'norcal-adv', + description: 'Adventure riding club', + ownerUid: 'owner_uid', + memberCount: 10, + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.open, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + test('resolves unavailable when club is null', () { + final p = ClubMembershipPresentation.resolve( + club: null, + member: null, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.unavailable); + expect(p.statusBadge, 'UNAVAILABLE'); + expect(p.isActionable, isFalse); + expect(p.primaryAction, isNull); + }); + + test('resolves archived state when club.isArchived is true', () { + final archivedClub = baseClub.copyWith(archivedAt: DateTime(2026, 2, 1)); + final p = ClubMembershipPresentation.resolve( + club: archivedClub, + member: null, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.archived); + expect(p.statusBadge, 'ARCHIVED'); + expect(p.badgeType, PresentationBadgeType.amber); + expect(p.canLeave, isFalse); + expect(p.isActionable, isFalse); + expect(p.primaryAction, isNull); + }); + + test('resolves unauthenticated state when signed out or anonymous', () { + final p1 = ClubMembershipPresentation.resolve( + club: baseClub, + member: null, + currentUserId: null, + isAuthenticated: false, + ); + expect(p1.state, ClubMembershipPresentationState.unauthenticated); + expect(p1.statusBadge, 'GUEST'); + expect(p1.primaryAction, ClubMembershipAction.signIn); + expect(p1.isActionable, isTrue); + + final p2 = ClubMembershipPresentation.resolve( + club: baseClub, + member: null, + currentUserId: '', + isAuthenticated: true, + ); + expect(p2.state, ClubMembershipPresentationState.unauthenticated); + }); + + test('resolves outsiderOpen for open admission when unjoined', () { + final p = ClubMembershipPresentation.resolve( + club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open), + member: null, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.outsiderOpen); + expect(p.statusBadge, 'OPEN ADMISSION'); + expect(p.badgeType, PresentationBadgeType.cyan); + expect(p.primaryAction, ClubMembershipAction.joinOpen); + expect(p.primaryActionLabel, 'Join Club'); + expect(p.canLeave, isFalse); + }); + + test('resolves outsiderRequest for request admission when unjoined', () { + final p = ClubMembershipPresentation.resolve( + club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.request), + member: null, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.outsiderRequest); + expect(p.statusBadge, 'REQUEST REQUIRED'); + expect(p.primaryAction, ClubMembershipAction.requestToJoin); + expect(p.primaryActionLabel, 'Request to Join'); + }); + + test('resolves outsiderInviteOnly for invite-only admission when unjoined', () { + final p = ClubMembershipPresentation.resolve( + club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.inviteOnly), + member: null, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.outsiderInviteOnly); + expect(p.statusBadge, 'INVITE ONLY'); + expect(p.primaryAction, isNull); + expect(p.isActionable, isFalse); + }); + + test('resolves historical states (left, declined, removed) as re-joinable outsiders', () { + for (final status in [ClubMemberStatus.left, ClubMemberStatus.declined, ClubMemberStatus.removed]) { + final member = ClubMember( + uid: 'rider_1', + clubId: baseClub.id, + status: status, + role: ClubRole.member, + updatedAt: DateTime(2026, 1, 1), + ); + + final p = ClubMembershipPresentation.resolve( + club: baseClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open), + member: member, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.outsiderOpen); + expect(p.primaryAction, ClubMembershipAction.joinOpen); + } + }); + + test('resolves invited state with accept and decline actions', () { + final invitedMember = ClubMember( + uid: 'rider_1', + clubId: baseClub.id, + status: ClubMemberStatus.invited, + role: ClubRole.member, + invitedByUid: 'owner_uid', + updatedAt: DateTime(2026, 1, 1), + ); + + final p = ClubMembershipPresentation.resolve( + club: baseClub, + member: invitedMember, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.invited); + expect(p.statusBadge, 'INVITED'); + expect(p.primaryAction, ClubMembershipAction.acceptInvite); + expect(p.primaryActionLabel, 'Accept Invitation'); + expect(p.secondaryAction, ClubMembershipAction.declineInvite); + expect(p.secondaryActionLabel, 'Decline'); + expect(p.isSecondaryDestructive, isTrue); + expect(p.canLeave, isFalse); + }); + + test('resolves requested state with withdraw request action', () { + final requestedMember = ClubMember( + uid: 'rider_1', + clubId: baseClub.id, + status: ClubMemberStatus.requested, + role: ClubRole.member, + requestedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final p = ClubMembershipPresentation.resolve( + club: baseClub, + member: requestedMember, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.requested); + expect(p.statusBadge, 'REQUEST PENDING'); + expect(p.badgeType, PresentationBadgeType.amber); + expect(p.primaryAction, ClubMembershipAction.withdrawRequest); + expect(p.primaryActionLabel, 'Withdraw Request'); + expect(p.isPrimaryDestructive, isTrue); + expect(p.canLeave, isFalse); + }); + + test('resolves activeMember state with leave club action', () { + final activeMember = ClubMember( + uid: 'rider_1', + clubId: baseClub.id, + status: ClubMemberStatus.active, + role: ClubRole.member, + joinedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final p = ClubMembershipPresentation.resolve( + club: baseClub, + member: activeMember, + currentUserId: 'rider_1', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.activeMember); + expect(p.statusBadge, 'MEMBER'); + expect(p.primaryAction, ClubMembershipAction.leaveClub); + expect(p.primaryActionLabel, 'Leave Club'); + expect(p.isPrimaryDestructive, isTrue); + expect(p.canLeave, isTrue); + }); + + test('resolves activeAdmin state with leave club action', () { + final activeAdmin = ClubMember( + uid: 'rider_admin', + clubId: baseClub.id, + status: ClubMemberStatus.active, + role: ClubRole.admin, + joinedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final p = ClubMembershipPresentation.resolve( + club: baseClub, + member: activeAdmin, + currentUserId: 'rider_admin', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.activeAdmin); + expect(p.statusBadge, 'ADMIN'); + expect(p.primaryAction, ClubMembershipAction.leaveClub); + expect(p.canLeave, isTrue); + }); + + test('resolves activeOwner state where owner cannot leave', () { + final activeOwner = ClubMember( + uid: 'owner_uid', + clubId: baseClub.id, + status: ClubMemberStatus.active, + role: ClubRole.owner, + joinedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final p = ClubMembershipPresentation.resolve( + club: baseClub, + member: activeOwner, + currentUserId: 'owner_uid', + isAuthenticated: true, + ); + + expect(p.state, ClubMembershipPresentationState.activeOwner); + expect(p.statusBadge, 'OWNER'); + expect(p.badgeType, PresentationBadgeType.green); + expect(p.primaryAction, isNull); + expect(p.canLeave, isFalse); + expect(p.isActionable, isFalse); + expect(p.statusDescription, contains('Ownership must be transferred')); + }); + }); +} diff --git a/test/clubs_hub_screen_test.dart b/test/clubs_hub_screen_test.dart index f0b6b43..8718043 100644 --- a/test/clubs_hub_screen_test.dart +++ b/test/clubs_hub_screen_test.dart @@ -170,5 +170,60 @@ void main() { expect(find.text('No club found with handle @unknown-club'), findsOneWidget); }); + + testWidgets('renders incoming invitations section in My Clubs tab and handles accept', (tester) async { + const user = AuthUser(uid: 'alice', isAnonymous: false); + final inviteClub = Club( + id: 'club_invite_1', + name: 'Bay Area Motos', + slug: 'bay-area-motos', + description: 'San Francisco & Bay Area motorcycle rides.', + ownerUid: 'bob', + memberCount: 15, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final inviteMember = ClubMember( + uid: 'alice', + clubId: 'club_invite_1', + status: ClubMemberStatus.invited, + role: ClubRole.member, + invitedByUid: 'bob', + updatedAt: DateTime(2026, 1, 1), + ); + + final fakeService = FakeClubServiceForHub( + clubs: {'club_invite_1': inviteClub}, + handles: {'bay-area-motos': 'club_invite_1'}, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => user), + clubServiceProvider.overrideWithValue(fakeService), + clubDetailProvider('club_invite_1').overrideWith((ref) => Stream.value(inviteClub)), + myClubsProvider.overrideWith((ref) => Stream.value(const [])), + myClubInvitesProvider.overrideWith((ref) => Stream.value([inviteMember])), + publicClubsProvider.overrideWith((ref) => Future.value([inviteClub])), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubsHubScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('CLUB INVITATIONS'), findsOneWidget); + expect(find.text('Bay Area Motos'), findsOneWidget); + expect(find.text('INVITED'), findsOneWidget); + expect(find.text('Accept'), findsOneWidget); + expect(find.text('Decline'), findsOneWidget); + }); }); }