diff --git a/.gitignore b/.gitignore index d460d5f..5c949b7 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,9 @@ devtools_options.yaml # Accidental Overpass API query downloads interpreter?data=* + +# Root-level dev utility scripts and one-off data files (not app code) +/test.py +/find_postboxes.js +/ciiir_postboxes.json +/postman_james.svg diff --git a/CLAUDE.md b/CLAUDE.md index 3a19c0b..c7c79a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Cross-platform (Flutter) mobile app + Firebase (Auth, Firestore, Cloud Functions - **Role**: Onboarding (first launch) and in-app advisor throughout the app. - **Placement**: Introduces the concept on first launch; appears in a strip/panel at the **bottom of the screen** on other screens, commenting on what the user is doing. - **Tone**: Light, British humour (like the advisor in Theme Park). -- **Current state**: `lib/intro.dart` now renders James as a **CustomPainter** (`_JamesPainter`) — navy body, red cap, skin-tone face, dot eyes or star-eyes (`showStarEyes: true`). No external asset; no flare_flutter dependency. The persistent "James at bottom commenting on actions" is not yet on all main screens — a `JamesHint` widget (small James + speech bubble) is planned but not yet placed on Nearby/Claim. +- **Current state**: James is rendered by `lib/postman_james_svg.dart` (`PostmanJamesSvg`) using the `assets/postman_james.svg` asset with animated overlays (head-bob, mouth open/close, blink, star-eyes). The persistent James strip (`lib/james_strip.dart`) slides up at the bottom of all main screens via `JamesController` / `JamesControllerScope` in `home.dart`. Messages are centralised in `lib/james_messages.dart`. Idle non-sequiturs fire every 2–5 minutes. ## Login before play @@ -31,10 +31,10 @@ The app shows a **fuzzy compass** that gives the user an **indication** of where ## Key paths - **App entry**: `lib/main.dart` → **if unauthenticated** → `_UnauthGate` → `Intro` (first run) or `LoginScreen`; **if authenticated** → `Home`. `Home` (`lib/home.dart`) is a `NavigationBar` + `IndexedStack` shell: tabs are **Nearby** (index 0), **Claim** (index 1), **Leaderboard** (index 2), **Friends** (index 3). Settings is in an AppBar `PopupMenuButton`. Named routes `/nearby`, `/Claim`, `/friends`, `/leaderboard`, `/settings` are retained for deep-link use. -- **Backend**: `functions/index.js` exports `nearbyPostboxes` and `startScoring`; `_lookupPostboxes.js` uses geohash + Firestore; `_getPoints.js` maps monarch (EIIR, GR, GVR, GVIR, VR, EVIIR, EVIIIR) to points. **New**: friends list and leaderboard data (Firestore: e.g. `users/{uid}/friends`, `leaderboards/daily|weekly|monthly` or aggregated in Cloud Functions). +- **Backend**: `functions/src/index.ts` exports `nearbyPostboxes`, `startScoring`, `updateDisplayName`, `onUserCreated`; `_lookupPostboxes.ts` uses ngeohash + Firestore geohash prefix queries; `_getPoints.ts` maps monarch (EIIR, CIIIR, GR, GVR, GVIR, VR, EVIIR, EVIIIR) to points (2/9/4/4/4/7/9/12). Friends list in `users/{uid}/friends` array; leaderboards updated by Cloud Functions in `leaderboards/{daily|weekly|monthly}` documents. - **Postbox data source and storage**: Postbox data is **sourced from OpenStreetMap (OSM)**—e.g. Overpass API (`amenity=post_box`, UK area). **test.json** in the repo is a sample of the OSM/Overpass response: nodes with `type`, `id`, `lat`, `lon`, and `tags` (e.g. `amenity`, `ref`, `royal_cypher`, `post_box:type`, `collection_times`, `postal_code`). This data is **not** queried from OSM at app runtime; it is **ingested and stored in the cloud database** (Firestore). The app and existing Cloud Functions read from Firestore only. -- **OSM→Firestore import pipeline (to implement)**: (1) Run Overpass query for UK `amenity=post_box` (see test.json structure). (2) For each node: compute geohash (e.g. ngeohash.encode(lat, lon, 9)), build Firestore document `{ position: { geopoint: GeoPoint(lat, lon), geohash: string }, monarch: tags.royal_cypher || null, ref: tags.ref, ... }`. (3) Batch write to `postboxes` collection (document ID can be OSM node id or a stable hash). (4) Ensure composite index on `postboxes` for `position.geohash` (orderBy/range queries). Can be a one-off script or scheduled Cloud Function. -- **Auth**: `UserRepository` + `AuthenticationBloc`; Google Sign-In + Email/Password; `firebase_options.dart` has **Android and Web** only—**iOS throws**. +- **OSM→Firestore import pipeline**: Implemented in `functions/import_postboxes.js`. Run from the `functions/` directory: `node import_postboxes.js --project the-postbox-game`. Stores each postbox as `{ geohash (precision 9), geopoint, overpass_id, monarch?, reference? }` in `postbox/{osm_}` with batch writes of 400. Use `--dry-run --limit 5` to preview. GEOHASH_PRECISION must remain 9 (maximum) so stored hashes match precision-8 prefix queries used by the 30 m claim scan. +- **Auth**: `UserRepository` + `AuthenticationBloc`; Google Sign-In + Email/Password; `firebase_options.dart` has Android, iOS, macOS, Web, and Windows configurations (generated by FlutterFire CLI). ## Critical issues @@ -44,17 +44,17 @@ The app shows a **fuzzy compass** that gives the user an **indication** of where - **Geolocator**: App uses `desiredAccuracy: LocationAccuracy.high` (geolocator ^10). In geolocator 13+ prefer `locationSettings: LocationSettings(accuracy: LocationAccuracy.high)`. - **Flutter Compass**: Code uses `FlutterCompass.events?.listen(...)` and `mounted` check for null safety. Package is lightly maintained; alternative: `sensors_plus` (magnetometer) with custom heading calculation. - **Android**: Root `android/build.gradle` uses **jcenter()** (deprecated/removed). **compileSdkVersion 33**, **targetSdkVersion 30** — Play Store may require higher target. Kotlin plugin version needs updating (Flutter now prompts: update `org.jetbrains.kotlin.android` in `android/settings.gradle`). Debug builds fail with Java heap space — use `--release` or increase Gradle JVM heap. -- **iOS**: No `Podfile` in repo (Flutter may regenerate). **`firebase_options.dart` throws for iOS** — run FlutterFire CLI to add iOS config if targeting iOS. +- **iOS**: No `Podfile` in repo (Flutter regenerates on `pod install`). `firebase_options.dart` now has iOS config (FlutterFire CLI has been run). A `Podfile` will be needed when building for iOS. - **firebase_dynamic_links**: Removed (Firebase deprecated Dynamic Links Aug 2025; was unused in lib). -- **Intro / Postman James assets**: (Fixed) `flare_flutter` / `james.flr` removed. James is now a `CustomPainter` in `lib/intro.dart`. `james.flr` still listed as an asset in `pubspec.yaml` (harmless; can be removed). No custom font needed — `google_fonts` provides Plus Jakarta Sans and Playfair Display. -- **Claim screen**: (Fixed) Was a dead-end — no actual claim button. Now has full `initial/searching/results/empty/claimed` state machine with a `claimPostbox` Firebase callable and success animation. **Note**: `claimPostbox` callable must be implemented in `functions/index.js` — currently stubbed. -- **Theme**: (Fixed) Centralized in `lib/theme.dart` (`AppTheme.light/dark`, `AppSpacing`). No more ad-hoc inline colours. Postal red `#C8102E` is primary; gold `#FFB400` is accent; royal navy `#0A1931` is dark. -- **`flutter_lints`**: Not in `dev_dependencies` — `analysis_options.yaml` references it but it's absent. Harmless warning; add `flutter_lints: ^4.0.0` to dev_dependencies if lint rules are wanted. +- **Intro / Postman James assets**: (Fixed) `flare_flutter` / `james.flr` removed. James is `PostmanJamesSvg` in `lib/postman_james_svg.dart` using `assets/postman_james.svg`. No custom font needed — `google_fonts` provides Plus Jakarta Sans and Playfair Display. +- **Claim screen**: (Fixed) Full `initial/searching/results/empty/quiz/quizFailed/claimed` state machine. `startScoring` Cloud Function implemented with per-user claim tracking, streak updates, and leaderboard aggregation. +- **Theme**: (Fixed) Centralized in `lib/theme.dart` (`AppTheme.light/dark`, `AppSpacing`). Postal red `#C8102E` is primary; gold `#FFB400` is accent; royal navy `#0A1931` is dark. Light and dark themes both configured. +- **`flutter_lints`**: Added to dev_dependencies (`flutter_lints: ^6.0.0`). `flutter analyze` reports no issues. ## Tests -- `test/widget_test.dart` pumps `PostboxGame()` which calls `Firebase.initializeApp()` — unit tests need Firebase mocked or test will fail. -- Functions test `test/index.js` expects `nearbyPostboxes(request, res)` but the function is **onCall**, i.e. `(data, context)`; test is wrong and references undefined `projectConfig`. +- `test/widget_test.dart` uses `firebase_auth_mocks` + `fake_cloud_firestore` and `setupFirebaseCoreMocks()` — tests run without real Firebase. 55 Dart tests passing. +- `functions/src/test/test.index.ts` uses `firebase-functions-test`. 98 TypeScript tests passing (pure unit tests + auth/validation integration tests that gracefully skip when no emulator is running). ## Security / release @@ -68,20 +68,25 @@ Web build succeeds (`flutter build web`). Android debug build fails with Java he ### Next steps (prioritised) -1. **`claimPostbox` Cloud Function**: The Claim screen calls a `claimPostbox` Firebase callable that doesn’t exist yet in `functions/index.js`. Implement it: validate auth, check 30m radius, write to `claims` collection, return `{points}`. -2. **Friends display names**: Store `displayName` in Firestore `users/{uid}` on register/login. Friends list currently shows raw UIDs. Resolve to names client-side or via a Cloud Function lookup. -3. **Postman James advisor strips**: Add `JamesHint` widget (small James + speech bubble row) at the bottom of `Nearby` and `Claim` screens with contextual copy. Class designed but not yet placed. -4. **Lottie James** (optional): If an animation asset is commissioned, replace `PostManJames` `CustomPainter` with `Lottie.asset(‘assets/james.json’)` (`lottie: ^3.1.0`). -5. **OSM→Firestore pipeline**: Implement or run the import/sync (Overpass → map to `postboxes` schema → batch write); ensure indexes. -6. **Android build**: Update Kotlin plugin in `android/settings.gradle` to latest stable. Consider bumping `compileSdkVersion`/`targetSdkVersion` to 34 for Play Store. -7. **Tests**: Add Firebase/Flutter mocks so widget tests don’t call real Firebase; fix or run functions tests with the emulator. -8. **iOS**: Run `dart run flutterfire configure` and add iOS to `firebase_options.dart` if targeting iOS. -9. **Phase 4 polish**: Staggered list animations (`flutter_staggered_animations`), confetti on claim success, pull-to-refresh on Nearby, dead code removal (`lib/signin.dart`, `lib/upload.dart`). +1. **Android build**: Update Kotlin plugin in `android/settings.gradle` to latest stable. Bump `compileSdkVersion`/`targetSdkVersion` to 34+ for Play Store. Debug builds fail with Java heap space — use `--release` or increase Gradle JVM heap. +2. **iOS build**: A `Podfile` will be needed (`pod install` in the `ios/` directory). Firebase options are already configured. +3. **Rate limiting / App Check enforcement**: App Check is configured with `AndroidPlayIntegrityProvider` for release builds — ensure it is enforced in the Firebase Console. +4. **Friend challenges / social features**: Friends list shows names but no gameplay integration yet (leaderboards are global, not friend-filtered). +5. **Push notifications**: Implement for daily reminder, streak breaks, rare postbox nearby, etc. + +**Already done** (items from prior list that are now complete): +- `startScoring` Cloud Function (with per-user claims, streaks, leaderboard updates) +- Display names stored in Firestore (`onUserCreated` + `updateDisplayName`) +- Postman James SVG strip on all main screens with idle non-sequiturs +- OSM→Firestore import script (`functions/import_postboxes.js`) +- Firebase/Flutter test mocks (55 Dart tests, 98 TS tests, all passing) +- iOS `firebase_options.dart` configured via FlutterFire CLI +- Staggered animations, confetti, pull-to-refresh all implemented ### Potential security concerns -- **Firestore rules**: Define who can read/write `postboxes`, `claims`, `users`, `leaderboards`, and friends subcollections; restrict writes to authenticated users and server/function writes where appropriate. -- **Cloud Functions**: Enforce `context.auth` on callables (`nearbyPostboxes`, `startScoring`, future friends/leaderboard endpoints); reject unauthenticated callers and validate inputs. +- **Firestore rules**: (Done) `firestore.rules` restricts all writes — postboxes/claims/leaderboards are server-only; `users/{uid}` client writes restricted to the `friends` array only. +- **Cloud Functions**: (Done) All callables enforce `request.auth?.uid` and validate inputs (lat/lng ranges, meters bounds, name length/profanity). - **Secrets**: No API keys or service account JSON paths in the repo; use env/config and secure storage for release builds. - **Rate limiting / abuse**: Consider rate limits on callables and abuse detection for claims (e.g. same device, location spoofing). - **PII**: Limit PII in claims and leaderboard display (e.g. display names only); comply with privacy policy and data deletion requests. diff --git a/functions/import_postboxes.js b/functions/import_postboxes.js index 1c0d606..4dae1eb 100644 --- a/functions/import_postboxes.js +++ b/functions/import_postboxes.js @@ -40,8 +40,12 @@ const geohash = require('ngeohash'); const COLLECTION = 'postbox'; // Geohash precision for the spatial index. -// Precision 6 ≈ 1.22 km cells; _lookupPostboxes uses precision 6 for 540 m scans. -const GEOHASH_PRECISION = 6; +// Must be >= the highest precision returned by setPrecision() in _lookupPostboxes.ts. +// The claim scan (30 m) uses precision 8; stored precision 6 caused documents to +// sort *below* precision-8 prefix query ranges, so claims never found postboxes. +// Precision 9 (~4.8 m cells) is the maximum and ensures prefix queries at any +// lower precision (8, 7, 6…) will always match stored documents. +const GEOHASH_PRECISION = 9; // Maximum documents per batch write (Firestore limit is 500). const BATCH_SIZE = 400; @@ -112,10 +116,17 @@ function buildDoc(node) { const cipher = rawCipher.toUpperCase().trim(); if (cipher && VALID_CIPHERS.has(cipher)) { doc.monarch = cipher; + } else { + // Explicitly delete any stale monarch field so that a reimport (which uses + // merge:true) removes it when the OSM data no longer has a known cipher. + // Without this, a postbox previously tagged e.g. EIIR would keep that + // value in Firestore even after the OSM tag is corrected or removed. + doc.monarch = admin.firestore.FieldValue.delete(); } const ref = node.tags?.ref ?? ''; - if (ref) doc.reference = ref; + // Same as monarch: explicitly delete stale reference values on reimport. + doc.reference = ref || admin.firestore.FieldValue.delete(); return doc; } @@ -177,7 +188,13 @@ async function main() { for (const node of sample) { const docId = `osm_${node.id}`; const doc = buildDoc(node); - console.log(` ${docId}:`, JSON.stringify({ ...doc, geopoint: `GeoPoint(${node.lat}, ${node.lon})` })); + // Substitute FieldValue sentinels with a readable placeholder for dry-run display. + const FieldValue = admin.firestore.FieldValue; + const display = Object.fromEntries( + Object.entries({ ...doc, geopoint: `GeoPoint(${node.lat}, ${node.lon})` }) + .map(([k, v]) => [k, (v instanceof FieldValue) ? '' : v]) + ); + console.log(` ${docId}:`, JSON.stringify(display)); } return; } diff --git a/functions/src/nearbyPostboxes.ts b/functions/src/nearbyPostboxes.ts index 94f3115..ffd5aed 100644 --- a/functions/src/nearbyPostboxes.ts +++ b/functions/src/nearbyPostboxes.ts @@ -1,5 +1,8 @@ import "./adminInit"; +import * as admin from "firebase-admin"; import * as functions from "firebase-functions"; +import { getTodayLondon } from "./_dateUtils"; +import { getPoints } from "./_getPoints"; import { lookupPostboxes } from "./_lookupPostboxes"; interface NearbyCallData { @@ -28,18 +31,98 @@ export const nearbyPostboxes = functions.https.onCall(async (request) => { } // Cap radius at 2km to prevent runaway Firestore queries. const clampedMeters = Math.min(meters, 2000); - const full = await lookupPostboxes(lat, lng, clampedMeters); + const uid = request.auth!.uid; + const todayLondon = getTodayLondon(); + + // Run postbox lookup and today's user-claims query in parallel. + const [full, userClaimsSnap] = await Promise.all([ + lookupPostboxes(lat, lng, clampedMeters), + admin.firestore().collection("claims") + .where("userid", "==", uid) + .where("dailyDate", "==", todayLondon) + .get(), + ]); + + // Build the set of postbox IDs already claimed by THIS user today. + // The postboxes field is stored as "/postbox/{key}". + const userClaimedKeys = new Set( + userClaimsSnap.docs + .map(d => d.data().postboxes as string | undefined) + .filter((ref): ref is string => typeof ref === "string") + .map(ref => ref.replace("/postbox/", "")) + ); // Strip precise location fields (geopoint, geohash, dailyClaim) before - // sending to the client. The client only needs monarch (for the quiz) and - // claimedToday (for UI state); all other fields are internal. + // sending to the client. Override claimedToday with per-user status so + // the Claim and Nearby screens only show boxes as "claimed" when the + // current user has claimed them — other players' claims never block. const slimPostboxes: Record = {}; for (const [id, pb] of Object.entries(full.postboxes)) { slimPostboxes[id] = { ...(pb.monarch !== undefined ? { monarch: pb.monarch } : {}), - claimedToday: pb.claimedToday ?? false, + claimedToday: userClaimedKeys.has(id), }; } - return { ...full, postboxes: slimPostboxes }; + // Build per-user counts, overriding both the total claimedToday and the + // per-cipher claimed counts ({cipher}_claimed) so that the Nearby screen's + // monarch breakdown shows the correct "X available" for the current user. + const updatedCounts: Record = {}; + // Copy all global counts first, then override the _claimed keys. + for (const [k, v] of Object.entries(full.counts)) { + if (!k.endsWith("_claimed")) { + updatedCounts[k] = v as number; + } + } + + // Accumulate per-user cipher claimed counts from the user's actual claims. + // full.postboxes[id].monarch is available because lookupPostboxes spreads + // the full PostboxDoc into the result map. + let myClaimedCount = 0; + for (const [id, pb] of Object.entries(full.postboxes)) { + if (userClaimedKeys.has(id)) { + myClaimedCount++; + if (pb.monarch !== undefined) { + const ck = `${pb.monarch}_claimed`; + updatedCounts[ck] = (updatedCounts[ck] ?? 0) + 1; + } + } + } + updatedCounts.claimedToday = myClaimedCount; + + // Compute per-user points range: exclude boxes already claimed by THIS user + // today so the "Worth X–Y pts" display reflects what's actually claimable. + let unclaimedMin = Infinity; + let unclaimedMax = 0; + for (const [id, pb] of Object.entries(full.postboxes)) { + if (!userClaimedKeys.has(id)) { + const pts = pb.monarch !== undefined ? getPoints(pb.monarch) : 2; + if (pts < unclaimedMin) unclaimedMin = pts; + if (pts > unclaimedMax) unclaimedMax = pts; + } + } + const updatedPoints = { + min: isFinite(unclaimedMin) ? unclaimedMin : 0, + max: unclaimedMax, + }; + + // Compute per-user compass: only include unclaimed boxes so "Where to look" + // guides the user toward boxes they can still claim, not ones already taken. + const updatedCompass: Record = {}; + for (const [id, pb] of Object.entries(full.postboxes)) { + if (!userClaimedKeys.has(id)) { + const dir = pb.compass?.exact; + if (dir) { + updatedCompass[dir] = (updatedCompass[dir] ?? 0) + 1; + } + } + } + + return { + ...full, + postboxes: slimPostboxes, + counts: updatedCounts, + points: updatedPoints, + compass: updatedCompass, + }; }); diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index 0b99e45..7b128f6 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -43,8 +43,26 @@ export const startScoring = functions.https.onCall(async (request) => { // Hoist date computation so all return paths include dailyDate for consistency. const todayLondon = getTodayLondon(); - // Fast-path: if every postbox in range was already claimed today, skip transactions. - if (results.counts.claimedToday === results.counts.total) { + // Pre-fetch this user's today claims so we can check per-user claim status + // without a postbox-document read inside every transaction. One query covers + // all postboxes in range; we extract the postbox keys from the stored path. + const userClaimsSnap = await database.collection('claims') + .where('userid', '==', userid) + .where('dailyDate', '==', todayLondon) + .get(); + const userClaimedKeys = new Set( + userClaimsSnap.docs + .map(d => d.data().postboxes as string | undefined) + .filter((ref): ref is string => typeof ref === "string") + .map(ref => ref.replace("/postbox/", "")) + ); + + // Fast-path: if every postbox in range was already claimed today by THIS USER, + // skip all transactions. Uses per-user data so other players' claims don't + // block the current user. + const userClaimedInRange = Object.keys(results.postboxes) + .filter(k => userClaimedKeys.has(k)).length; + if (userClaimedInRange === results.counts.total) { return { found: true, claimed: 0, points: 0, allClaimedToday: true, dailyDate: todayLondon }; } @@ -52,20 +70,23 @@ export const startScoring = functions.https.onCall(async (request) => { // points from postboxes whose transactions already committed successfully. const claimSettled = await Promise.allSettled( Object.entries(results.postboxes).map(([key, postbox]) => { - // Use our own todayLondon (not the derived claimedToday flag from - // lookupPostboxes) to guard against a rare midnight rollover between - // the two getTodayLondon() calls. - if (postbox.dailyClaim?.date === todayLondon) return Promise.resolve(null); + // Per-user skip: if this user has already claimed this postbox today + // (from the pre-fetch), skip without a transaction. Other users' claims + // do NOT block the current user. + if (userClaimedKeys.has(key)) return Promise.resolve(null); const postboxRef = database.collection('postbox').doc(key); - const claimRef = database.collection('claims').doc(); + // Deterministic claim ID: one document per (user, postbox, date) triple. + // The transaction reads this doc to confirm the user hasn't claimed since + // the pre-fetch, then creates it atomically — preventing double-claims from + // concurrent requests. + const claimRef = database.collection('claims').doc(`${userid}_${key}_${todayLondon}`); const pts = postbox.monarch !== undefined ? getPoints(postbox.monarch) : 2; return database.runTransaction(async (tx) => { - const snap = await tx.get(postboxRef); - if (snap.data()?.dailyClaim?.date === todayLondon) return null; + const claimSnap = await tx.get(claimRef); + if (claimSnap.exists) return null; // concurrent request already claimed - tx.set(postboxRef, { dailyClaim: { date: todayLondon, by: userid } }, { merge: true }); const claimData: Record = { userid, timestamp: admin.firestore.Timestamp.now(), @@ -76,6 +97,9 @@ export const startScoring = functions.https.onCall(async (request) => { }; if (postbox.monarch !== undefined) claimData.monarch = postbox.monarch; tx.set(claimRef, claimData); + // Keep dailyClaim on the postbox doc for display purposes (shows + // "someone found this today" in future UI); does not gate claiming. + tx.set(postboxRef, { dailyClaim: { date: todayLondon, by: userid } }, { merge: true }); return pts; }); }) diff --git a/functions/src/test/test.index.ts b/functions/src/test/test.index.ts index 5cc79d1..d9dafb5 100644 --- a/functions/src/test/test.index.ts +++ b/functions/src/test/test.index.ts @@ -84,6 +84,13 @@ describe("getPeriodKey", () => { describe("setPrecision", () => { // Verify geohash precision thresholds used for postbox proximity queries. // Lower precision = larger cells = more documents fetched but guaranteed coverage. + // + // IMPORTANT — import precision coupling: import_postboxes.js must store + // postboxes at a geohash precision >= the highest precision returned here. + // The Claim screen uses a 30 m radius → precision 8 prefix queries. + // If stored precision < 8 the documents sort lexicographically before the + // prefix range, so every claim silently returns { found: false }. + // Current import precision: 9 (maximum). Do not lower it. it("returns 9 at exact upper boundary (0.00477 km)", () => assert.strictEqual(setPrecision(0.00477), 9)); it("returns 8 just above precision-9 boundary", () => assert.strictEqual(setPrecision(0.005), 8)); it("returns 8 for 30 m radius (0.030 km, used by Claim screen)", () => assert.strictEqual(setPrecision(0.030), 8)); diff --git a/lib/authentication_bloc/authentication_bloc.dart b/lib/authentication_bloc/authentication_bloc.dart index 483747e..8520f17 100644 --- a/lib/authentication_bloc/authentication_bloc.dart +++ b/lib/authentication_bloc/authentication_bloc.dart @@ -21,12 +21,7 @@ class AuthenticationBloc ) async { try { final isSignedIn = await _userRepository.isSignedIn(); - if (isSignedIn) { - final name = await _userRepository.getUser(); - emit(Authenticated(name ?? '')); - } else { - emit(Unauthenticated()); - } + emit(isSignedIn ? const Authenticated() : Unauthenticated()); } catch (_) { emit(Unauthenticated()); } @@ -36,7 +31,7 @@ class AuthenticationBloc LoggedIn event, Emitter emit, ) async { - emit(Authenticated(await _userRepository.getUser() ?? '')); + emit(const Authenticated()); } Future _mapLoggedOutToState( diff --git a/lib/authentication_bloc/authentication_state.dart b/lib/authentication_bloc/authentication_state.dart index a450ef8..10ab266 100644 --- a/lib/authentication_bloc/authentication_state.dart +++ b/lib/authentication_bloc/authentication_state.dart @@ -10,15 +10,7 @@ abstract class AuthenticationState extends Equatable { class Uninitialized extends AuthenticationState {} class Authenticated extends AuthenticationState { - final String displayName; - - const Authenticated(this.displayName); - - @override - List get props => [displayName]; - - @override - String toString() => 'Authenticated { displayName: $displayName }'; + const Authenticated(); } class Unauthenticated extends AuthenticationState {} diff --git a/lib/claim.dart b/lib/claim.dart index 3e2923f..1edfa7a 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -122,7 +122,11 @@ class ClaimState extends State with SingleTickerProviderStateMixin { ? 'No internet connection. Please try again.' : 'Could not scan for postboxes. Please try again.'); if (!mounted) return; - JamesController.of(context).show(JamesMessages.claimErrorGeneral.resolve()); + JamesController.of(context)?.show( + isOffline + ? JamesMessages.errorOffline.resolve() + : JamesMessages.claimErrorGeneral.resolve(), + ); setState(() => currentStage = ClaimStage.initial); } catch (e) { debugPrint('Error scanning: $e'); @@ -131,7 +135,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { final msg = e.toString().contains('permission') ? JamesMessages.nearbyErrorPermission.resolve() : JamesMessages.claimErrorGeneral.resolve(); - JamesController.of(context).show(msg); + JamesController.of(context)?.show(msg); setState(() => currentStage = ClaimStage.initial); } } @@ -154,7 +158,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { // User moved out of range between scan and claim. if (!mounted) return; setState(() => _isClaiming = false); - JamesController.of(context).show(JamesMessages.claimOutOfRange.resolve()); + JamesController.of(context)?.show(JamesMessages.claimOutOfRange.resolve()); await _startSearch(); return; } @@ -162,7 +166,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { if (!mounted) return; setState(() => _isClaiming = false); _showErrorSnackBar('Already claimed today — come back tomorrow!'); - JamesController.of(context).show(JamesMessages.claimErrorAlreadyClaimed.resolve()); + JamesController.of(context)?.show(JamesMessages.claimErrorAlreadyClaimed.resolve()); await _startSearch(); return; } @@ -192,7 +196,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { ? JamesMessages.claimSuccessRare.resolve() : JamesMessages.claimSuccessStandard.resolve(); } - JamesController.of(context).show(msg); + JamesController.of(context)?.show(msg); } } on FirebaseFunctionsException catch (e) { debugPrint('Claim error: ${e.code} ${e.message}'); @@ -202,12 +206,14 @@ class ClaimState extends State with SingleTickerProviderStateMixin { _showErrorSnackBar(snackMsg); if (!mounted) return; setState(() => _isClaiming = false); - final msg = (e.code == 'already-claimed') - ? JamesMessages.claimErrorAlreadyClaimed.resolve() - : (e.code == 'out-of-range') - ? JamesMessages.claimErrorOutOfRange.resolve() - : JamesMessages.claimErrorGeneral.resolve(); - JamesController.of(context).show(msg); + final msg = (e.code == 'unavailable') + ? JamesMessages.errorOffline.resolve() + : (e.code == 'already-claimed') + ? JamesMessages.claimErrorAlreadyClaimed.resolve() + : (e.code == 'out-of-range') + ? JamesMessages.claimErrorOutOfRange.resolve() + : JamesMessages.claimErrorGeneral.resolve(); + JamesController.of(context)?.show(msg); } catch (e) { debugPrint('Claim error: $e'); final isPermission = e.toString().contains('permission'); @@ -219,7 +225,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { : 'Could not claim postbox. Please try again.'); if (!mounted) return; setState(() => _isClaiming = false); - JamesController.of(context).show(msg); + JamesController.of(context)?.show(msg); } } @@ -376,7 +382,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { style: Theme.of(context) .textTheme .bodyMedium - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.lg), @@ -444,7 +450,8 @@ class ClaimState extends State with SingleTickerProviderStateMixin { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.location_off, size: 80, color: Colors.grey.shade400), + Icon(Icons.location_off, size: 80, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: AppSpacing.md), Text( 'No postboxes found within ${AppPreferences.formatShortDistance(AppPreferences.claimRadiusMeters, _distanceUnit)}', @@ -460,7 +467,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { style: Theme.of(context) .textTheme .bodyMedium - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xl), @@ -527,7 +534,11 @@ class ClaimState extends State with SingleTickerProviderStateMixin { ), ) : const Icon(Icons.check_circle_outline), - label: Text(_isClaiming ? 'Claiming...' : 'Claim this postbox!'), + label: Text(_isClaiming + ? 'Claiming...' + : (_count - _claimedToday) == 1 + ? 'Claim this postbox!' + : 'Claim ${_count - _claimedToday} postboxes!'), ), ), ), @@ -575,7 +586,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -595,7 +606,9 @@ class ClaimState extends State with SingleTickerProviderStateMixin { const Icon(Icons.help_outline, size: 64, color: postalRed), const SizedBox(height: AppSpacing.md), Text( - 'What\'s the cipher on this postbox?', + (_count - _claimedToday) == 1 + ? 'What\'s the cipher on this postbox?' + : 'What\'s the cipher on one of the nearby postboxes?', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), @@ -603,11 +616,13 @@ class ClaimState extends State with SingleTickerProviderStateMixin { ), const SizedBox(height: AppSpacing.xs), Text( - 'Look at the postbox and pick the correct royal cipher.', + (_count - _claimedToday) == 1 + ? 'Look at the postbox and pick the correct royal cipher.' + : 'Look at one of the postboxes and pick its royal cipher.', style: Theme.of(context) .textTheme .bodyMedium - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xl), @@ -667,7 +682,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -710,7 +725,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { style: Theme.of(context) .textTheme .bodyMedium - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xl), diff --git a/lib/friends_screen.dart b/lib/friends_screen.dart index 62b2986..125f5ee 100644 --- a/lib/friends_screen.dart +++ b/lib/friends_screen.dart @@ -150,7 +150,7 @@ class _FriendsScreenState extends State { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey.shade700), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), overflow: TextOverflow.ellipsis, ), ), @@ -227,8 +227,8 @@ class _FriendsScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.people_outline, - size: 72, color: Colors.grey.shade300), + Icon(Icons.people_outline, size: 72, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: AppSpacing.md), Text( 'No friends yet', @@ -243,7 +243,7 @@ class _FriendsScreenState extends State { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), ], @@ -283,12 +283,12 @@ class _FriendsScreenState extends State { displayName != null ? friendUid : 'UID', overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey.shade500, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), trailing: IconButton( icon: Icon(Icons.person_remove_outlined, - color: Colors.grey.shade500), + color: Theme.of(context).colorScheme.onSurfaceVariant), tooltip: 'Remove friend', onPressed: () => _removeFriend(friendUid), ), diff --git a/lib/fuzzy_compass.dart b/lib/fuzzy_compass.dart index dd32007..32909d5 100644 --- a/lib/fuzzy_compass.dart +++ b/lib/fuzzy_compass.dart @@ -76,7 +76,7 @@ class FuzzyCompass extends StatelessWidget { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey.shade500), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: AppSpacing.md), SizedBox( @@ -113,7 +113,7 @@ class FuzzyCompass extends StatelessWidget { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ], diff --git a/lib/intro.dart b/lib/intro.dart index 6161966..952e2af 100644 --- a/lib/intro.dart +++ b/lib/intro.dart @@ -1,5 +1,6 @@ import 'package:animated_text_kit/animated_text_kit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:postbox_game/james_messages.dart'; import 'package:postbox_game/postman_james_svg.dart'; import 'package:postbox_game/theme.dart'; @@ -137,11 +138,11 @@ class _IntroState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white24, width: 2), ), - child: const Column( + child: Column( children: [ - Icon(Icons.mail, size: 80, color: postalRed), - SizedBox(height: AppSpacing.sm), - Text( + SvgPicture.asset('assets/postbox.svg', width: 120, height: 120), + const SizedBox(height: AppSpacing.sm), + const Text( 'A normal postbox', style: TextStyle(color: Colors.white70, fontSize: 18), ), @@ -162,13 +163,21 @@ class _IntroState extends State with TickerProviderStateMixin { mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 40), - FractionalTranslation( - translation: Offset(_jamesSlide.value, 0), - child: const PostmanJamesSvg(size: 100), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SvgPicture.asset('assets/postbox.svg', width: 80, height: 80), + const SizedBox(width: AppSpacing.lg), + FractionalTranslation( + translation: Offset(_jamesSlide.value, 0), + child: const PostmanJamesSvg(size: 100), + ), + ], ), const SizedBox(height: AppSpacing.lg), const Text( - 'Postman James arrives', + 'Someone arrives...', style: TextStyle(color: Colors.white70, fontSize: 20), ), ], @@ -185,7 +194,15 @@ class _IntroState extends State with TickerProviderStateMixin { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const PostmanJamesSvg(size: 90, isTalking: true), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SvgPicture.asset('assets/postbox.svg', width: 64, height: 64), + const SizedBox(width: AppSpacing.md), + const PostmanJamesSvg(size: 90, isTalking: true), + ], + ), const SizedBox(height: AppSpacing.xl), Container( padding: const EdgeInsets.all(AppSpacing.lg), @@ -292,7 +309,9 @@ class _IntroState extends State with TickerProviderStateMixin { Icon(Icons.thumb_up, size: 64, color: postalGold), const SizedBox(height: AppSpacing.lg), Text( - 'Sign in or create an account to start collecting mega points.', + widget.replay + ? 'Get out there and find some mega-rare postboxes!' + : 'Sign in or create an account to start collecting mega points.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.9), diff --git a/lib/james_controller.dart b/lib/james_controller.dart index 9e81f4e..daebe27 100644 --- a/lib/james_controller.dart +++ b/lib/james_controller.dart @@ -9,8 +9,12 @@ class JamesController extends ChangeNotifier { _scheduleIdleCheck(); } - static JamesController of(BuildContext context) => - context.dependOnInheritedWidgetOfExactType()!.notifier!; + /// Returns the nearest [JamesController], or null if no [JamesControllerScope] + /// is present in the widget tree (e.g. screens opened via named routes / deep + /// links that bypass the [Home] shell). Callers use `?.show()` so James + /// messages are silently skipped rather than crashing. + static JamesController? of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.notifier; String? _pendingMessage; String? get pendingMessage => _pendingMessage; diff --git a/lib/james_messages.dart b/lib/james_messages.dart index 6e4e3f4..b198941 100644 --- a/lib/james_messages.dart +++ b/lib/james_messages.dart @@ -116,6 +116,13 @@ abstract final class JamesMessages { ["Something went wrong there. Give it another go."], ); + // ── Offline / no network ───────────────────────────────────────────────── + + static const errorOffline = JamesMessage( + 'jamesErrorOffline', + ["No signal out here. Find some Wi-Fi and give it another go."], + ); + // ── Claim out-of-range ─────────────────────────────────────────────────── static const claimOutOfRange = JamesMessage( @@ -167,7 +174,7 @@ abstract final class JamesMessages { static const introStep2 = JamesMessage( 'jamesIntroStep2', - ['Hi, my name is Postman James.\nWhat you see here is a normal postbox.'], + ['Ah, you found one!\nWhat you see here is a perfectly ordinary postbox.'], ); static const introStep3 = JamesMessage( diff --git a/lib/leaderboard_screen.dart b/lib/leaderboard_screen.dart index 0d92287..eb5e8f9 100644 --- a/lib/leaderboard_screen.dart +++ b/lib/leaderboard_screen.dart @@ -69,7 +69,8 @@ class _LeaderboardListState extends State<_LeaderboardList> { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 48, color: Colors.grey.shade400), + Icon(Icons.error_outline, size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(height: AppSpacing.md), Text('Could not load leaderboard', style: Theme.of(context).textTheme.titleMedium), @@ -87,8 +88,8 @@ class _LeaderboardListState extends State<_LeaderboardList> { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.leaderboard_outlined, - size: 72, color: Colors.grey.shade300), + Icon(Icons.leaderboard_outlined, size: 72, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: AppSpacing.md), Text('No rankings yet', style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -100,7 +101,7 @@ class _LeaderboardListState extends State<_LeaderboardList> { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -134,7 +135,7 @@ class _LeaderboardListState extends State<_LeaderboardList> { 'You\'re outside the top ${entries.length} — keep claiming to climb!', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey.shade500, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ); @@ -163,8 +164,9 @@ class _LeaderboardListState extends State<_LeaderboardList> { trailing: Text( '$points pts', style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: - isCurrentUser ? postalRed : Colors.grey.shade600, + color: isCurrentUser + ? postalRed + : Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.normal, diff --git a/lib/login/bloc/login_event.dart b/lib/login/bloc/login_event.dart index fc12712..8ba57a0 100644 --- a/lib/login/bloc/login_event.dart +++ b/lib/login/bloc/login_event.dart @@ -29,7 +29,7 @@ class PasswordChanged extends LoginEvent { List get props => [password]; @override - String toString() => 'PasswordChanged { password: $password }'; + String toString() => 'PasswordChanged'; } class LoginWithGooglePressed extends LoginEvent { @@ -50,7 +50,5 @@ class LoginWithCredentialsPressed extends LoginEvent { List get props => [email, password]; @override - String toString() { - return 'LoginWithCredentialsPressed { email: $email, password: $password }'; - } + String toString() => 'LoginWithCredentialsPressed { email: $email }'; } \ No newline at end of file diff --git a/lib/login/login_form.dart b/lib/login/login_form.dart index ec7357c..de6cc6c 100644 --- a/lib/login/login_form.dart +++ b/lib/login/login_form.dart @@ -1,3 +1,4 @@ +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:postbox_game/authentication_bloc/bloc.dart'; @@ -108,7 +109,17 @@ class _LoginFormState extends State { : null; }, ), - const SizedBox(height: AppSpacing.lg), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: state.isSubmitting ? null : _onForgotPassword, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + child: const Text('Forgot password?'), + ), + ), + const SizedBox(height: AppSpacing.sm), LoginButton( onPressed: isLoginButtonEnabled(state) ? _onFormSubmitted @@ -124,7 +135,7 @@ class _LoginFormState extends State { if (state.isSubmitting) Positioned.fill( child: Container( - color: Colors.white.withValues(alpha:0.7), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), child: const Center( child: CircularProgressIndicator(color: postalRed), ), @@ -144,6 +155,76 @@ class _LoginFormState extends State { super.dispose(); } + Future _onForgotPassword() async { + final emailController = + TextEditingController(text: _emailController.text.trim()); + try { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Reset password'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter your email address and we\'ll send you a link to reset your password.'), + const SizedBox(height: AppSpacing.md), + TextFormField( + controller: emailController, + autofocus: emailController.text.isEmpty, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => Navigator.of(context).pop(true), + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Send link'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + final email = emailController.text.trim(); + if (email.isEmpty) return; + await widget._userRepository.sendPasswordResetEmail(email); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text( + 'If that address is registered, a reset link is on its way.'), + ), + ); + } on FirebaseAuthException catch (e) { + if (!mounted) return; + final msg = e.code == 'invalid-email' + ? 'That email address isn\'t valid.' + : 'Could not send reset email. Please try again.'; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(msg), + backgroundColor: Colors.red.shade700, + )); + } finally { + emailController.dispose(); + } + } + void _onEmailChanged() { _loginBloc.add(EmailChanged(email: _emailController.text)); } diff --git a/lib/login/login_screen.dart b/lib/login/login_screen.dart index 6059b05..b30ec66 100644 --- a/lib/login/login_screen.dart +++ b/lib/login/login_screen.dart @@ -45,7 +45,7 @@ class LoginScreen extends StatelessWidget { Text( 'Sign in to start collecting', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey.shade600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: AppSpacing.xl), diff --git a/lib/main.dart b/lib/main.dart index 3b452fe..57bb18e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -82,7 +82,7 @@ class _PostboxGameState extends State { ), routes: { '/nearby': (context) => _guardRoute(context, () => const Nearby()), - '/Claim': (context) => _guardRoute(context, () => const Claim()), + '/claim': (context) => _guardRoute(context, () => const Claim()), '/friends': (context) => _guardRoute(context, () => const FriendsScreen()), '/leaderboard': (context) => _guardRoute(context, () => const LeaderboardScreen()), '/settings': (context) => _guardRoute(context, () => const SettingsScreen()), diff --git a/lib/nearby.dart b/lib/nearby.dart index 72d827f..11a0fb8 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -130,12 +130,16 @@ class NearbyState extends State { final msg = _count > 0 ? JamesMessages.nearbyFound(_count, box) : JamesMessages.nearbyNoneFound.resolve(); - JamesController.of(context).show(msg); + JamesController.of(context)?.show(msg); } on FirebaseFunctionsException catch (e) { debugPrint('Firebase functions error: ${e.code} ${e.message}'); if (!mounted) return; final isOffline = e.code == 'unavailable'; - JamesController.of(context).show(JamesMessages.nearbyErrorGeneral.resolve()); + JamesController.of(context)?.show( + isOffline + ? JamesMessages.errorOffline.resolve() + : JamesMessages.nearbyErrorGeneral.resolve(), + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(isOffline @@ -151,7 +155,7 @@ class NearbyState extends State { final msg = e.toString().contains('permission') ? JamesMessages.nearbyErrorPermission.resolve() : JamesMessages.nearbyErrorGeneral.resolve(); - JamesController.of(context).show(msg); + JamesController.of(context)?.show(msg); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e.toString().replaceFirst('Exception: ', '')), @@ -182,7 +186,8 @@ class NearbyState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.location_searching, - size: 80, color: Colors.grey.shade300), + size: 80, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: AppSpacing.lg), Text( 'Find nearby postboxes', @@ -197,7 +202,7 @@ class NearbyState extends State { style: Theme.of(context) .textTheme .bodyMedium - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.xl), @@ -277,7 +282,7 @@ class NearbyState extends State { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey.shade600), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), if (_lastScanned != null) Text( @@ -285,7 +290,7 @@ class NearbyState extends State { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey.shade500), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), if (_claimedToday > 0) Row( @@ -322,7 +327,8 @@ class NearbyState extends State { padding: const EdgeInsets.all(AppSpacing.xl), child: Column( children: [ - Icon(Icons.location_off, size: 60, color: Colors.grey.shade300), + Icon(Icons.location_off, size: 60, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: AppSpacing.md), Text( 'No postboxes found within ${AppPreferences.formatDistance(AppPreferences.nearbyRadiusMeters, _distanceUnit)}', @@ -335,7 +341,7 @@ class NearbyState extends State { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), @@ -371,8 +377,10 @@ class NearbyState extends State { }), ], - // Compasses - if (_count > 0) ...[ + // Compass — only shown when there are unclaimed postboxes; the + // server now returns an unclaimed-only compass so hiding it when + // everything is claimed avoids showing a blank "No postboxes" disc. + if (_count > 0 && _claimedToday < _count) ...[ Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.md, AppSpacing.lg, AppSpacing.md, AppSpacing.xs), @@ -438,7 +446,9 @@ class NearbyState extends State { child: Text( allClaimed ? '✓' : '$available', style: TextStyle( - color: allClaimed ? Colors.grey.shade400 : color, + color: allClaimed + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38) + : color, fontWeight: FontWeight.bold, ), ), @@ -446,7 +456,7 @@ class NearbyState extends State { title: Text( label, style: allClaimed - ? TextStyle(color: Colors.grey.shade400) + ? TextStyle(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38)) : null, ), subtitle: Text( @@ -456,7 +466,7 @@ class NearbyState extends State { ? '$code · ${MonarchInfo.getPoints(code)} pts · $available of $count available' : '$code · ${MonarchInfo.getPoints(code)} pts each', style: allClaimed - ? TextStyle(color: Colors.grey.shade400) + ? TextStyle(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38)) : null, ), trailing: allClaimed ? null : trailing, diff --git a/lib/postman_james_svg.dart b/lib/postman_james_svg.dart index 116adb3..ac97808 100644 --- a/lib/postman_james_svg.dart +++ b/lib/postman_james_svg.dart @@ -2,15 +2,16 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_svg/flutter_svg.dart'; -/// Renders the Postman James SVG character with animated overlays. +/// Renders the Postman James SVG character with native SVG shape animations. /// /// Animations: /// - Head-bob (sine wave) while [isTalking] -/// - Mouth open/close while [isTalking] (only at size >= 60) -/// - Periodic blink every 3–6 seconds -/// - Gold spinning star-eyes when [showStarEyes] +/// - Mouth cycle (path8 → A → O → E) while [isTalking], using SVG native shapes +/// - Periodic blink every 3–6 seconds, using SVG native closed-eye layers +/// - Gold spinning star-eyes when [showStarEyes] (canvas overlay) class PostmanJamesSvg extends StatefulWidget { const PostmanJamesSvg({ super.key, @@ -33,9 +34,73 @@ class _PostmanJamesSvgState extends State late final AnimationController _mouthController; late final AnimationController _blinkController; late final AnimationController _starController; - late final Animation _mouthAnim; Timer? _blinkTimer; + // Talking frames: 4 SVG strings (path8/A/O/E), eyes open. + List _talkingFrames = const []; + // Blink frames: same 4 strings but with eyes closed. + List _blinkFrames = const []; + + /// Toggles display visibility for the SVG element with [elementId]. + /// Handles elements that already have display:X, or whose style has no + /// display property yet (e.g. path8 default smile). + static String _setVisible(String svg, String elementId, bool visible) { + final want = visible ? 'display:inline' : 'display:none'; + final opposite = visible ? 'display:none' : 'display:inline'; + // [^>] matches newlines in Dart, so multi-line Inkscape opening tags work. + final tagRe = RegExp(r'<[^>]*\bid="' + elementId + r'"[^>]*>'); + return svg.replaceFirstMapped(tagRe, (match) { + final tag = match.group(0)!; + if (tag.contains(opposite)) { + return tag.replaceFirst(opposite, want); + } else if (tag.contains(RegExp(r'display:[^;>"]*'))) { + return tag.replaceFirstMapped( + RegExp(r'display:[^;>"]*'), + (_) => want, + ); + } else if (tag.contains('style="')) { + return tag.replaceFirst('style="', 'style="$want;'); + } + return tag; // already correct + }); + } + + /// Returns an SVG string with [activeMouthId] visible and all other + /// mouth shapes hidden. + static String _applyMouth(String svg, String activeMouthId) { + const allMouths = [ + 'path8', 'path12', 'path17', 'path18', 'path23', + 'path24', 'path25', 'path26', 'path27', 'path28', 'path29', + ]; + var result = svg; + for (final id in allMouths) { + result = _setVisible(result, id, id == activeMouthId); + } + return result; + } + + /// Returns an SVG string with open/closed eyes toggled for blinking. + static String _applyBlink(String svg) { + var result = svg; + result = _setVisible(result, 'layer5', false); // Left Eye open: hide + result = _setVisible(result, 'layer6', false); // Right Eye open: hide + result = _setVisible(result, 'layer9', true); // Left Eye Closed: show + result = _setVisible(result, 'layer10', true); // Right Eye Closed: show + return result; + } + + Future _loadSvgVariants() async { + final base = await rootBundle.loadString('assets/postman_james.svg'); + if (!mounted) return; + const cycle = ['path8', 'path17', 'path18', 'path23']; + final talking = cycle.map((id) => _applyMouth(base, id)).toList(); + final blink = talking.map(_applyBlink).toList(); + setState(() { + _talkingFrames = talking; + _blinkFrames = blink; + }); + } + @override void initState() { super.initState(); @@ -47,11 +112,7 @@ class _PostmanJamesSvgState extends State _mouthController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 300), - ); - _mouthAnim = CurvedAnimation( - parent: _mouthController, - curve: Curves.easeInOut, + duration: const Duration(milliseconds: 880), ); _blinkController = AnimationController( @@ -64,6 +125,8 @@ class _PostmanJamesSvgState extends State duration: const Duration(milliseconds: 1800), ); + _loadSvgVariants(); + if (widget.isTalking) _startTalkingAnimations(); if (widget.showStarEyes) _starController.repeat(); _scheduleBlink(); @@ -71,7 +134,7 @@ class _PostmanJamesSvgState extends State void _startTalkingAnimations() { _bobController.repeat(); - _mouthController.repeat(reverse: true); + _mouthController.repeat(); } void _stopTalkingAnimations() { @@ -128,13 +191,32 @@ class _PostmanJamesSvgState extends State @override Widget build(BuildContext context) { - final showMouth = widget.isTalking && widget.size >= 60; + // Show static asset while SVG variants are loading (first frame only). + if (_talkingFrames.isEmpty) { + return SizedBox( + width: widget.size, + height: widget.size, + child: SvgPicture.asset( + 'assets/postman_james.svg', + width: widget.size, + height: widget.size, + fit: BoxFit.contain, + ), + ); + } return AnimatedBuilder( - animation: Listenable.merge([_bobController, _mouthController]), - builder: (context, child) { - final bob = - math.sin(_bobController.value * 2 * math.pi) * 2.5; + animation: + Listenable.merge([_bobController, _mouthController, _blinkController]), + builder: (context, _) { + final bob = math.sin(_bobController.value * 2 * math.pi) * 2.5; + final mouthIdx = + (_mouthController.value * 4).floor().clamp(0, 3); + final isBlinking = _blinkController.value > 0.05; + final svgFrame = isBlinking + ? _blinkFrames[mouthIdx] + : _talkingFrames[mouthIdx]; + return Transform.translate( offset: Offset(0, bob), child: SizedBox( @@ -142,27 +224,12 @@ class _PostmanJamesSvgState extends State height: widget.size, child: Stack( children: [ - SvgPicture.asset( - 'assets/postman_james.svg', + SvgPicture.string( + svgFrame, width: widget.size, height: widget.size, fit: BoxFit.contain, ), - if (showMouth) - CustomPaint( - size: Size(widget.size, widget.size), - painter: - _MouthOverlayPainter(openFraction: _mouthAnim.value), - ), - AnimatedBuilder( - animation: _blinkController, - builder: (_, __) => CustomPaint( - size: Size(widget.size, widget.size), - painter: _BlinkOverlayPainter( - closeFraction: _blinkController.value, - ), - ), - ), if (widget.showStarEyes) AnimatedBuilder( animation: _starController, @@ -173,7 +240,7 @@ class _PostmanJamesSvgState extends State pulse: 0.85 + 0.15 * math.sin( - _starController.value * 2 * math.pi * 2), + _starController.value * 2 * math.pi), ), ), ), @@ -188,113 +255,6 @@ class _PostmanJamesSvgState extends State // ── Overlay painters ────────────────────────────────────────────────────────── -/// Hides the static SVG smile and draws an animated open/close mouth. -/// -/// Proportional coordinates are derived from SVG path analysis -/// (viewBox 10 15 192 243, layer1 matrix(1.0819613,0,0,1.0819613,-6.6485319,-2.8901256)). -/// Mouth path8 spans SVG-local x≈63–97, y≈163–177 → normalised cx≈0.37, cy≈0.68. -class _MouthOverlayPainter extends CustomPainter { - const _MouthOverlayPainter({required this.openFraction}); - final double openFraction; - - // SVG skin fill is #ffaaaa. - static const Color _skinColour = Color(0xFFFFAAAA); - - static const double _cx = 0.37; - static const double _cy = 0.68; - static const double _halfW = 0.095; - static const double _skinR = 0.095; - - @override - void paint(Canvas canvas, Size size) { - final cx = size.width * _cx; - final cy = size.height * _cy; - final hw = size.width * _halfW; - - // Erase the SVG's static smile with a skin-coloured oval. - final eraser = Paint()..color = _skinColour; - canvas.drawOval( - Rect.fromCenter( - center: Offset(cx, cy), - width: size.width * _skinR * 2.2, - height: size.height * _skinR * 0.7, - ), - eraser, - ); - - final openH = size.height * 0.03 * openFraction; - if (openH < 0.5) return; - - // Filled oval for open mouth (dark red interior). - final mouthPaint = Paint() - ..color = const Color(0xFF8B2500) - ..style = PaintingStyle.fill; - final rect = Rect.fromCenter( - center: Offset(cx, cy + openH * 0.5), - width: hw * 2, - height: openH * 2, - ); - canvas.drawOval(rect, mouthPaint); - - // White teeth strip when more than half open. - if (openFraction > 0.5) { - canvas.drawRect( - Rect.fromLTRB(cx - hw * 0.7, cy, cx + hw * 0.7, cy + openH * 0.4), - Paint()..color = Colors.white, - ); - } - } - - @override - bool shouldRepaint(_MouthOverlayPainter old) => - old.openFraction != openFraction; -} - -/// Paints skin-coloured lids over both eyes to simulate a blink. -/// -/// Eye centres derived from SVG eyeball bounds: -/// - Left eye (path3): SVG-local x≈62–88, y≈80–116 → normalised (0.33, 0.36) -/// - Right eye (path4): SVG-local x≈97–136, y≈83–118 → normalised (0.57, 0.37) -class _BlinkOverlayPainter extends CustomPainter { - const _BlinkOverlayPainter({required this.closeFraction}); - final double closeFraction; - - // SVG skin fill is #ffaaaa. - static const Color _skinColour = Color(0xFFFFAAAA); - - static const List _eyeCentres = [ - Offset(0.33, 0.36), // left eye - Offset(0.57, 0.37), // right eye - ]; - static const double _eyeHalfW = 0.115; - static const double _eyeHalfH = 0.085; - - @override - void paint(Canvas canvas, Size size) { - if (closeFraction < 0.05) return; - final paint = Paint()..color = _skinColour; - for (final centre in _eyeCentres) { - final cx = size.width * centre.dx; - final eyeTop = size.height * (centre.dy - _eyeHalfH); - final lidH = size.height * _eyeHalfH * 2 * closeFraction; - // Lid descends from the top of the eye downward. - canvas.drawOval( - Rect.fromLTRB( - cx - size.width * _eyeHalfW, - eyeTop, - cx + size.width * _eyeHalfW, - eyeTop + lidH, - ), - paint, - ); - } - } - - @override - bool shouldRepaint(_BlinkOverlayPainter old) => - old.closeFraction != closeFraction; -} - /// Draws animated gold 4-point star overlays over both eyes. /// /// Stars rotate and pulse at 1 revolution per 1.8 seconds. diff --git a/lib/register/bloc/register_event.dart b/lib/register/bloc/register_event.dart index 71dcfcf..12ba6e1 100644 --- a/lib/register/bloc/register_event.dart +++ b/lib/register/bloc/register_event.dart @@ -28,7 +28,7 @@ class PasswordChanged extends RegisterEvent { List get props => [password]; @override - String toString() => 'PasswordChanged { password: $password }'; + String toString() => 'PasswordChanged'; } class Submitted extends RegisterEvent { @@ -44,7 +44,5 @@ class Submitted extends RegisterEvent { List get props => [email, password]; @override - String toString() { - return 'Submitted { email: $email, password: $password }'; - } + String toString() => 'Submitted { email: $email }'; } \ No newline at end of file diff --git a/lib/register/register_form.dart b/lib/register/register_form.dart index bd6591a..80da390 100644 --- a/lib/register/register_form.dart +++ b/lib/register/register_form.dart @@ -41,7 +41,6 @@ class _RegisterFormState extends State { listener: (BuildContext context, RegisterState state) { if (state.isSuccess) { BlocProvider.of(context).add(LoggedIn()); - Navigator.of(context).pop(); } if (state.isFailure) { ScaffoldMessenger.of(context) @@ -114,7 +113,7 @@ class _RegisterFormState extends State { if (state.isSubmitting) Positioned.fill( child: Container( - color: Colors.white.withValues(alpha:0.7), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), child: const Center( child: CircularProgressIndicator(color: postalRed), ), diff --git a/lib/register/register_screen.dart b/lib/register/register_screen.dart index 0ae295b..f8313e6 100644 --- a/lib/register/register_screen.dart +++ b/lib/register/register_screen.dart @@ -51,7 +51,7 @@ class RegisterScreen extends StatelessWidget { Text( 'Create your account to start collecting postboxes', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Colors.grey.shade600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index 4e39e0d..6f96da4 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -36,44 +36,65 @@ class _SettingsScreenState extends State { final controller = TextEditingController( text: FirebaseAuth.instance.currentUser?.displayName ?? '', ); - final formKey = GlobalKey(); - final newName = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Display name'), - content: Form( - key: formKey, - child: TextFormField( - controller: controller, - autofocus: true, - maxLength: 30, - decoration: const InputDecoration(labelText: 'Name'), - validator: (v) => Validators.displayNameError(v ?? ''), - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) { - if (formKey.currentState?.validate() ?? false) { - Navigator.pop(ctx, controller.text.trim()); + final String? newName; + try { + // errorText must live in the outer closure (not inside StatefulBuilder.builder) + // so that setDialogState updates it by reference and it persists across rebuilds. + String? errorText; + newName = await showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (_, setDialogState) { + void trySubmit() { + final error = Validators.displayNameError(controller.text.trim()); + if (error != null) { + setDialogState(() => errorText = error); + return; } - }, - ), + // Use the outer (settings screen) context to navigate, not the + // dialog's builder context. The dialog's context is owned by a + // route that is being deactivated by this very pop call, so using + // it can race with InkWell/button ripple rebuilds still scheduled + // against it, causing the '_dependents.isEmpty' Flutter assertion. + if (mounted) Navigator.of(context).pop(controller.text.trim()); + } + + return AlertDialog( + title: const Text('Display name'), + content: TextField( + controller: controller, + autofocus: true, + maxLength: 30, + decoration: InputDecoration( + labelText: 'Name', + errorText: errorText, + ), + textInputAction: TextInputAction.done, + onChanged: (_) { + if (errorText != null) setDialogState(() => errorText = null); + }, + onSubmitted: (_) => trySubmit(), + ), + actions: [ + TextButton( + onPressed: () { + if (mounted) Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: trySubmit, + child: const Text('Save'), + ), + ], + ); + }, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - Navigator.pop(ctx, controller.text.trim()); - } - }, - child: const Text('Save'), - ), - ], - ), - ); + ); + } finally { + controller.dispose(); + } if (newName == null || !mounted) return; @@ -101,7 +122,7 @@ class _SettingsScreenState extends State { Future _chooseDistanceUnit() async { final chosen = await showModalBottomSheet( context: context, - builder: (context) => SafeArea( + builder: (_) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -116,18 +137,18 @@ class _SettingsScreenState extends State { leading: Radio( value: DistanceUnit.meters, groupValue: _distanceUnit, - onChanged: (v) => Navigator.pop(context, v), + onChanged: (v) => Navigator.of(context).pop(v), ), - onTap: () => Navigator.pop(context, DistanceUnit.meters), + onTap: () => Navigator.of(context).pop(DistanceUnit.meters), ), ListTile( title: const Text('Miles'), leading: Radio( value: DistanceUnit.miles, groupValue: _distanceUnit, - onChanged: (v) => Navigator.pop(context, v), + onChanged: (v) => Navigator.of(context).pop(v), ), - onTap: () => Navigator.pop(context, DistanceUnit.miles), + onTap: () => Navigator.of(context).pop(DistanceUnit.miles), ), ], ), @@ -139,19 +160,176 @@ class _SettingsScreenState extends State { } } + Future _changePassword() async { + final currentPwCtrl = TextEditingController(); + final newPwCtrl = TextEditingController(); + final confirmPwCtrl = TextEditingController(); + + try { + final confirmed = await showDialog( + context: context, + builder: (_) { + bool showCurrent = false; + bool showNew = false; + bool showConfirm = false; + String? currentError; + String? newError; + String? confirmError; + return StatefulBuilder( + builder: (_, setDialogState) { + void trySubmit() { + final ce = currentPwCtrl.text.isEmpty ? 'Required' : null; + final ne = newPwCtrl.text.length < 6 + ? 'Password must be at least 6 characters' + : null; + final co = confirmPwCtrl.text != newPwCtrl.text + ? "Passwords don't match" + : null; + if (ce != null || ne != null || co != null) { + setDialogState(() { + currentError = ce; + newError = ne; + confirmError = co; + }); + return; + } + if (mounted) Navigator.of(context).pop(true); + } + + return AlertDialog( + title: const Text('Change password'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: currentPwCtrl, + obscureText: !showCurrent, + decoration: InputDecoration( + labelText: 'Current password', + prefixIcon: const Icon(Icons.lock_outline), + errorText: currentError, + suffixIcon: IconButton( + icon: Icon(showCurrent + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setDialogState(() => showCurrent = !showCurrent), + ), + ), + onChanged: (_) { + if (currentError != null) { + setDialogState(() => currentError = null); + } + }, + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: newPwCtrl, + obscureText: !showNew, + decoration: InputDecoration( + labelText: 'New password', + helperText: 'At least 6 characters', + prefixIcon: const Icon(Icons.lock_outline), + errorText: newError, + suffixIcon: IconButton( + icon: Icon(showNew + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setDialogState(() => showNew = !showNew), + ), + ), + onChanged: (_) { + if (newError != null) setDialogState(() => newError = null); + }, + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: confirmPwCtrl, + obscureText: !showConfirm, + decoration: InputDecoration( + labelText: 'Confirm new password', + prefixIcon: const Icon(Icons.lock_outline), + errorText: confirmError, + suffixIcon: IconButton( + icon: Icon(showConfirm + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setDialogState(() => showConfirm = !showConfirm), + ), + ), + onChanged: (_) { + if (confirmError != null) { + setDialogState(() => confirmError = null); + } + }, + onSubmitted: (_) => trySubmit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + if (mounted) Navigator.of(context).pop(false); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: trySubmit, + child: const Text('Update'), + ), + ], + ); + }, + ); + }, + ); + if (confirmed != true || !mounted) return; + setState(() => _isSaving = true); + await _userRepository.changePassword( + currentPassword: currentPwCtrl.text, + newPassword: newPwCtrl.text, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password updated.')), + ); + } + } on FirebaseAuthException catch (e) { + if (!mounted) return; + final msg = switch (e.code) { + 'wrong-password' || 'invalid-credential' => + 'Current password is incorrect.', + 'weak-password' => 'New password is too weak.', + 'network-request-failed' => 'No internet connection.', + 'too-many-requests' => 'Too many attempts. Please wait and try again.', + _ => 'Could not update password. Please try again.', + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg), backgroundColor: Colors.red.shade700), + ); + } finally { + currentPwCtrl.dispose(); + newPwCtrl.dispose(); + confirmPwCtrl.dispose(); + if (mounted) setState(() => _isSaving = false); + } + } + Future _signOut() async { final confirm = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (_) => AlertDialog( title: const Text('Sign out'), content: const Text('Are you sure you want to sign out?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), + onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () => Navigator.of(context).pop(true), child: const Text('Sign out'), ), ], @@ -251,6 +429,13 @@ class _SettingsScreenState extends State { ), _sectionHeader('Account'), + if (user?.providerData.any((p) => p.providerId == 'password') ?? false) + ListTile( + leading: const Icon(Icons.lock_reset), + title: const Text('Change password'), + subtitle: const Text('Update your account password'), + onTap: _isSaving ? null : _changePassword, + ), ListTile( leading: const Icon(Icons.logout), title: const Text('Sign out'), diff --git a/lib/user_repository.dart b/lib/user_repository.dart index b1c8ca7..a565705 100644 --- a/lib/user_repository.dart +++ b/lib/user_repository.dart @@ -77,6 +77,31 @@ class UserRepository { await user.reload(); } + /// Sends a password reset email to [email]. + /// Uses a generic success message on the calling side to prevent user + /// enumeration — do not surface whether the address is registered. + Future sendPasswordResetEmail(String email) { + return _firebaseAuth.sendPasswordResetEmail(email: email); + } + + /// Reauthenticates with [currentPassword] then updates to [newPassword]. + /// Throws [FirebaseAuthException] on wrong current password or network error. + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final user = _firebaseAuth.currentUser; + if (user == null || user.email == null) { + throw FirebaseAuthException(code: 'no-current-user'); + } + final credential = EmailAuthProvider.credential( + email: user.email!, + password: currentPassword, + ); + await user.reauthenticateWithCredential(credential); + await user.updatePassword(newPassword); + } + Future signOut() async { await Future.wait([ _firebaseAuth.signOut(), @@ -88,8 +113,4 @@ class UserRepository { final currentUser = _firebaseAuth.currentUser; return currentUser != null; } - - Future getUser() async { - return _firebaseAuth.currentUser?.email; - } } diff --git a/pubspec.lock b/pubspec.lock index 7a42eb6..f8cda9c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.12.4+4" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: transitive description: @@ -696,6 +704,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + rive: + dependency: "direct main" + description: + name: rive + sha256: "2551a44fa766a7ed3f52aa2b94feda6d18d00edc25dee5f66e72e9b365bb6d6c" + url: "https://pub.dev" + source: hosted + version: "0.13.20" + rive_common: + dependency: transitive + description: + name: rive_common + sha256: "2ba42f80d37a4efd0696fb715787c4785f8a13361e8aea9227c50f1e78cf763a" + url: "https://pub.dev" + source: hosted + version: "0.4.15" rx: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5eb20da..bbe8294 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: firebase_app_check: ^0.4.2 confetti: ^0.8.0 flutter_staggered_animations: ^1.1.1 + rive: ^0.13.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/widget_test.dart b/test/widget_test.dart index db6fa1f..460dbb1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,4 +1,5 @@ import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core_platform_interface/test.dart'; @@ -127,6 +128,40 @@ void main() { expect(displayName!.startsWith('Player_'), isTrue, reason: 'Profane email prefix should fall back to Player_ in Auth profile'); }); + + test('sendPasswordResetEmail completes without error for valid email', () async { + // The real Firebase sends an email; the mock just completes. + // We verify the method chain reaches FirebaseAuth without throwing. + await expectLater( + repo.sendPasswordResetEmail('alice@example.com'), + completes, + ); + }); + + test('changePassword throws when no user is signed in', () async { + // repo uses MockFirebaseAuth() with no signed-in user (mockAuth default). + expect( + () => repo.changePassword( + currentPassword: 'old', + newPassword: 'newpassword', + ), + throwsA(isA()), + ); + }); + + test('changePassword completes for a signed-in email user', () async { + // Sign up first so there is a current user with an email address. + await repo.signUp(email: 'bob@example.com', password: 'password123'); + // MockFirebaseAuth.reauthenticateWithCredential always succeeds (no real + // credential validation in the mock), so this verifies the call chain. + await expectLater( + repo.changePassword( + currentPassword: 'password123', + newPassword: 'newpassword123', + ), + completes, + ); + }); }); // --------------------------------------------------------------------------- @@ -443,6 +478,7 @@ void main() { JamesMessages.nearbyNoneFound, JamesMessages.nearbyErrorPermission, JamesMessages.nearbyErrorGeneral, + JamesMessages.errorOffline, JamesMessages.claimOutOfRange, JamesMessages.claimSuccessRare, JamesMessages.claimSuccessStandard, @@ -467,6 +503,7 @@ void main() { JamesMessages.nearbyNoneFound, JamesMessages.nearbyErrorPermission, JamesMessages.nearbyErrorGeneral, + JamesMessages.errorOffline, JamesMessages.claimOutOfRange, JamesMessages.claimSuccessRare, JamesMessages.claimSuccessStandard,