Skip to content

feat: Tier 3 intro polish — Rive character animation#84

Draft
code418 wants to merge 37 commits intomasterfrom
feat/intro-polish-tier3
Draft

feat: Tier 3 intro polish — Rive character animation#84
code418 wants to merge 37 commits intomasterfrom
feat/intro-polish-tier3

Conversation

@code418
Copy link
Copy Markdown
Owner

@code418 code418 commented Apr 12, 2026

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:

  • Skeletal animation
  • Lip-sync state machines
  • Facial expressions triggered by app events
  • All from a single .riv file with zero per-frame Dart code

Not wired up yet

Requires a .riv asset created in the Rive editor (rive.app). This PR is a stake in the ground for when that asset exists.

Test plan

  • flutter pub get succeeds
  • flutter analyze clean
  • No runtime regressions

code418 and others added 30 commits April 12, 2026 11:02
- 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>
code418 and others added 7 commits April 12, 2026 20:54
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant