feat: Tier 3 intro polish — Rive character animation#84
Draft
feat: Tier 3 intro polish — Rive character animation#84
Conversation
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 '<delete>' for readability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
"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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…assword - 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 <noreply@anthropic.com>
Replaced GlobalKey<FormState> + 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…pty crash 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…erlay painters 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 <noreply@anthropic.com>
…er 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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds the
rive(^0.13.0) package as a dependency.Why
Rive is the gold standard for game-quality interactive character animation in Flutter. A Rive-rigged version of the postman character would replace the current SVG + overlay approach entirely, enabling:
.rivfile with zero per-frame Dart codeNot wired up yet
Requires a
.rivasset created in the Rive editor (rive.app). This PR is a stake in the ground for when that asset exists.Test plan
flutter pub getsucceedsflutter analyzeclean