Skip to content

feat(route): walk-to-destination mode with corridor + detour scoring#121

Merged
code418 merged 39 commits into
masterfrom
feat/route-mode
May 18, 2026
Merged

feat(route): walk-to-destination mode with corridor + detour scoring#121
code418 merged 39 commits into
masterfrom
feat/route-mode

Conversation

@code418
Copy link
Copy Markdown
Owner

@code418 code418 commented May 15, 2026

Summary

Adds Route Mode: pick a destination, see how many unclaimed postboxes are en-route and how many points they're worth, then walk it with a precise destination indicator + the existing fuzzy compass for postboxes + the existing quiz-driven claim flow as a bottom sheet. Preserves the app's "fuzzy compass, never pinpoint" contract — the server never returns postbox locations.

Built from docs/superpowers/specs/… plan; 15 commits sequenced as algorithm extraction → backend callable → claim-quiz refactor → location streaming → screens (picker, preview, live, completion) → wiring → tests → docs → Android SDK bump.

Screens added

  • DestinationPickerScreen (lib/route/destination_picker_screen.dart) — tap-on-map via PostboxMap.onTap + Nominatim address search bar (1 req/sec throttle, UK-biased).
  • RoutePreviewScreen — pace toggle (Walk 4.5 km/h / Jog 8.5 km/h), corridor slider 50–500 m, extra-time slider 0–60 min (switches mode to detour when > 0), debounced routePostboxes call, "X postboxes worth Y points" headline.
  • LiveRouteScreenpositionStream GPS, precise distance + bearing arrow + ETA at pace, FuzzyCompass rotated to the destination bearing, "Where now, postie?" hint button (picks the highest-count fuzzy sector, translates to ahead/left/right/behind, fires a James line from the new pool). Periodic nearbyPostboxes scan; when a returned box has distance ≤ 30 && !claimedToday, opens the extracted ClaimQuizSheet as a compact: true modal. 60s dedupe so the same box doesn't re-prompt. On arrival (< 25 m) fires a local notification then pushReplacements to RouteCompletionScreen.
  • RouteCompletionScreen — points earned (gold headline), claims, walk time, pace chip, James congratulation line, Done + "Plan another route" CTAs.

Backend changes

  • Shared algorithm module functions/src/_routePlanner.ts — extracted the orienteering beam search + ellipse filter from the existing scripts/plan_route.ts CLI, added a new filterToCorridor. Single source of truth for both the CLI and the new callable.
  • New callable routePostboxes (functions/src/routePostboxes.ts) — auth-gated, validates inputs (30 km hard cap), fetches via the 9-cell geohash prefix pattern, applies the user's claimed-today set, returns ONLY { count, points, directDistanceM, budgetDistanceM, warnings }. Never leaks postbox IDs or locations.
  • Reused (no edits): pointsForMonarch, getTodayLondon, setPrecision, the claims query pattern from nearbyPostboxes/startScoring.

Reuse highlights

  • lib/widgets/claim_quiz_sheet.dart extracted from the 1109-line lib/claim.dart (now 282 lines). Both Route Mode and the existing Claim screen use the same widget — anti-cheat parity, no score-table drift, same James messages.
  • lib/location_service.dart got a new positionStream() alongside the existing one-shot getPosition().
  • The FuzzyCompass widget composes with a new RouteCompassView (destination arrow overlay) — no changes to fuzzy compass internals.

Build / dep changes

  • New Flutter deps: http: ^1.2.0 (for Nominatim).
  • flutter_local_notifications was already in pubspec; T9 added a thin RouteNotifications wrapper rather than touching the existing NotificationService.
  • Android upgrade (last commit): compileSdk 35 → 36, AGP 8.7.0 → 8.9.1, Gradle wrapper 8.9 → 8.11.1, prompted by transitive androidx requirements from the plugin set. Local builds now require JDK 17.

Test plan

  • flutter analyze — clean
  • flutter test — 181 passed (was 105 pre-feature; +76 new across route_session, route_destination_picker, nominatim_service, route_preview_screen, route_live_screen, route_completion_screen, James pools)
  • functions/ npm test — 295 passed (was 274; +21 for routePostboxes and filterToCorridor)
  • CLI regression: node functions/lib/scripts/plan_route.js … still produces Total: 36 points for the canonical 6-postbox fixture
  • Manual phone build with JDK 17 — destination picker → preview → live route → quiz sheet within 30 m → arrival notification → completion. (Local build blocked here only by JDK 17 install; deferred to reviewer / CI.)
  • Backend smoke: deploy routePostboxes against staging, hit it from the Functions shell with a known London start/destination, compare count against a manual Firestore query.

