From fab6aa504fc36c0dbac9f9128c14682bd78d781d Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Fri, 24 Apr 2026 17:36:44 +0100 Subject: [PATCH 1/5] fix(wear): cache user doc stream to avoid re-subscribe on rebuild _userDocStream() was invoked directly inside build(), so each rebuild created a new Firestore snapshot subscription. This caused unnecessary reads and a brief "0 pts / 0 claimed" flicker every time the sibling StreamBuilder emitted (e.g. streak change). Cached in a late final field to match the existing _streakStream pattern. Co-Authored-By: Claude Opus 4.7 --- lib/wear/wear_status_page.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/wear/wear_status_page.dart b/lib/wear/wear_status_page.dart index 6a2147a..093233d 100644 --- a/lib/wear/wear_status_page.dart +++ b/lib/wear/wear_status_page.dart @@ -18,10 +18,14 @@ class WearStatusPage extends StatefulWidget { class _WearStatusPageState extends State { final StreakService _streakService = StreakService(); late final Stream _streakStream = _streakService.streakStream(); + // Cache so the StreamBuilder doesn't re-subscribe (and briefly show empty + // stats) on every rebuild. Computed once in initState from the uid at mount. + late final Stream>>? _userDocStream = + _buildUserDocStream(); String? get _displayName => FirebaseAuth.instance.currentUser?.displayName; - Stream>>? _userDocStream() { + Stream>>? _buildUserDocStream() { final uid = FirebaseAuth.instance.currentUser?.uid; if (uid == null) return null; return FirebaseFirestore.instance.collection('users').doc(uid).snapshots(); @@ -71,7 +75,7 @@ class _WearStatusPageState extends State { // Lifetime points StreamBuilder>>( - stream: _userDocStream(), + stream: _userDocStream, builder: (context, snap) { final data = snap.data?.data(); final lifetimePoints = From d05038869a1cf56638260af71c4b06a68f21e387 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Fri, 24 Apr 2026 18:02:27 +0100 Subject: [PATCH 2/5] fix(wear): pass rotation to compass painter so N label stays upright FuzzyCompassPainter counter-rotates the 'N' marker glyph so it remains readable while its ring position still tracks magnetic north. The wear compass page wrapped the painter in Transform.rotate(-rotation) but didn't forward rotation to the painter, so the N letter spun with the canvas and appeared sideways whenever the watch wasn't facing north. Co-Authored-By: Claude Opus 4.7 --- lib/wear/wear_compass_page.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/wear/wear_compass_page.dart b/lib/wear/wear_compass_page.dart index 8ab96c3..96fcaf2 100644 --- a/lib/wear/wear_compass_page.dart +++ b/lib/wear/wear_compass_page.dart @@ -206,7 +206,13 @@ class _WearCompassPageState extends State { angle: -rotation, child: CustomPaint( size: Size(size, size), - painter: FuzzyCompassPainter(sectors: sectorValues), + // Pass rotation so the painter counter-rotates the 'N' label; + // otherwise it spins with the canvas and is unreadable whenever + // the watch is not pointing north. + painter: FuzzyCompassPainter( + sectors: sectorValues, + rotation: rotation, + ), ), ), // Centre count overlay (doesn't rotate) From 75876ca7197ce2f0ad29736707e6c821936d5365 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Fri, 24 Apr 2026 18:29:06 +0100 Subject: [PATCH 3/5] fix(wear): normalise scan counts via num to avoid int cast throws Cloud Functions serialise JS numbers as either int or double depending on value, so `as int?` throws on a double. wear_claim_page and wear_compass_page both cast counts['total'] (and compass sector values) with `as int?`, which would crash the scan flow on any backend response where the count came through as a double. Normalise via `num?.toInt()` and reconstruct the compass counts map entry-by-entry for the same reason. Co-Authored-By: Claude Opus 4.7 --- lib/wear/wear_claim_page.dart | 6 ++++-- lib/wear/wear_compass_page.dart | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/wear/wear_claim_page.dart b/lib/wear/wear_claim_page.dart index 32e1baf..b8f2445 100644 --- a/lib/wear/wear_claim_page.dart +++ b/lib/wear/wear_claim_page.dart @@ -52,8 +52,10 @@ class _WearClaimPageState extends State { }); if (!mounted) return; final counts = result.data['counts'] ?? {}; - final total = (counts['total'] as int?) ?? 0; - final claimed = (counts['claimedToday'] as int?) ?? 0; + // Cloud Functions serialise JS numbers as either int or double; `as int?` + // would throw on a double, so normalise via num. + final total = (counts['total'] as num?)?.toInt() ?? 0; + final claimed = (counts['claimedToday'] as num?)?.toInt() ?? 0; _postboxes = Map.from(result.data['postboxes'] ?? {}); setState(() { _count = total; diff --git a/lib/wear/wear_compass_page.dart b/lib/wear/wear_compass_page.dart index 96fcaf2..48174e2 100644 --- a/lib/wear/wear_compass_page.dart +++ b/lib/wear/wear_compass_page.dart @@ -75,8 +75,13 @@ class _WearCompassPageState extends State { final counts = result.data['counts'] ?? {}; final compassRaw = result.data['compass'] ?? {}; setState(() { - _totalCount = (counts['total'] as int?) ?? 0; - _compassCounts = Map.from(compassRaw); + // Cloud Functions serialise JS numbers as either int or double; + // `as int?` would throw on a double, so normalise via num. + _totalCount = (counts['total'] as num?)?.toInt() ?? 0; + _compassCounts = { + for (final e in (compassRaw as Map).entries) + e.key as String: (e.value as num).toInt(), + }; _stage = _CompassStage.results; }); // Haptic pulse to signal scan complete. From eca935af33afb172e6d7d58f1434238b834b3d4f Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Fri, 24 Apr 2026 18:55:41 +0100 Subject: [PATCH 4/5] fix(phone): normalise nearby/claim counts via num to avoid int cast throws The Nearby and Claim screens assigned counts['total'], pts['max'], cipher counts, and compass sector values (all dynamic, originating from HttpsCallable.data) straight into typed int fields. Cloud Functions can serialise a JS number as either int or double depending on value (e.g. aggregations that go through a divide), so any double would crash the scan with a runtime cast error. Mirror the wear-side fix and normalise via `num?.toInt()`. Co-Authored-By: Claude Opus 4.7 --- lib/claim.dart | 11 +++++++---- lib/nearby.dart | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/claim.dart b/lib/claim.dart index 952c5d2..f14cf7e 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -125,11 +125,14 @@ class _ClaimState extends State with TickerProviderStateMixin { postboxes[entry.key as String] = Map.from(entry.value as Map); } + // Cloud Functions serialise JS numbers as either int or double; + // assigning a double to a typed int field throws, so normalise via num. + int asInt(dynamic v) => (v as num?)?.toInt() ?? 0; setState(() { - _count = counts['total'] ?? 0; - _maxPoints = points['max'] ?? 0; - _minPoints = points['min'] ?? 0; - _claimedToday = counts['claimedToday'] ?? 0; + _count = asInt(counts['total']); + _maxPoints = asInt(points['max']); + _minPoints = asInt(points['min']); + _claimedToday = asInt(counts['claimedToday']); _postboxes = postboxes; currentStage = _count > 0 ? ClaimStage.results : ClaimStage.empty; }); diff --git a/lib/nearby.dart b/lib/nearby.dart index 4f743cb..a047335 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -113,23 +113,26 @@ class _NearbyState extends State { final claimedCompass = data['claimedCompass'] != null ? Map.from(data['claimedCompass'] as Map) : {}; + // Cloud Functions serialise JS numbers as either int or double; assigning + // a double to a typed int field throws, so normalise every count via num. + int asInt(dynamic v) => (v as num?)?.toInt() ?? 0; setState(() { - _count = counts['total'] ?? 0; - _maxPoints = pts['max'] ?? 0; - _minPoints = pts['min'] ?? 0; + _count = asInt(counts['total']); + _maxPoints = asInt(pts['max']); + _minPoints = asInt(pts['min']); currentStage = NearbyStage.results; for (final dir in const [ 'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW', ]) { - _compassCounts[dir] = compass[dir] ?? 0; - _claimedCompassCounts[dir] = claimedCompass[dir] ?? 0; + _compassCounts[dir] = asInt(compass[dir]); + _claimedCompassCounts[dir] = asInt(claimedCompass[dir]); } - _claimedToday = counts['claimedToday'] ?? 0; + _claimedToday = asInt(counts['claimedToday']); for (final cipher in MonarchInfo.all) { - _cipherTotals[cipher] = counts[cipher] ?? 0; - _cipherClaimed[cipher] = counts['${cipher}_claimed'] ?? 0; + _cipherTotals[cipher] = asInt(counts[cipher]); + _cipherClaimed[cipher] = asInt(counts['${cipher}_claimed']); } _lastScanned = DateTime.now(); }); From cdcb911d56062e9c012ee6618c34a345ce389495 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sat, 25 Apr 2026 09:56:09 +0100 Subject: [PATCH 5/5] docs(plan): storage-claim-photos --- docs/plans/storage-claim-photos.md | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/plans/storage-claim-photos.md diff --git a/docs/plans/storage-claim-photos.md b/docs/plans/storage-claim-photos.md new file mode 100644 index 0000000..52c5b19 --- /dev/null +++ b/docs/plans/storage-claim-photos.md @@ -0,0 +1,66 @@ +# Plan — Claim photos with moderation + +- **Status:** Proposed +- **Effort:** Large +- **Firebase services:** Cloud Storage, Cloud Functions, (optionally) Cloud Vision API + +## Overview + +Let users attach a photo to a claim ("postbox selfie"). Photos are private by default and moderated before being attached. Adds collectible / scrapbook feel and gives the recap screen visual content. + +## User flow + +1. After a successful claim, a sheet offers "Add a photo?". +2. User takes or picks a photo (never uploads without explicit tap). +3. Upload progress overlay; James comments during wait. +4. On moderation pass, photo appears on that claim in the personal scrapbook and recap. + +## Storage layout + +- `claims-photos/{uid}/{claimId}/original.jpg` — private, user-write, user-read. +- `claims-photos/{uid}/{claimId}/thumb.jpg` — created by Function, used by the client. + +## Moderation + +- Cloud Function `onClaimPhotoUploaded` (Storage trigger): + 1. Runs Cloud Vision `SafeSearchDetection`. + 2. If adult/violence/racy > `LIKELY`, delete both objects and write `moderation/flags/...`. + 3. Otherwise generate a `thumb.jpg` (max 512 px edge) and mark the claim doc with `photoReady: true`. +- Alternative if Vision API cost is a concern: use a cheap on-device check in the client (Mediapipe or ML Kit) first and defer cloud scan. + +## Firestore + +- `claims/{id}` gains `photoReady: bool`, `photoPath: string?`, `thumbPath: string?`. + +## Security rules (Storage) + +``` +match /claims-photos/{uid}/{claimId}/{file} { + allow write: if request.auth.uid == uid && request.resource.size < 5*1024*1024; + allow read: if request.auth.uid == uid; +} +``` + +- Friends-visible mode is out of scope for this PR; photos stay private. + +## Client + +- Add `image_picker` + `flutter_image_compress`. +- Compress to ≤ 1280 px, JPEG quality 80 before upload. +- Retry-safe uploader with resume support. + +## Rollout + +- Flag `feature_claim_photos`. +- Gradually enable: internal → 10 % → 100 %. +- Provide an in-app delete-photo button. + +## Risks + +- Moderation false negatives: ensure strict defaults and reporting flow. +- Storage cost: impose per-user quota (e.g. 500 photos). +- Battery / data usage: compress aggressively; allow "Wi-Fi only upload". + +## Testing + +- Integration test upload → moderation → thumb generation with Firebase emulator + stubbed Vision client.