From 2b98f5dd10093139f28d389ce6721958c2616e1a Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 11:02:17 +0100 Subject: [PATCH 01/37] fix: geohash precision and display name edit dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise import geohash precision from 6 to 9 in import_postboxes.js. Precision-6 hashes sort lexicographically *before* the precision-8 prefix ranges used by the 30 m claim scan, so every claim silently returned { found: false }. Precision 9 (~4.8 m cells) ensures stored hashes are always longer than any query prefix, making prefix queries at precisions 8, 7, 6, … always match. - Replace showModalBottomSheet with AlertDialog in settings_screen.dart for the display-name edit flow. The bottom sheet with isScrollControlled + autofocus expanded to fill the screen when the keyboard appeared, covering the scrim and leaving users stuck. AlertDialog handles keyboard avoidance natively. Also wraps the dialog call in try/finally to guarantee controller.dispose(). Co-Authored-By: Claude Sonnet 4.6 --- functions/import_postboxes.js | 8 +++- lib/settings_screen.dart | 73 +++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/functions/import_postboxes.js b/functions/import_postboxes.js index 1c0d606..f366314 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; diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index 4e39e0d..14cddab 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -38,42 +38,47 @@ class _SettingsScreenState extends State { ); 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 { + 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()); + } + }, + ), ), + 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'), + ), + ], ), - 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; From f8337faa3e654b8d743d283567adecdb1d4bb1ea Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 11:07:09 +0100 Subject: [PATCH 02/37] docs: document import precision coupling in setPrecision tests Add a comment to the setPrecision describe block explaining that import_postboxes.js must store geohashes at precision >= 8 (the max query precision for the 30 m claim radius). This makes the constraint discoverable for future maintainers. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/test/test.index.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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)); From 09b8cb20497ddbdbf5d2870d7e8a511d025e1712 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 12:08:41 +0100 Subject: [PATCH 03/37] feat: per-user daily claim tracking (independent of other players) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the dailyClaim field on each postbox doc acted as a global daily lock: whoever claimed a postbox first blocked every other user from claiming it that day. This was unintentional — the game intent is that each user earns points from finding postboxes, not that they race to be first. startScoring.ts: - Pre-fetch the current user's today claims in one query; build a Set of postbox keys already claimed by this user. - Replace the outer skip check (global dailyClaim.date) with a per-user check (userClaimedKeys.has(key)). - Use a deterministic claim document ID ({userid}_{key}_{date}) instead of a random one. The transaction reads this doc atomically to guard against concurrent requests; the write is idempotent. - Keep writing dailyClaim on the postbox doc for display purposes ("someone found this today"), but it no longer gates claiming. - Fast-path now counts boxes claimed by this user, not by anyone. nearbyPostboxes.ts: - Import admin and getTodayLondon. - Run lookupPostboxes and the user's today-claims query in parallel. - Override each slim postbox's claimedToday with per-user status. - Override counts.claimedToday with the per-user count so the client's "X of N available" and "All claimed today" logic is also per-user. Old random-ID claim docs are unaffected: leaderboard queries use field filters (userid, dailyDate), not document IDs. New deterministic IDs live alongside old ones in the same collection. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/nearbyPostboxes.ts | 41 +++++++++++++++++++++++++---- functions/src/startScoring.ts | 45 +++++++++++++++++++++++++------- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/functions/src/nearbyPostboxes.ts b/functions/src/nearbyPostboxes.ts index 94f3115..9f2c515 100644 --- a/functions/src/nearbyPostboxes.ts +++ b/functions/src/nearbyPostboxes.ts @@ -1,5 +1,7 @@ import "./adminInit"; +import * as admin from "firebase-admin"; import * as functions from "firebase-functions"; +import { getTodayLondon } from "./_dateUtils"; import { lookupPostboxes } from "./_lookupPostboxes"; interface NearbyCallData { @@ -28,18 +30,47 @@ 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 => { + const ref = d.data().postboxes as string; + return 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 }; + // Override counts.claimedToday with the per-user count so the client's + // "X of N available" and "All claimed today" logic is also per-user. + const myClaimedCount = Object.keys(full.postboxes) + .filter(k => userClaimedKeys.has(k)).length; + + return { + ...full, + postboxes: slimPostboxes, + counts: { ...full.counts, claimedToday: myClaimedCount }, + }; }); diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index 0b99e45..ea6cf5c 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -43,8 +43,27 @@ 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 => { + const ref = d.data().postboxes as string; + // stored as "/postbox/{key}" → extract just the key + return 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 +71,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 +98,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; }); }) From 074f8f4de59d95462f7394dfb9124c5419ae6230 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 12:09:40 +0100 Subject: [PATCH 04/37] fix: per-user cipher claimed counts in nearbyPostboxes The previous commit fixed counts.claimedToday but left the per-cipher {cipher}_claimed counts (EIIR_claimed, GR_claimed, etc.) global. The Nearby screen's monarch breakdown uses these to show "N available", so a globally-claimed EIIR box would show "0 available" even for users who hadn't claimed it yet. Rebuild all {cipher}_claimed counts from the user's own claimed keys, using the monarch field already present in full.postboxes[id]. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/nearbyPostboxes.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/functions/src/nearbyPostboxes.ts b/functions/src/nearbyPostboxes.ts index 9f2c515..18eb138 100644 --- a/functions/src/nearbyPostboxes.ts +++ b/functions/src/nearbyPostboxes.ts @@ -63,14 +63,35 @@ export const nearbyPostboxes = functions.https.onCall(async (request) => { }; } - // Override counts.claimedToday with the per-user count so the client's - // "X of N available" and "All claimed today" logic is also per-user. - const myClaimedCount = Object.keys(full.postboxes) - .filter(k => userClaimedKeys.has(k)).length; + // 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; return { ...full, postboxes: slimPostboxes, - counts: { ...full.counts, claimedToday: myClaimedCount }, + counts: updatedCounts, }; }); From fab6aaeb5d9ca3aabbf114c09ac61deef9e82940 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 12:13:08 +0100 Subject: [PATCH 05/37] fix: per-user points min/max in nearbyPostboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously points.min/max was forwarded from lookupPostboxes which computed it using the global dailyClaim.date flag — excluding boxes claimed by *any* user today even if the current user hadn't claimed them. Now nearbyPostboxes recomputes the range using userClaimedKeys so the "Worth X–Y pts" display accurately reflects what the calling user can still claim. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/nearbyPostboxes.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/functions/src/nearbyPostboxes.ts b/functions/src/nearbyPostboxes.ts index 18eb138..1783d5a 100644 --- a/functions/src/nearbyPostboxes.ts +++ b/functions/src/nearbyPostboxes.ts @@ -2,6 +2,7 @@ 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 { @@ -89,9 +90,26 @@ export const nearbyPostboxes = functions.https.onCall(async (request) => { } 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, + }; + return { ...full, postboxes: slimPostboxes, counts: updatedCounts, + points: updatedPoints, }; }); From 652691f156caae262ade9d87451245e39182ebbb Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 12:41:07 +0100 Subject: [PATCH 06/37] chore: gitignore root-level dev utility scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test.py, find_postboxes.js, and ciiir_postboxes.json are one-off dev tools for analysing OSM postbox data — not app code and shouldn't clutter the tracked file list. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index d460d5f..c73202a 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,8 @@ 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 From 1265650baf1c32d42701cfc00413e4c8ff18d28b Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 12:42:58 +0100 Subject: [PATCH 07/37] feat: per-user unclaimed-only compass in nearbyPostboxes The "Where to look" compass now reflects only postboxes the current user has not yet claimed today, matching the game's design intent of guiding players toward claimable boxes rather than ones they've already taken. Previously full.compass included all postboxes (per anyone's dailyClaim); now it is rebuilt from unclaimed-per-user entries using the compass.exact direction stored by _lookupPostboxes on each postbox doc. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/nearbyPostboxes.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/functions/src/nearbyPostboxes.ts b/functions/src/nearbyPostboxes.ts index 1783d5a..bedd071 100644 --- a/functions/src/nearbyPostboxes.ts +++ b/functions/src/nearbyPostboxes.ts @@ -106,10 +106,23 @@ export const nearbyPostboxes = functions.https.onCall(async (request) => { 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, }; }); From 47652dc32d399bf869af2dcfa226ee5b2d24f399 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 13:16:17 +0100 Subject: [PATCH 08/37] fix: hide compass when all nearby postboxes already claimed The server now returns an unclaimed-only compass, so displaying it when the user has claimed every box in range produces a blank disc. Guard the "Where to look" section with `_claimedToday < _count`. Co-Authored-By: Claude Sonnet 4.6 --- lib/nearby.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/nearby.dart b/lib/nearby.dart index 72d827f..74249d4 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -371,8 +371,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), From 8dae3dc1339d1da8bc8a7bc08c483c4e0fc7abdf Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 13:54:20 +0100 Subject: [PATCH 09/37] fix: explicitly delete stale monarch/reference fields on reimport When import_postboxes.js is run with merge:true, fields not present in the new doc are left unchanged. A postbox whose OSM cipher is corrected or removed would keep the old monarch value in Firestore, causing wrong point values. Using FieldValue.delete() for absent fields ensures the stored document always reflects the current OSM data. Same fix applied to the reference field. Dry-run output now renders FieldValue sentinels as '' for readability. Co-Authored-By: Claude Sonnet 4.6 --- functions/import_postboxes.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/functions/import_postboxes.js b/functions/import_postboxes.js index f366314..4dae1eb 100644 --- a/functions/import_postboxes.js +++ b/functions/import_postboxes.js @@ -116,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; } @@ -181,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; } From 9ad72e30238478516b9d680e08db6890fadf28ff Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 14:28:18 +0100 Subject: [PATCH 10/37] docs: update CLAUDE.md to reflect current codebase state Many sections were written before the bulk of the implementation work and described things as "to implement" or "throws for iOS" that are now done. Updated: firebase_options.dart now has all platforms; backend file paths now reference .ts not .js; James is SVG not CustomPainter; claim screen and tests are fully implemented; next-steps list trimmed of completed items; security concerns marked as done. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) 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. From 3b98c161c5204b5a7f6983b5e69b6027d7df55be Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 14:32:32 +0100 Subject: [PATCH 11/37] chore: gitignore root-level postman_james.svg dev copy The canonical SVG is assets/postman_james.svg; the root copy is a stale Inkscape working file, not app code. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c73202a..5c949b7 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,4 @@ interpreter?data=* /test.py /find_postboxes.js /ciiir_postboxes.json +/postman_james.svg From bccbd43e91cc39834a933afd99470474920b4a54 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 14:32:55 +0100 Subject: [PATCH 12/37] fix: normalise /Claim route to lowercase /claim All other named routes use lowercase; /Claim was inconsistent and would silently fail any deep link using /claim. Co-Authored-By: Claude Sonnet 4.6 --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()), From bf36b6286dbbdb9f3eeee874d7c0a45a58c17fbc Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 15:07:45 +0100 Subject: [PATCH 13/37] fix: make JamesController.of() null-safe to prevent deep-link crash Screens reached via named routes (/nearby, /claim, etc.) don't have Home's JamesControllerScope in the tree. The previous `!.notifier!` would crash with a null-check error if a user deep-linked to these screens and triggered a search or claim. Changed of() to return JamesController? and updated all 10 call sites to use ?.show() so James messages are silently skipped rather than crashing the app. Co-Authored-By: Claude Sonnet 4.6 --- lib/claim.dart | 14 +++++++------- lib/james_controller.dart | 8 ++++++-- lib/nearby.dart | 6 +++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/claim.dart b/lib/claim.dart index 3e2923f..46ad9be 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -122,7 +122,7 @@ 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(JamesMessages.claimErrorGeneral.resolve()); setState(() => currentStage = ClaimStage.initial); } catch (e) { debugPrint('Error scanning: $e'); @@ -131,7 +131,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 +154,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 +162,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 +192,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}'); @@ -207,7 +207,7 @@ class ClaimState extends State with SingleTickerProviderStateMixin { : (e.code == 'out-of-range') ? JamesMessages.claimErrorOutOfRange.resolve() : JamesMessages.claimErrorGeneral.resolve(); - JamesController.of(context).show(msg); + JamesController.of(context)?.show(msg); } catch (e) { debugPrint('Claim error: $e'); final isPermission = e.toString().contains('permission'); @@ -219,7 +219,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); } } 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/nearby.dart b/lib/nearby.dart index 74249d4..dab4526 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -130,12 +130,12 @@ 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(JamesMessages.nearbyErrorGeneral.resolve()); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(isOffline @@ -151,7 +151,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: ', '')), From e30a8dfebf2622b257a231de4caf0e243acf0024 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 15:47:28 +0100 Subject: [PATCH 14/37] =?UTF-8?q?refactor:=20simplify=20Authenticated=20st?= =?UTF-8?q?ate=20=E2=80=94=20remove=20dead=20displayName=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authenticated.displayName was set to the user's email (via getUser()) but never read anywhere in the widget tree; main.dart only checks `state is Authenticated` as a type guard. Display names are read from FirebaseAuth.instance.currentUser?.displayName at the point of use. Removed the field and the unnecessary getUser() call in _mapLoggedInToState; _mapAppStartedToState simplified to a single emit expression. Co-Authored-By: Claude Sonnet 4.6 --- lib/authentication_bloc/authentication_bloc.dart | 9 ++------- lib/authentication_bloc/authentication_state.dart | 10 +--------- 2 files changed, 3 insertions(+), 16 deletions(-) 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 {} From f2e86fee82d2af4963fa746467922849858b1e1c Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 15:48:01 +0100 Subject: [PATCH 15/37] refactor: remove unused UserRepository.getUser() method getUser() returned currentUser?.email and was only used by AuthenticationBloc to populate the now-removed Authenticated.displayName field. No remaining callers; deleting it shrinks the public API surface. Co-Authored-By: Claude Sonnet 4.6 --- lib/user_repository.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/user_repository.dart b/lib/user_repository.dart index b1c8ca7..100ed1f 100644 --- a/lib/user_repository.dart +++ b/lib/user_repository.dart @@ -88,8 +88,4 @@ class UserRepository { final currentUser = _firebaseAuth.currentUser; return currentUser != null; } - - Future getUser() async { - return _firebaseAuth.currentUser?.email; - } } From 4e9238a1dc52ad1f972c3a769ceaa23f26456964 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 16:43:42 +0100 Subject: [PATCH 16/37] fix: pluralise claim button when multiple postboxes are in range "Claim this postbox!" was shown even when several unclaimed boxes were in range. Now shows "Claim N postboxes!" when N > 1. Co-Authored-By: Claude Sonnet 4.6 --- lib/claim.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/claim.dart b/lib/claim.dart index 46ad9be..ad6192c 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -527,7 +527,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!'), ), ), ), From d4074b1eb4a3f4dd917918b8fa7922337c971885 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 16:45:10 +0100 Subject: [PATCH 17/37] fix: remove spurious Navigator.pop() after successful registration register_form.dart called both AuthenticationBloc.add(LoggedIn()) and Navigator.of(context).pop() on success. The pop() caused a one-frame flash back to LoginScreen before main.dart's BlocBuilder replaced the entire tree with Home. login_form.dart correctly omits the pop(). Co-Authored-By: Claude Sonnet 4.6 --- lib/register/register_form.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/register/register_form.dart b/lib/register/register_form.dart index bd6591a..34f86b5 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) From d7f8a25c52c2a65e291a6166db5051fc101cf6cb Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 17:11:12 +0100 Subject: [PATCH 18/37] fix: show correct outro message in intro replay mode The last step of the intro always said "Sign in or create an account" even when opened from Settings by an already-authenticated user. When replay:true, show "Get out there and find some mega-rare postboxes!" instead. Co-Authored-By: Claude Sonnet 4.6 --- lib/intro.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/intro.dart b/lib/intro.dart index 6161966..24132b0 100644 --- a/lib/intro.dart +++ b/lib/intro.dart @@ -292,7 +292,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), From b41e2219c847c47dbfe2176d631774f67becf347 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 17:35:31 +0100 Subject: [PATCH 19/37] fix: pluralise quiz heading when multiple postboxes are in range When _count - _claimedToday > 1 the quiz heading now says "What's the cipher on one of the nearby postboxes?" to match the already-pluralised claim button text. Co-Authored-By: Claude Sonnet 4.6 --- lib/claim.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/claim.dart b/lib/claim.dart index ad6192c..72333a2 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -599,7 +599,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, ), @@ -607,7 +609,9 @@ 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 From 25b5078b7c44fd6343ca303221a5b67fbbda3fef Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 18:34:34 +0100 Subject: [PATCH 20/37] feat: add offline-specific James message for no-network errors Previously James always said "Something went wrong there" even when the device had no internet. Added JamesMessages.errorOffline ("No signal out here") and wired it to the unavailable error code in both the Nearby scan and Claim scan/claim error handlers. Co-Authored-By: Claude Sonnet 4.6 --- lib/claim.dart | 18 ++++++++++++------ lib/james_messages.dart | 7 +++++++ lib/nearby.dart | 6 +++++- test/widget_test.dart | 2 ++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/lib/claim.dart b/lib/claim.dart index 72333a2..ce505bb 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'); @@ -202,11 +206,13 @@ 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(); + 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'); diff --git a/lib/james_messages.dart b/lib/james_messages.dart index 6e4e3f4..c69b3cd 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( diff --git a/lib/nearby.dart b/lib/nearby.dart index dab4526..d02dde9 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -135,7 +135,11 @@ class NearbyState extends State { 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 diff --git a/test/widget_test.dart b/test/widget_test.dart index db6fa1f..8a2f534 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -443,6 +443,7 @@ void main() { JamesMessages.nearbyNoneFound, JamesMessages.nearbyErrorPermission, JamesMessages.nearbyErrorGeneral, + JamesMessages.errorOffline, JamesMessages.claimOutOfRange, JamesMessages.claimSuccessRare, JamesMessages.claimSuccessStandard, @@ -467,6 +468,7 @@ void main() { JamesMessages.nearbyNoneFound, JamesMessages.nearbyErrorPermission, JamesMessages.nearbyErrorGeneral, + JamesMessages.errorOffline, JamesMessages.claimOutOfRange, JamesMessages.claimSuccessRare, JamesMessages.claimSuccessStandard, From b6fc300b2eaa9490317b4a2a5f9d0299a91c91e9 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 18:35:09 +0100 Subject: [PATCH 21/37] fix: redact password from BLoC event toString() debug output PasswordChanged and LoginWithCredentialsPressed/Submitted were logging the raw password in their toString() representations, which could appear in debug logs or Flutter DevTools. Co-Authored-By: Claude Sonnet 4.6 --- lib/login/bloc/login_event.dart | 6 ++---- lib/register/bloc/register_event.dart | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) 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/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 From ba6b0b0e23ca2781843ede0116434974a08be2f2 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 19:10:46 +0100 Subject: [PATCH 22/37] fix: use theme surface colour for login/register loading overlay Hard-coded white overlay was jarring in dark mode. Now uses Theme.of(context).colorScheme.surface at 70% opacity, which adapts correctly to both light and dark themes. Co-Authored-By: Claude Sonnet 4.6 --- lib/login/login_form.dart | 2 +- lib/register/register_form.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/login/login_form.dart b/lib/login/login_form.dart index ec7357c..0a8b90a 100644 --- a/lib/login/login_form.dart +++ b/lib/login/login_form.dart @@ -124,7 +124,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), ), diff --git a/lib/register/register_form.dart b/lib/register/register_form.dart index 34f86b5..80da390 100644 --- a/lib/register/register_form.dart +++ b/lib/register/register_form.dart @@ -113,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), ), From 1285347d24b2a5b6572d8e252dcba0b268140fe7 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:20:23 +0100 Subject: [PATCH 23/37] fix: replace hard-coded grey secondary text with onSurfaceVariant Colors.grey.shadeXXX failed dark-mode contrast (bodySmall text at ~3.8:1 on the dark surface) in register, nearby, claim, leaderboard, and friends screens. Replaced with Theme.of(context).colorScheme.onSurfaceVariant which adapts correctly to both light and dark themes. Co-Authored-By: Claude Sonnet 4.6 --- lib/claim.dart | 12 ++++++------ lib/friends_screen.dart | 8 ++++---- lib/leaderboard_screen.dart | 9 +++++---- lib/nearby.dart | 8 ++++---- lib/register/register_screen.dart | 2 +- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/lib/claim.dart b/lib/claim.dart index ce505bb..43ab0c9 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -382,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), @@ -466,7 +466,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), @@ -585,7 +585,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), ), ], ), @@ -621,7 +621,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), @@ -681,7 +681,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), ), ], ), @@ -724,7 +724,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..bd1dbf7 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, ), ), @@ -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/leaderboard_screen.dart b/lib/leaderboard_screen.dart index 0d92287..10b67b7 100644 --- a/lib/leaderboard_screen.dart +++ b/lib/leaderboard_screen.dart @@ -100,7 +100,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 +134,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 +163,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/nearby.dart b/lib/nearby.dart index d02dde9..9687c76 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -201,7 +201,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), @@ -281,7 +281,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( @@ -289,7 +289,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( @@ -339,7 +339,7 @@ class NearbyState extends State { style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: Colors.grey), + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ), 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, ), From 068e0e834e418e19a4f3a8cf9bb1e7d4a929609d Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:21:08 +0100 Subject: [PATCH 24/37] fix: replace grey secondary text in FuzzyCompass with onSurfaceVariant Co-Authored-By: Claude Sonnet 4.6 --- lib/fuzzy_compass.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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), ), ], ], From a9276f5024ad7f1eb9fa443a60df68a1df05799b Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:22:07 +0100 Subject: [PATCH 25/37] fix: guard claims postbox-field access against missing/undefined d.data().postboxes was cast directly to string and then .replace() called on it. A malformed or schema-mismatched claim document would throw a TypeError crashing the entire nearbyPostboxes or startScoring call. Now filters out non-string entries before processing. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/nearbyPostboxes.ts | 8 ++++---- functions/src/startScoring.ts | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/functions/src/nearbyPostboxes.ts b/functions/src/nearbyPostboxes.ts index bedd071..ffd5aed 100644 --- a/functions/src/nearbyPostboxes.ts +++ b/functions/src/nearbyPostboxes.ts @@ -46,10 +46,10 @@ export const nearbyPostboxes = functions.https.onCall(async (request) => { // 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 => { - const ref = d.data().postboxes as string; - return ref.replace("/postbox/", ""); - }) + 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 diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index ea6cf5c..7b128f6 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -51,11 +51,10 @@ export const startScoring = functions.https.onCall(async (request) => { .where('dailyDate', '==', todayLondon) .get(); const userClaimedKeys = new Set( - userClaimsSnap.docs.map(d => { - const ref = d.data().postboxes as string; - // stored as "/postbox/{key}" → extract just the key - return ref.replace('/postbox/', ''); - }) + 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, From ea32bcab05311cb5c6004539e9662cdd3bde52cc Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:22:42 +0100 Subject: [PATCH 26/37] fix: replace grey subtitle text in LoginScreen with onSurfaceVariant Co-Authored-By: Claude Sonnet 4.6 --- lib/login/login_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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), From 60cd31be757db7929582ab12421c7bde9b80f21d Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:23:23 +0100 Subject: [PATCH 27/37] fix: use theme disabled colour for claimed-today monarch card text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colors.grey.shade400 was too light in dark mode for the dimmed "all claimed today" state in the monarch breakdown cards. Replaced with colorScheme.onSurface.withValues(alpha: 0.38) — the Material 3 semantic value for disabled/de-emphasised text. Co-Authored-By: Claude Sonnet 4.6 --- lib/nearby.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/nearby.dart b/lib/nearby.dart index 9687c76..4d83237 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -444,7 +444,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, ), ), @@ -452,7 +454,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( @@ -462,7 +464,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, From 51ae9325c728516bbebe17e65e521e1e78143630 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:33:45 +0100 Subject: [PATCH 28/37] feat: forgot password and change password Forgot password (login screen): - "Forgot password?" TextButton below the password field - AlertDialog pre-fills the email field if the user already typed one - Calls sendPasswordResetEmail; always shows a generic success snackbar regardless of whether the address is registered (prevents user enumeration) - Only shows invalid-email specifically; all other errors are generic Change password (settings): - "Change password" ListTile in the Account section - Only rendered for email/password users (hidden for Google-only accounts) - Three-field dialog: current password, new password, confirm new password - All three fields have show/hide visibility toggles - Reauthenticates with current password before calling updatePassword - Maps wrong-password/invalid-credential and weak-password to clear messages Co-Authored-By: Claude Sonnet 4.6 --- lib/login/login_form.dart | 83 +++++++++++++++++++++- lib/settings_screen.dart | 140 ++++++++++++++++++++++++++++++++++++++ lib/user_repository.dart | 25 +++++++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/lib/login/login_form.dart b/lib/login/login_form.dart index 0a8b90a..37781b2 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 @@ -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: (ctx) => 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.pop(ctx, true), + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, 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/settings_screen.dart b/lib/settings_screen.dart index 14cddab..20893e0 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -144,6 +144,139 @@ class _SettingsScreenState extends State { } } + Future _changePassword() async { + final currentPwCtrl = TextEditingController(); + final newPwCtrl = TextEditingController(); + final confirmPwCtrl = TextEditingController(); + final formKey = GlobalKey(); + + try { + final confirmed = await showDialog( + context: context, + builder: (ctx) { + bool showCurrent = false; + bool showNew = false; + bool showConfirm = false; + return StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: const Text('Change password'), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: currentPwCtrl, + obscureText: !showCurrent, + decoration: InputDecoration( + labelText: 'Current password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(showCurrent + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setDialogState(() => showCurrent = !showCurrent), + ), + ), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + ), + const SizedBox(height: AppSpacing.md), + TextFormField( + controller: newPwCtrl, + obscureText: !showNew, + decoration: InputDecoration( + labelText: 'New password', + helperText: 'At least 6 characters', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(showNew + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setDialogState(() => showNew = !showNew), + ), + ), + validator: (v) { + if (v == null || v.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + const SizedBox(height: AppSpacing.md), + TextFormField( + controller: confirmPwCtrl, + obscureText: !showConfirm, + decoration: InputDecoration( + labelText: 'Confirm new password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(showConfirm + ? Icons.visibility_off_outlined + : Icons.visibility_outlined), + onPressed: () => + setDialogState(() => showConfirm = !showConfirm), + ), + ), + validator: (v) => v != newPwCtrl.text + ? 'Passwords don\'t match' + : null, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + Navigator.pop(ctx, true); + } + }, + 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, @@ -256,6 +389,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 100ed1f..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(), From cd94a0849f21e981fb1cc4353df0188b1588735e Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:45:04 +0100 Subject: [PATCH 29/37] test: add UserRepository tests for sendPasswordResetEmail and changePassword - sendPasswordResetEmail: verifies method completes without error - changePassword: verifies FirebaseAuthException thrown when no user signed in - changePassword: verifies success path for a signed-in email user (mock reauthenticateWithCredential always succeeds; tests the call chain) Co-Authored-By: Claude Sonnet 4.6 --- test/widget_test.dart | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/widget_test.dart b/test/widget_test.dart index 8a2f534..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, + ); + }); }); // --------------------------------------------------------------------------- From 91b4cfdfeffc0385d2e51040e5ab8b710a4b0606 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:52:03 +0100 Subject: [PATCH 30/37] fix: eliminate _dependents.isEmpty crash in settings dialogs Replaced GlobalKey + TextFormField + formKey.currentState?.validate() in both _editDisplayName and _changePassword with StatefulBuilder + TextField + inline error-string state. Calling validate() in the same callback as Navigator.pop scheduled a form rebuild after the dialog context was deactivated, triggering the '_dependents.isEmpty' Flutter assertion. The new approach updates error text via setDialogState and only calls Navigator.pop when ctx.mounted, avoiding any cross-frame context access. Co-Authored-By: Claude Sonnet 4.6 --- lib/settings_screen.dart | 170 +++++++++++++++++++++++---------------- 1 file changed, 102 insertions(+), 68 deletions(-) diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index 20893e0..9dca6f8 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -36,44 +36,54 @@ class _SettingsScreenState extends State { final controller = TextEditingController( text: FirebaseAuth.instance.currentUser?.displayName ?? '', ); - final formKey = GlobalKey(); final String? newName; try { 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()); - } - }, - ), - ), - 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'), - ), - ], + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + String? errorText; + + void trySubmit() { + final error = Validators.displayNameError(controller.text.trim()); + if (error != null) { + setDialogState(() => errorText = error); + return; + } + if (ctx.mounted) Navigator.pop(ctx, 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 (ctx.mounted) Navigator.pop(ctx); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: trySubmit, + child: const Text('Save'), + ), + ], + ); + }, ), ); } finally { @@ -148,7 +158,6 @@ class _SettingsScreenState extends State { final currentPwCtrl = TextEditingController(); final newPwCtrl = TextEditingController(); final confirmPwCtrl = TextEditingController(); - final formKey = GlobalKey(); try { final confirmed = await showDialog( @@ -157,20 +166,42 @@ class _SettingsScreenState extends State { bool showCurrent = false; bool showNew = false; bool showConfirm = false; + String? currentError; + String? newError; + String? confirmError; return StatefulBuilder( - builder: (ctx, setDialogState) => AlertDialog( - title: const Text('Change password'), - content: Form( - key: formKey, - child: Column( + builder: (ctx, 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 (ctx.mounted) Navigator.pop(ctx, true); + } + + return AlertDialog( + title: const Text('Change password'), + content: Column( mainAxisSize: MainAxisSize.min, children: [ - TextFormField( + 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 @@ -179,17 +210,21 @@ class _SettingsScreenState extends State { setDialogState(() => showCurrent = !showCurrent), ), ), - validator: (v) => - (v == null || v.isEmpty) ? 'Required' : null, + onChanged: (_) { + if (currentError != null) { + setDialogState(() => currentError = null); + } + }, ), const SizedBox(height: AppSpacing.md), - TextFormField( + 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 @@ -198,20 +233,18 @@ class _SettingsScreenState extends State { setDialogState(() => showNew = !showNew), ), ), - validator: (v) { - if (v == null || v.length < 6) { - return 'Password must be at least 6 characters'; - } - return null; + onChanged: (_) { + if (newError != null) setDialogState(() => newError = null); }, ), const SizedBox(height: AppSpacing.md), - TextFormField( + 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 @@ -220,28 +253,29 @@ class _SettingsScreenState extends State { setDialogState(() => showConfirm = !showConfirm), ), ), - validator: (v) => v != newPwCtrl.text - ? 'Passwords don\'t match' - : null, + onChanged: (_) { + if (confirmError != null) { + setDialogState(() => confirmError = null); + } + }, + onSubmitted: (_) => trySubmit(), ), ], ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - Navigator.pop(ctx, true); - } - }, - child: const Text('Update'), - ), - ], - ), + actions: [ + TextButton( + onPressed: () { + if (ctx.mounted) Navigator.pop(ctx, false); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: trySubmit, + child: const Text('Update'), + ), + ], + ); + }, ); }, ); From b78937a7a9f0d87c57667ed97cea1612d07a9409 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:54:57 +0100 Subject: [PATCH 31/37] fix: use theme-adaptive colours for empty-state and error icons Replaced remaining hard-coded Colors.grey.shade300/400 on decorative empty-state icons (Nearby, Claim, Friends, Leaderboard) with colorScheme.onSurface.withValues(alpha: 0.2) and the error icon with colorScheme.onSurfaceVariant. In dark mode, the grey shades were too light relative to the surface, giving a harsh contrast on placeholder icons. The adaptive values scale with the theme so both modes look appropriately muted. The intentional silver (2nd-place trophy) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- lib/claim.dart | 3 ++- lib/friends_screen.dart | 4 ++-- lib/leaderboard_screen.dart | 7 ++++--- lib/nearby.dart | 6 ++++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/claim.dart b/lib/claim.dart index 43ab0c9..1edfa7a 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -450,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)}', diff --git a/lib/friends_screen.dart b/lib/friends_screen.dart index bd1dbf7..125f5ee 100644 --- a/lib/friends_screen.dart +++ b/lib/friends_screen.dart @@ -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', diff --git a/lib/leaderboard_screen.dart b/lib/leaderboard_screen.dart index 10b67b7..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( diff --git a/lib/nearby.dart b/lib/nearby.dart index 4d83237..11a0fb8 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -186,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', @@ -326,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)}', From 960012819a4c062071eb940daed13e3ed49534bf Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 20:56:41 +0100 Subject: [PATCH 32/37] fix: move errorText outside StatefulBuilder.builder in _editDisplayName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit errorText was declared inside StatefulBuilder.builder, causing it to reset to null on every rebuild triggered by setDialogState — making validation errors invisible (set on one frame, cleared on the next). Moving it to the outer showDialog builder closure means setDialogState mutates it by reference, so the rebuilt TextField correctly reads the persisted error value. Co-Authored-By: Claude Sonnet 4.6 --- lib/settings_screen.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index 9dca6f8..a222490 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -39,12 +39,13 @@ class _SettingsScreenState extends State { 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: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) { - String? errorText; - void trySubmit() { final error = Validators.displayNameError(controller.text.trim()); if (error != null) { From 158e6fead171fe0fe1f0a9143bf836c76b3003e5 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 21:16:51 +0100 Subject: [PATCH 33/37] fix: navigate from stable outer context to eliminate _dependents.isEmpty crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '_dependents.isEmpty' assertion fires because Navigator.pop(ctx, value) was called using the dialog's own BuildContext (ctx). When the button tap completes, Flutter's InkWell schedules a ripple/state-change rebuild against ctx. Simultaneously, Navigator.pop deactivates the dialog's element tree — including the InheritedElements that ctx's descendants depend on. The pending ripple rebuild then tries to access those deactivating InheritedElements, triggering the assertion. Fix: replace every Navigator.pop(ctx, ...) inside showDialog builders with Navigator.of(context).pop(...) where context is this widget state's stable BuildContext. The settings screen is never deactivated while its own dialog is open, so there is no race. Applied to _editDisplayName, _changePassword, _signOut (settings_screen.dart) and _onForgotPassword (login_form.dart). The dialog builder parameter is renamed to _ to make clear it is unused. Co-Authored-By: Claude Sonnet 4.6 --- lib/login/login_form.dart | 8 ++++---- lib/settings_screen.dart | 27 ++++++++++++++++----------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/login/login_form.dart b/lib/login/login_form.dart index 37781b2..de6cc6c 100644 --- a/lib/login/login_form.dart +++ b/lib/login/login_form.dart @@ -161,7 +161,7 @@ class _LoginFormState extends State { try { final confirmed = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (_) => AlertDialog( title: const Text('Reset password'), content: Column( mainAxisSize: MainAxisSize.min, @@ -176,7 +176,7 @@ class _LoginFormState extends State { keyboardType: TextInputType.emailAddress, autocorrect: false, textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => Navigator.pop(ctx, true), + onFieldSubmitted: (_) => Navigator.of(context).pop(true), decoration: const InputDecoration( labelText: 'Email', prefixIcon: Icon(Icons.email_outlined), @@ -186,11 +186,11 @@ class _LoginFormState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx, false), + onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(ctx, true), + onPressed: () => Navigator.of(context).pop(true), child: const Text('Send link'), ), ], diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index a222490..ad61d5a 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -44,15 +44,20 @@ class _SettingsScreenState extends State { String? errorText; newName = await showDialog( context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setDialogState) { + builder: (_) => StatefulBuilder( + builder: (_, setDialogState) { void trySubmit() { final error = Validators.displayNameError(controller.text.trim()); if (error != null) { setDialogState(() => errorText = error); return; } - if (ctx.mounted) Navigator.pop(ctx, controller.text.trim()); + // 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( @@ -74,7 +79,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () { - if (ctx.mounted) Navigator.pop(ctx); + if (mounted) Navigator.of(context).pop(); }, child: const Text('Cancel'), ), @@ -163,7 +168,7 @@ class _SettingsScreenState extends State { try { final confirmed = await showDialog( context: context, - builder: (ctx) { + builder: (_) { bool showCurrent = false; bool showNew = false; bool showConfirm = false; @@ -171,7 +176,7 @@ class _SettingsScreenState extends State { String? newError; String? confirmError; return StatefulBuilder( - builder: (ctx, setDialogState) { + builder: (_, setDialogState) { void trySubmit() { final ce = currentPwCtrl.text.isEmpty ? 'Required' : null; final ne = newPwCtrl.text.length < 6 @@ -188,7 +193,7 @@ class _SettingsScreenState extends State { }); return; } - if (ctx.mounted) Navigator.pop(ctx, true); + if (mounted) Navigator.of(context).pop(true); } return AlertDialog( @@ -266,7 +271,7 @@ class _SettingsScreenState extends State { actions: [ TextButton( onPressed: () { - if (ctx.mounted) Navigator.pop(ctx, false); + if (mounted) Navigator.of(context).pop(false); }, child: const Text('Cancel'), ), @@ -315,16 +320,16 @@ class _SettingsScreenState extends State { 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'), ), ], From 6e9a12e7550d41398a121e8207ec3d66afc3ae89 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 21:20:27 +0100 Subject: [PATCH 34/37] fix: use stable outer context in _chooseDistanceUnit bottom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consistent with the same pattern applied to all dialogs in this file — shadow the builder's context parameter with _ and navigate via Navigator.of(context) using the screen's stable BuildContext. Co-Authored-By: Claude Sonnet 4.6 --- lib/settings_screen.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index ad61d5a..6f96da4 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -122,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: [ @@ -137,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), ), ], ), From 8aae5dcb5fc26d19dc8bebad2550dfa92d18a0dd Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 21:41:29 +0100 Subject: [PATCH 35/37] feat: use SVG native mouth/eye shapes for animation; remove canvas overlay painters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-computes 8 SVG variant strings (4 mouth positions × 2 eye states) at init via rootBundle. build() selects the correct string each frame using discrete frame indexing. _MouthOverlayPainter and _BlinkOverlayPainter deleted; _StarEyesOverlayPainter retained for star-eyes effect. Co-Authored-By: Claude Sonnet 4.6 --- lib/postman_james_svg.dart | 242 ++++++++++++++++--------------------- 1 file changed, 101 insertions(+), 141 deletions(-) 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. From cd76986cc2d24acad88dbb78df646a2ac8e44d79 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 21:43:51 +0100 Subject: [PATCH 36/37] feat: postbox.svg in intro, James side-by-side layout, remove character name - Replace mail icon placeholder with assets/postbox.svg (120px) on stage step - Walk-in step: James slides in beside the postbox (80px) in a Row - Dialogue steps: postbox (64px) and James (90px, isTalking) shown side-by-side - Walk-in label changed to 'Someone arrives...' (name suppressed) - introStep2 dialogue no longer introduces character by name Co-Authored-By: Claude Sonnet 4.6 --- lib/intro.dart | 35 ++++++++++++++++++++++++++--------- lib/james_messages.dart | 2 +- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/intro.dart b/lib/intro.dart index 24132b0..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), diff --git a/lib/james_messages.dart b/lib/james_messages.dart index c69b3cd..b198941 100644 --- a/lib/james_messages.dart +++ b/lib/james_messages.dart @@ -174,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( From 2732651cc412d1ad25bcb1f5628dc5c7f30dc2f6 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 21:46:31 +0100 Subject: [PATCH 37/37] deps: add rive for future character animation Co-Authored-By: Claude Sonnet 4.6 --- pubspec.lock | 24 ++++++++++++++++++++++++ pubspec.yaml | 1 + 2 files changed, 25 insertions(+) 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