Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2b98f5d
fix: geohash precision and display name edit dialog
code418 Apr 12, 2026
f8337fa
docs: document import precision coupling in setPrecision tests
code418 Apr 12, 2026
09b8cb2
feat: per-user daily claim tracking (independent of other players)
code418 Apr 12, 2026
074f8f4
fix: per-user cipher claimed counts in nearbyPostboxes
code418 Apr 12, 2026
fab6aae
fix: per-user points min/max in nearbyPostboxes
code418 Apr 12, 2026
652691f
chore: gitignore root-level dev utility scripts
code418 Apr 12, 2026
1265650
feat: per-user unclaimed-only compass in nearbyPostboxes
code418 Apr 12, 2026
47652dc
fix: hide compass when all nearby postboxes already claimed
code418 Apr 12, 2026
8dae3dc
fix: explicitly delete stale monarch/reference fields on reimport
code418 Apr 12, 2026
9ad72e3
docs: update CLAUDE.md to reflect current codebase state
code418 Apr 12, 2026
3b98c16
chore: gitignore root-level postman_james.svg dev copy
code418 Apr 12, 2026
bccbd43
fix: normalise /Claim route to lowercase /claim
code418 Apr 12, 2026
bf36b62
fix: make JamesController.of() null-safe to prevent deep-link crash
code418 Apr 12, 2026
e30a8df
refactor: simplify Authenticated state — remove dead displayName field
code418 Apr 12, 2026
f2e86fe
refactor: remove unused UserRepository.getUser() method
code418 Apr 12, 2026
4e9238a
fix: pluralise claim button when multiple postboxes are in range
code418 Apr 12, 2026
d4074b1
fix: remove spurious Navigator.pop() after successful registration
code418 Apr 12, 2026
d7f8a25
fix: show correct outro message in intro replay mode
code418 Apr 12, 2026
b41e221
fix: pluralise quiz heading when multiple postboxes are in range
code418 Apr 12, 2026
25b5078
feat: add offline-specific James message for no-network errors
code418 Apr 12, 2026
b6fc300
fix: redact password from BLoC event toString() debug output
code418 Apr 12, 2026
ba6b0b0
fix: use theme surface colour for login/register loading overlay
code418 Apr 12, 2026
1285347
fix: replace hard-coded grey secondary text with onSurfaceVariant
code418 Apr 12, 2026
068e0e8
fix: replace grey secondary text in FuzzyCompass with onSurfaceVariant
code418 Apr 12, 2026
a9276f5
fix: guard claims postbox-field access against missing/undefined
code418 Apr 12, 2026
ea32bca
fix: replace grey subtitle text in LoginScreen with onSurfaceVariant
code418 Apr 12, 2026
60cd31b
fix: use theme disabled colour for claimed-today monarch card text
code418 Apr 12, 2026
51ae932
feat: forgot password and change password
code418 Apr 12, 2026
cd94a08
test: add UserRepository tests for sendPasswordResetEmail and changeP…
code418 Apr 12, 2026
91b4cfd
fix: eliminate _dependents.isEmpty crash in settings dialogs
code418 Apr 12, 2026
b78937a
fix: use theme-adaptive colours for empty-state and error icons
code418 Apr 12, 2026
9600128
fix: move errorText outside StatefulBuilder.builder in _editDisplayName
code418 Apr 12, 2026
158e6fe
fix: navigate from stable outer context to eliminate _dependents.isEm…
code418 Apr 12, 2026
6e9a12e
fix: use stable outer context in _chooseDistanceUnit bottom sheet
code418 Apr 12, 2026
8aae5dc
feat: use SVG native mouth/eye shapes for animation; remove canvas ov…
code418 Apr 12, 2026
cd76986
feat: postbox.svg in intro, James side-by-side layout, remove charact…
code418 Apr 12, 2026
2732651
deps: add rive for future character animation
code418 Apr 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,9 @@ devtools_options.yaml

# Accidental Overpass API query downloads
interpreter?data=*