Notes for review

  • One subtle Bash-display gotcha caused two reviewer false-positives during development: the sentinel in geohash prefix queries renders invisibly in terminal diffs. Bytes verified via xxd; the prefix scan is correct.
  • The Android Gradle bump was pulled from the user's pre-existing local stash. Other unrelated WIP (lib/claim.dart auto-skip-to-quiz tweak, history-screen default view, view-toggle styling) remains in stash for post-merge reapplication.

🤖 Generated with Claude Code

code418 and others added 30 commits May 15, 2026 18:55
Move metresBetween, midpoint, filterToEllipse, beamSearch (→ beamSearchOrienteering),
and finalise (→ finaliseRoute) from scripts/plan_route.ts into the new pure
helper module _routePlanner.ts. Add filterToCorridor (corridor / cross-track
filter needed by the upcoming routePostboxes Cloud Function). Smoke test
output unchanged (36 points, 5 stops). Six new mocha tests for filterToCorridor
all passing (274 total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove `visitedKey` from the exported `SearchState` type — it was an
internal beam-search implementation detail that leaked into the public
API. The dedup key is now computed inline inside the expansion loop.
Also add a clarifying comment on the degenerate-segment fallback and
tighten the half-width-0 test to `strictEqual` now that `pb_on`'s
exact-zero perpendicular distance is confirmed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New HTTPS callable that accepts a start/dest pair and corridor or detour
mode, queries nearby postboxes via a geohash prefix scan, strips already-
claimed-today boxes, applies filterToCorridor or filterToEllipse+
beamSearchOrienteering, and returns { count, points, directDistanceM,
budgetDistanceM, warnings } — no per-postbox IDs or coordinates leaked.

Input validated (auth, lat/lng ranges, mode-specific params, 30 km cap);
21 new tests added covering auth, all validation paths, corridor/detour
pure-logic happy paths, claimed-today filter, and response shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pulls the searching→results→empty/quiz/quizFailed/claimed state machine
out of Claim into a new reusable ClaimQuizSheet widget
(lib/widgets/claim_quiz_sheet.dart). Claim now owns only the initial
state (scan button + streak badge) and delegates everything from the
nearbyPostboxes call onward to the sheet via onCompleted/onCancel
callbacks. ClaimQuizResult carries claimedCount/pointsEarned/quizFailed/
empty back to the parent. The compact flag scales up touch targets for
the upcoming LiveRouteScreen bottom-sheet variant (Task 8).

Behaviour parity preserved: same Cloud Function calls, quiz option
generation, James messages, analytics events, confetti, and animations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pass the parent _ClaimState's streakStream into ClaimQuizSheet via a
  new optional parameter; remove ClaimQuizSheet's own StreakService
  instance and subscription so only one listener is active at a time.
  LiveRouteScreen (T8) can pass its own stream or leave it null to omit
  the streak chip.
- Unify bottom-padding across all five _build* stages behind three state
  getters (_bottomPad / _buttonHeight / _optionHeight); remove the six
  inline local variable declarations and the inconsistent 100.0 literals
  (non-compact path now uniformly uses kJamesStripClearance = 80).
- Invert the _runSearch guard to an early-return pattern; drop the
  empty-branch narration comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a public positionStream() factory to lib/location_service.dart that
wraps Geolocator.getPositionStream with the same Wear OS / AndroidSettings
branching as the existing getPosition(). Permissions are the caller's
responsibility; the stream surfaces platform errors directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces lib/route/ with three new files:
- route_session.dart: mutable session holder (RoutePace, RouteMode enums,
  corridorMetres/detourMinutes clamped at construction, speedKmh getter)
- destination_picker_screen.dart: map-tap-to-pin picker with one-shot GPS
  via getPosition(), location-error handling with retry, disabled button
  until pin placed, initialPosition injection hook for testability
- route_preview_screen.dart: stub scaffold ready for T7

Adds test/route_destination_picker_test.dart (10 tests: 6 RouteSession
unit tests + 4 widget tests). All 115 Flutter tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds NominatimService (OSM Nominatim, 1-rps throttle, GB-biased, User-Agent
compliant) and wires a debounced search bar into DestinationPickerScreen;
result taps drop the pin, centre the map, and populate destinationLabel on
RouteSession. Adds http ^1.2.0 dependency. 24 new tests (10 unit + 4 widget).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the stub with a full implementation: pace SegmentedButton,
corridor/detour sliders, 400 ms debounced routePostboxes callable,
postbox count/points headline (loading/result/error states), and a
Start Route button gated on a first successful response. Adds the
LiveRouteScreen stub and 10 widget tests (all green, analyze clean).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements T8 of the route-mode plan:
- LiveRouteScreen: full StatefulWidget with injected position/compass/nearby
  streams, destination card (distance/bearing/ETA), RouteCompassView,
  hint button wired to JamesController, arrival detection at <25 m,
  abandon dialog, and per-route points/claimed status row.
- RouteCompassView: composite widget layering FuzzyCompass with a precise
  destination arrow (Transform.rotate on Icons.navigation).
- RouteCompletionScreen: stub scaffold (T9 will add the full completion UI).
- 16 new tests in route_live_screen_test.dart; all 155 Dart tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full RouteCompletionScreen layout (points, claimed count, walking time,
pace chip, confetti burst, Postman James greeting) replacing the T8 stub.
Adds RouteNotifications service (flutter_local_notifications) that fires a
local "You're here!" notification on arrival; wired into LiveRouteScreen
(_navigateToCompletion + initState permission ask) and initialised in main.

Android AndroidManifest.xml already had POST_NOTIFICATIONS; no new manifest
changes required. iOS runtime permission handled by the plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add OutlinedButton.icon "Walk to a destination" in Nearby initial state,
  navigating via Navigator.pushNamed(context, '/route')
- Register /route -> DestinationPickerScreen in MaterialApp.routes with
  the same _guardRoute auth-guard wrapper used by all other named routes
- Add routeStart / routePostboxNearby / routeArrival JamesMessage pools
  (8 lines each) and routeHint(direction) function (5 lines per direction)
  in JamesMessages; no em-dashes; falls back to "ahead" pool for unknown
  direction with a debugPrint warning
- Wire James messages in LiveRouteScreen (routeStart on initState, one-shot
  routePostboxNearby on first claim-sheet open, routeHint in hint button)
  and RouteCompletionScreen (routeArrival replaces hardcoded placeholder)
- Add 15 tests: routeStart/Nearby/Arrival pool coverage, routeHint for all
  four directions + unknown fallback + em-dash guard, /route named-route
  smoke test, and NavigatorObserver spy for the Nearby entry button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Retrofit ClaimQuizSheet with injectable NearbyPostboxesCallableFn and
StartScoringCallableFn so it can be rendered headless in tests. Thread
nearbyCallableForSheet / startScoringCallableForSheet through
LiveRouteScreen into the modal sheet. Add two deferred T11 tests:
proximity trigger (sheet opens when a postbox is within 30 m) and
dedupe (same postbox within 60 s cooldown does not reopen the sheet).
Also fix the pre-existing hint-message assertion that tested for literal
direction words instead of just a non-empty James message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Transitive androidx deps (browser 1.9.0, activity 1.12.4, core 1.18.0,
navigationevent 1.0.2) pulled in by flutter_local_notifications and
related plugins require compileSdk 36 and AGP 8.9.1. Bump Gradle wrapper
to 8.11.1 to match. Local builds now require JDK 17.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The master-side _startScan block referenced fields removed by the
ClaimQuizSheet refactor (_count, _claimedToday, _startQuiz,
currentStage); error/empty/already-claimed handling now lives inside
ClaimQuizSheet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The ClaimQuizSheet refactor reduced the quiz to a single picked cipher,
but the UI still prompts "What's the cipher on one of the nearby
postboxes?" when multiple unclaimed boxes are in range. A correct
identification of a different nearby cipher was wrongly failed.

Restore the multi-cipher accept-list behaviour from master: collect
every valid nearby cipher into a Set, include them all in the option
pool (up to 4) padded with distractors, and accept any of them as
correct. The wear OS variant already had this logic.
…ger fires

LiveRouteScreen.checkForNearbyClaimable reads box['distance'] from the
nearbyPostboxes response to decide when a postbox sits inside the 30 m
claim radius and the ClaimQuizSheet should be auto-opened. The slim
postbox shape only carried monarch + claimedToday, so distance was
always undefined and the trigger never fired in production. (The test
stub injected distance directly, masking the gap.)

Carry distance through applyUserClaims when present on the underlying
LookupResult. Privacy footprint is small: the user already has their
own GPS fix, so an unclaimed box's distance is bounded by the scan
radius they specified — at most the 2 km server cap.
The post-import update used the count of docs written in this run, which
excludes:
  - docs skipped because they carry correctedBy (an admin's reviewReport
    fix); they still exist in the postbox collection
  - manual_<reportId> docs created when an admin accepts a
    missing_postbox report

Either case caused the lifetime leaderboard's "X of Y postboxes (Z%)"
denominator to silently undercount the true population. Use a Firestore
count() aggregation to set the stored figure to the actual collection
size after the import commits.
…claims today

recomputeUserAggregates unconditionally wrote dailyDate/weekStart/
monthStart to the current period when re-summing claims for a user
affected by a cypher correction. If that user had no claims in the
current period, periodSums.dailyPoints was 0 but dailyDate landed at
today — making shouldNotifyFirstClaim / shouldNotifyOvertake treat the
user as already-having-claimed-today and silently suppress legitimate
friend notifications.

Only write each marker when the corresponding period sum is positive,
so the markers track actual claim activity. (startScoring is unaffected
— it only runs the lifetime tx after a successful claim, so the daily
sum there is always > 0.)
backfill_lifetime_scores.js unconditionally stamped dailyDate/weekStart/
monthStart even when the corresponding period sum was 0. Same trap as
the recompute path fixed in 94ef721: shouldNotifyFirstClaim and
shouldNotifyOvertake treat dailyDate === today as evidence the user
claimed today, so the backfill silently suppressed legitimate friend
notifications for the rest of the London day on every user with no
claims in the current period.

Only write each marker when the corresponding sum is positive, matching
the runtime path.
…'t scan

Between LiveRouteScreen._arrived = true and the pushReplacement landing
on RouteCompletionScreen, the position stream keeps firing. The old
guard only short-circuited the navigate call; setState + _maybeScan
still ran on every late frame, so a slow nav transition could trigger
a stray nearbyPostboxes call (and pointless bearing-chip rebuilds) for
a screen the user is already leaving.

Skip the whole handler when _arrived is true.
When a partial-success run leaves some claims already rewritten to the
target monarch, a follow-up retry would re-stamp those claims with
correctedFromMonarch set to the *new* monarch (read from d.monarch on
the second pass), erasing the only on-doc record of the real
pre-correction value.

Skip claims whose monarch and points already match the target. This
also avoids a no-op write on every admin retry. claimCount now
reflects actual rewrites rather than the read total, so the admin
sees an accurate "rescoredClaims" figure on retries. Added a test
covering the partial-rewrite retry case.
Both completion-screen buttons popped to Home, so 'Plan another route'
silently did nothing distinguishable from 'Done'. Pop the route-attempt
stack first and then push a fresh destination picker so the user lands
on the picker with a clean back-stack back to Home.
ReportCypherScreen seeded its dropdown with widget.currentMonarch when
that value matched a known cypher, so a user reporting "this is wrong"
could submit a suggestion that matched the existing record — a no-op
correction the admin still has to triage. Start at notSureCypher and
require the user to actively pick a value (or leave the dropdown alone
and add a note/photo).
Gradle drops android/build/ (and android/app/build/) on assemble; the
Flutter-tools-generated .gitignore only had **/android/.gradle. Added
both build dirs so 'git status' is clean after a release build.
…eports

The accept dialog already had a copy-to-clipboard button next to the
storage path so the admin can paste it into the Firebase console to
download the .osc file. The same path on reviewed reports below the
list was plain text only — adminstrators looking at it later had to
scrape it from the rendered text. Adds the same copy button for
consistency.
When a user dragged the corridor or detour slider, _scheduleFetch
queued a 400 ms debounced fetch but left _headlineState on the prior
result. The Start-route button stayed enabled with the stale headline
showing, and a fast tap could start a route whose stats banner showed
the previous params' count/points.

Set state to loading the moment a param change comes in so the
headline reflects "we're recomputing" and the Start-route button is
disabled until the new request lands. Existing test for the
completion-screen "Plan another route" tweaked to register the /route
named route so the post-pop pushNamed has a destination.
code418 added 9 commits May 17, 2026 14:44
_PhotoThumb was a StatelessWidget that called
FirebaseStorage.instance.ref(path).getDownloadURL() inside a
FutureBuilder built in its build() method, so every rebuild of the
parent _ReportCard (busy toggles, Accept dialog opening, sibling
state changes) fired a fresh Storage round-trip per thumb. On a
pending-tab full of multi-photo reports that was easily 10+ wasted
calls per interaction.

Convert to StatefulWidget with a per-path cached Future. Same image
shows the moment a previous fetch resolved; new path (rare) builds a
fresh Future. Photo-detail dialog also reuses the cached URL instead
of firing its own getDownloadURL.
Adds a regression test for 7c7940a: changing a slider during a route
preview must disable Start-route immediately rather than waiting for
the 400 ms debounce, so the user can't start a route whose headline
banner shows the old params' postbox count/points.
AdminReportsScreen was a StatelessWidget that called
AdminAccess.isAdmin(forceRefresh: true) inside FutureBuilder on every
build. Each rebuild fired a fresh getIdTokenResult(true) network round
trip to refresh the auth token — the admin claim is granted out of band
and shouldn't change during a session, so once is enough.

Convert to StatefulWidget and hold the Future in a late final so an
orientation flip or Theme update doesn't re-roundtrip Firebase Auth.
LiveRouteScreen wraps its FlutterCompass.events subscription in
try/catch because the events stream throws a MissingPluginException in
test environments without the platform channel. The Nearby screen and
the Wear OS compass page subscribed bare — a Dart test that pumped
either widget without mocking the channel would crash on initState.

Apply the same try/catch + null-out pattern, matching the existing
guard in LiveRouteScreen.
LoginButton (email) was correctly disabled when state.isSubmitting was
true, but GoogleLoginButton stayed enabled. A second tap during the
Google sign-in flow would dispatch another LoginWithGooglePressed, the
bloc would re-emit loading and re-call signInWithGoogle, and the plugin
could either throw or stack a second authentication on top of the
first. Wrap in BlocBuilder filtered on isSubmitting and null the
onPressed during loading, matching the email button's guard.
If the user starts typing an address then back-navigates within the
500 ms debounce window, the Timer would fire after dispose, calling
_runSearch -> _nominatimService.search() to issue a Nominatim HTTP
request whose result _runSearch's `if (!mounted) return;` then throws
away. Burns a slot in the 1-rps OSM throttle and races against the
next picker mount.

Cancel _debounceTimer in dispose, same as the route preview screen.
NominatimService creates an http.Client in its default constructor and
held it for the service's lifetime — but the destination picker created
a fresh NominatimService on each open without ever calling close().
Repeated opens (route the user abandons + reopens) accumulated client
connection pools.

Add a close() method that closes the client only when we created it
(tests inject their own), and have the destination picker call it in
dispose. Tracks _ownsClient / _ownsNominatimService so injected
instances aren't closed under the caller.
Analytics.observer was a getter that returned a new
FirebaseAnalyticsObserver on every call. main.dart hands it to
MaterialApp.navigatorObservers — which is read on every PostboxGame
rebuild — so each rebuild created a fresh observer, replaced the
Navigator's subscription, and orphaned the previous one (which still
holds an internal RouteObserver subscriber list).

Change to a single static final instance so the same observer hangs
off the Navigator across rebuilds.
Both the lifetime and per-county rescore transactions read the current
total and add countyDelta — which can be negative when a cypher
correction reduces the points value (e.g. EVIIR 9 → GR 4). In healthy
state the user has earned at least |countyDelta| from those claims so
the sum stays positive, but a missing per-county stats doc (legacy
claim made before per-county tracking, never backfilled) or an
under-reported lifetimePoints (earlier bug) could push the sum
negative, which would then leave the user sorted below users with 0
points on the leaderboard.

Clamp both newTotal and newLifetimePoints at 0 as defensive insurance.
@code418 code418 merged commit adf70c6 into master May 18, 2026
0 of 2 checks passed
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