# Root-level dev utility scripts and one-off data files (not app code)
/test.py
/find_postboxes.js
/ciiir_postboxes.json
/postman_james.svg
49 changes: 27 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <overpass-export.json> --project the-postbox-game`. Stores each postbox as `{ geohash (precision 9), geopoint, overpass_id, monarch?, reference? }` in `postbox/{osm_<id>}` 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

Expand All @@ -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

Expand All @@ -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.
Expand Down
25 changes: 21 additions & 4 deletions functions/import_postboxes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -112,10 +116,17 @@ function buildDoc(node) {
const cipher = rawCipher.toUpperCase().trim();
if (cipher && VALID_CIPHERS.has(cipher)) {
doc.monarch = cipher;
} else {
// Explicitly delete any stale monarch field so that a reimport (which uses
// merge:true) removes it when the OSM data no longer has a known cipher.
// Without this, a postbox previously tagged e.g. EIIR would keep that
// value in Firestore even after the OSM tag is corrected or removed.
doc.monarch = admin.firestore.FieldValue.delete();
}

const ref = node.tags?.ref ?? '';
if (ref) doc.reference = ref;
// Same as monarch: explicitly delete stale reference values on reimport.
doc.reference = ref || admin.firestore.FieldValue.delete();

return doc;
}
Expand Down Expand Up @@ -177,7 +188,13 @@ async function main() {
for (const node of sample) {
const docId = `osm_${node.id}`;
const doc = buildDoc(node);
console.log(` ${docId}:`, JSON.stringify({ ...doc, geopoint: `GeoPoint(${node.lat}, ${node.lon})` }));
// Substitute FieldValue sentinels with a readable placeholder for dry-run display.
const FieldValue = admin.firestore.FieldValue;
const display = Object.fromEntries(
Object.entries({ ...doc, geopoint: `GeoPoint(${node.lat}, ${node.lon})` })
.map(([k, v]) => [k, (v instanceof FieldValue) ? '<delete>' : v])
);
console.log(` ${docId}:`, JSON.stringify(display));
}
return;
}
Expand Down
93 changes: 88 additions & 5 deletions functions/src/nearbyPostboxes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import "./adminInit";
import * as admin from "firebase-admin";
import * as functions from "firebase-functions";
import { getTodayLondon } from "./_dateUtils";
import { getPoints } from "./_getPoints";
import { lookupPostboxes } from "./_lookupPostboxes";

interface NearbyCallData {
Expand Down Expand Up @@ -28,18 +31,98 @@ export const nearbyPostboxes = functions.https.onCall(async (request) => {
}
// Cap radius at 2km to prevent runaway Firestore queries.
const clampedMeters = Math.min(meters, 2000);
const full = await lookupPostboxes(lat, lng, clampedMeters);
const uid = request.auth!.uid;
const todayLondon = getTodayLondon();

// Run postbox lookup and today's user-claims query in parallel.
const [full, userClaimsSnap] = await Promise.all([
lookupPostboxes(lat, lng, clampedMeters),
admin.firestore().collection("claims")
.where("userid", "==", uid)
.where("dailyDate", "==", todayLondon)
.get(),
]);

// Build the set of postbox IDs already claimed by THIS user today.
// The postboxes field is stored as "/postbox/{key}".
const userClaimedKeys = new Set(
userClaimsSnap.docs
.map(d => d.data().postboxes as string | undefined)
.filter((ref): ref is string => typeof ref === "string")
.map(ref => ref.replace("/postbox/", ""))
);

// Strip precise location fields (geopoint, geohash, dailyClaim) before
// sending to the client. The client only needs monarch (for the quiz) and
// claimedToday (for UI state); all other fields are internal.
// sending to the client. Override claimedToday with per-user status so
// the Claim and Nearby screens only show boxes as "claimed" when the
// current user has claimed them — other players' claims never block.
const slimPostboxes: Record<string, { monarch?: string; claimedToday: boolean }> = {};
for (const [id, pb] of Object.entries(full.postboxes)) {
slimPostboxes[id] = {
...(pb.monarch !== undefined ? { monarch: pb.monarch } : {}),
claimedToday: pb.claimedToday ?? false,
claimedToday: userClaimedKeys.has(id),
};
}

return { ...full, postboxes: slimPostboxes };
// Build per-user counts, overriding both the total claimedToday and the
// per-cipher claimed counts ({cipher}_claimed) so that the Nearby screen's
// monarch breakdown shows the correct "X available" for the current user.
const updatedCounts: Record<string, number> = {};
// Copy all global counts first, then override the _claimed keys.
for (const [k, v] of Object.entries(full.counts)) {
if (!k.endsWith("_claimed")) {
updatedCounts[k] = v as number;
}
}

// Accumulate per-user cipher claimed counts from the user's actual claims.
// full.postboxes[id].monarch is available because lookupPostboxes spreads
// the full PostboxDoc into the result map.
let myClaimedCount = 0;
for (const [id, pb] of Object.entries(full.postboxes)) {
if (userClaimedKeys.has(id)) {
myClaimedCount++;
if (pb.monarch !== undefined) {
const ck = `${pb.monarch}_claimed`;
updatedCounts[ck] = (updatedCounts[ck] ?? 0) + 1;
}
}
}
updatedCounts.claimedToday = myClaimedCount;

// Compute per-user points range: exclude boxes already claimed by THIS user
// today so the "Worth X–Y pts" display reflects what's actually claimable.
let unclaimedMin = Infinity;
let unclaimedMax = 0;
for (const [id, pb] of Object.entries(full.postboxes)) {
if (!userClaimedKeys.has(id)) {
const pts = pb.monarch !== undefined ? getPoints(pb.monarch) : 2;
if (pts < unclaimedMin) unclaimedMin = pts;
if (pts > unclaimedMax) unclaimedMax = pts;
}
}
const updatedPoints = {
min: isFinite(unclaimedMin) ? unclaimedMin : 0,
max: unclaimedMax,
};

// Compute per-user compass: only include unclaimed boxes so "Where to look"
// guides the user toward boxes they can still claim, not ones already taken.
const updatedCompass: Record<string, number> = {};
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,
};
});
Loading
Loading