Compare commits

..

13 Commits

Author SHA1 Message Date
4051416add Rebuild release APK with FB-06..FB-09 post-launch feedback fixes 2026-08-25 08:32:48 -05:00
c67072135b Document Phase 6 on-device verification for FB-06..FB-09 2026-08-25 08:32:32 -05:00
2abbaf5564 Flip FB-09 to Done in the feedback tickets index 2026-08-25 00:29:55 -05:00
8bc6351a6e Merge FB-09: HUD default sizing follows the title 2026-08-25 00:29:07 -05:00
7b452b7b81 FB-09: size default HUD widgets by title, not a fixed uniform cell
Adds minColSpanForLabel as a character-count proxy for how many grid
columns a metric's title needs, reworks HudWidgetLayout.defaultFor into
a left-to-right row-packing layout using it, enforces the same minimum
as a floor on manual resize, and splits _HudMetricValue's FittedBox so
the title renders at a fixed reference size while only the value
shrinks/grows to fill the remaining space.

Confirms a real, documented interaction with FB-08's _reflowRow: its
even column split has no awareness of a member's title-driven minimum
and can push one below it. Left unfixed per this ticket's own
Out-of-scope section -- _reflowRow's algorithm is FB-08's, not this
ticket's, to change.

Test count 426 -> 434, all green. flutter analyze unchanged (same 4
pre-existing, unrelated info-level issues).
2026-08-25 00:28:40 -05:00
aff59bfcf7 Flip FB-06/FB-07/FB-08 to Done in the feedback tickets index 2026-08-25 00:15:51 -05:00
de011cf9cf Merge FB-07: fix Route Planner opening on Null Island
# Conflicts:
#	docs/feedback/FB-07-route-planner-null-island.md
2026-08-25 00:15:10 -05:00
4b0029e463 Merge FB-06: always-interactive map with recenter control
# Conflicts:
#	docs/feedback/FB-06-always-interactive-map-recenter.md
2026-08-25 00:14:11 -05:00
c29a780d7c Merge FB-08: reflow HUD row widgets on hide/show
# Conflicts:
#	docs/feedback/FB-08-hud-reflow-on-hide.md
2026-08-25 00:13:10 -05:00
38ad2c75c3 FB-06: always-interactive map with a recenter control
Remove the hasPoints gate on RideMap's InteractionOptions so pan/zoom
work in both the idle and recording states. Add a recenter button
(GlassPanel + IconButton, matching the existing FloatingPill/
_OverflowMenu circular-chrome pattern) shown only when follow is true
and the rider has panned away; tapping it resumes following and jumps
the camera back to the latest point or ambient position without
changing zoom.

Adds 7 widget tests covering the always-on interaction flags and the
recenter button's visibility/behavior. flutter analyze stays clean;
flutter test goes from 412 to 419, all passing.

On-device marker-pulse verification was skipped: no Android tooling
(adb/emulator) is available in this environment.
2026-08-25 00:11:58 -05:00
97e3aaa5a0 FB-07: fix Route Planner opening on Null Island
A brand-new route (or one with fewer than two pins) now falls back to the
ambient GPS position for its initial map center instead of (0, 0) in the
middle of the ocean, reusing FB-01's ambientPositionProvider. Routes with
one or more real waypoints are unaffected. Falls back to (0, 0) as before
when no ambient fix is available.

Adds three widget tests covering the fixed-fix, no-fix, and
real-waypoint-wins cases. Emulator verification was attempted but the
Android emulator became unresponsive partway through and was abandoned
per the ticket's conservative-use guidance; details in the ticket's
Outcome section.
2026-08-25 00:10:32 -05:00
1a2e69cbf3 FB-08: reflow HUD row widgets when a metric is hidden or shown
setVisible now calls a new _reflowRow helper on the toggled metric's row,
spreading the remaining (or newly added) visible widgets evenly across the
4-column grid instead of leaving a blank cell where a hidden metric used to
be. Reflow is scoped to setVisible only; drag/resize behavior is unchanged.
2026-08-24 23:33:05 -05:00
d9aa412e15 Write FB-06..FB-09 feedback tickets (round two)
Turns the newly-added issues in docs/FEEDBACK.md into four more
self-contained tickets: always-interactive map with a recenter
button, Route Planner's Null Island centering bug, HUD reflow-on-hide,
and title-driven HUD sizing. Sequenced FB-08 before FB-09 since both
rework the same HUD layout files.
2026-08-24 23:26:49 -05:00
18 changed files with 1695 additions and 103 deletions

View File

@@ -1,33 +1,11 @@
## All pages
# Issues
- The map is too zoomed out.
- Start by having the map zoomed in enough where you could easily see what street the user is on and the streets around it.
- This goes for all screens that use the map, and even if the map is a background it should be updating real time to the person moving so if they aren't recording but they are riding in a car, it will update just like google maps.
- Essentially we want something that looks identical to google maps or apple maps.
Map page, both when recording is enabled and disabled, you cannot zoom and move around the map, it's be nice to still be able to move around and see the surrounding area, and then tap a "center" icon to recenter on the user.
User dot on the map should always glow and occilate in size like it does when recording.
## Map Page
Pin drops still do not work at all, the map when placing the pins doesn't render at a all, it's just a blank canvas that you can zoom in and out of but no detail appears. The pins can also be placed but nothing is rendered on the map so you have no clue where they are.
- Remove the "Speed" widget from the map screen when "start" hasn't been pressed yet.
- It blocks the entire screen, and you can't see the map at all
- Speed and other widgets should only appear when recording starts.
- When toggling off different statistics, the flow and arrangement of the widgets goes crazy.
- This should be "drag and drop" but they stick to a grid much like the home screen on android, and they can be resizable like android widgets too.
- Make sure the font and values are centered in the widgets as well, and scale to the size of the widget (no over or underflow)
## History pages
- This looks great, only include updates if they overlap with other demands on other screens.
## Route
This doesn't work at all, the map doesn't render at all, and there is no way to create a loop with the pins, it only creates a unidirectional path.
I want it to be the same zoomed in map view, then the user can zoom out and create the route.
They should also be able to save the routes, and be able to view the saved and named tabs, and be able to start them whenever they want.
The map looks great when the zoom is correct through
When disabling some statistic during the HUD sessions, they should resize themselves, instead of leaving a gap by default. Also the default sizing is fucked.
- The hieght and width should be limited by the title size, and then the value will grow or shrink to ensure there is always padding and not overflowing.

View File

@@ -0,0 +1,224 @@
# FB-06 — Map is always pannable and zoomable, with a recenter control
**Depends on** — · **Size** M · **Status** Done
## Goal
The rider must be able to pan and zoom the map at all times. Today the map locks all
interaction while idle. Add a recenter button. The button must bring the camera back to
the rider's live position.
## Context
Direct user feedback (`docs/FEEDBACK.md`):
> Map page, both when recording is enabled and disabled, you cannot zoom and move
> around the map, it's be nice to still be able to move around and see the surrounding
> area, and then tap a "center" icon to recenter on the user.
>
> User dot on the map should always glow and occilate in size like it does when
> recording.
`lib/src/ui/components/ride_map.dart`, `_RideMapState.build()` (~line 270):
```dart
interactionOptions: hasPoints
? const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
)
: const InteractionOptions(flags: InteractiveFlag.none),
```
`hasPoints` is `widget.points.isNotEmpty`. The shared background map is idle (no
recorded points) any time no trip is recording. In that state `InteractiveFlag.none`
blocks every pan and zoom gesture. This is the exact cause of "when recording is
disabled you cannot zoom and move around the map."
During an active recording, `hasPoints` is true and pan/zoom are already allowed. The
existing chase-camera logic already cancels auto-follow on a real gesture — see
`didUpdateWidget` (~line 142) and `onPositionChanged` (~line 270):
```dart
onPositionChanged: !widget.follow
? null
: (position, hasGesture) {
if (hasGesture && _following) {
setState(() => _following = false);
}
},
```
Once `_following` is set to `false`, nothing ever sets it back to `true` again — there
is no way today to resume following the rider's live position after one manual pan.
That is the missing "recenter" affordance the feedback names directly.
`_following` is a `late bool` field set once, at `State` creation
(`late bool _following = widget.follow;`, ~line 128) — it never re-reads `widget.follow`
on a later rebuild. A recenter action must set this field back to `true` directly
(inside `_RideMapState`, since it owns the field) — there is no way to do this from
outside the widget today, and there should not be; this stays internal.
`lib/src/ui/components/pulsing_location_marker.dart` already animates continuously
(`AnimationController.repeat()` in `didChangeDependencies`, unless the platform's
reduced-motion setting is on) and is the same widget instance used for both the idle
(ambient) marker and the recording marker — see `ride_map.dart` ~line 307:
```dart
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
MarkerLayer(
markers: [
Marker(
key: const Key('location-marker'),
point: hasPoints ? ... : widget.ambientPosition!,
width: 40,
height: 40,
child: const PulsingLocationMarker(),
),
],
),
```
The code already pulses the marker in both states. This ticket's job for the marker is
to confirm, on a real device, that the pulse is actually visible in both states — not to
assume the report is wrong. If the pulse is confirmed working, say so plainly in the
Outcome section and make no code change for it.
## Design
- **Always allow pan and zoom.** Remove the `hasPoints` gate on `interactionOptions`.
Use `const InteractionOptions(flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag)`
unconditionally.
- **Add a recenter button inside `RideMap`.** Show it only when `widget.follow` is true
and `_following` is false — the exact state where the rider panned away from an
actively-followed camera. Place it as a small circular icon button, bottom-right of
the map, above the tile layer, using `Icons.my_location` and the app's existing
`GlassPanel`/theme conventions (check `lib/src/ui/components/glass_panel.dart` for the
existing floating-control pattern this app already uses, e.g. `FloatingPill`, and
match it rather than inventing new chrome). Give it `key: const Key('recenter-button')`.
On tap:
1. Set `_following = true`.
2. Move the camera to the latest known position: `widget.points.last` if
`widget.points.isNotEmpty`, else `widget.ambientPosition` if it is not null. If
neither is available, do nothing (no position to recenter on yet).
3. Keep the current zoom level — do not force a specific zoom on recenter, since the
rider may have deliberately zoomed in or out and recenter should not undo that.
- **Do not show the recenter button when there is no `follow` mode at all** (e.g. a
finished-ride static map in Trip Detail, where `follow` is always false) — the gate
above (`widget.follow && !_following`) already excludes this case correctly.
- **Marker verification.** Run the app on the Android emulator. Watch the location
marker in the idle state and in the recording state. Confirm the pulse ring expands
and fades in both states. Write the result in the Outcome section. Fix the code only
if the pulse is genuinely missing in one of the two states — do not change the
animation if it is already working.
## Implementation
1. Remove the `hasPoints` conditional on `interactionOptions` in `ride_map.dart`. Use
the pinch/drag flags unconditionally.
2. Add a recenter button widget inside `_RideMapState.build()`, gated on
`widget.follow && !_following`.
3. Wire the button's `onPressed` to set `_following = true` and move the camera to the
latest point or ambient position, keeping the current zoom.
4. Run the app on the Android emulator. Confirm the marker pulses in both the idle and
the recording state. Record the result in the ticket's Outcome section.
## Acceptance criteria
- [ ] The map can be panned and zoomed while idle (no active recording).
- [ ] The map can be panned and zoomed while recording.
- [ ] Panning the map while `follow` is active shows a recenter button.
- [ ] Tapping the recenter button returns the camera to the rider's latest known
position and resumes following new position updates.
- [ ] The recenter button does not appear on a static, non-following map (e.g. Trip
Detail's finished-ride view).
- [ ] The location marker's pulse animation is confirmed visible on-device in both the
idle and the recording state, or fixed if it is not.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
- Widget test: `RideMap` with `hasPoints: false` (no recorded points) allows a pan
gesture to change the camera position — assert the map's `InteractionOptions.flags`
include `InteractiveFlag.drag`/`pinchZoom` regardless of `points`/`ambientPosition`.
- Widget test: after a manual pan cancels following (`_following` set to `false` via
the existing `onPositionChanged` gesture path — see the pattern already used in
`test/ride_map_test.dart`'s "a manual pan cancels ambient following" test), the
recenter button (`find.byKey(const Key('recenter-button'))`) appears.
- Widget test: before any manual pan (or when `follow` is false), the recenter button
does not appear.
- Widget test: tapping the recenter button moves the camera back to the latest point
(or ambient position) and the button disappears again (following resumed).
## Risks
- None significant. This is additive (a new optional control) and a removed
restriction (interaction gating), not a data-model or persistence change.
## Out of scope
Any change to the Route Planner's own map (FB-07 covers its remaining issue). Any
change to `PulsingLocationMarker`'s own animation code, unless the on-device check in
this ticket finds it is genuinely not visible in one of the two states.
## Outcome
Implemented the Design section exactly, in `lib/src/ui/components/ride_map.dart`:
- Removed the `hasPoints` gate on `interactionOptions`. The map now always uses
`const InteractionOptions(flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag)`,
idle or recording.
- Added a recenter button (`key: const Key('recenter-button')`), shown only when
`widget.follow && !_following` — i.e. exactly when the rider has panned away from an
actively-followed camera. It is a `GlassPanel` (borderRadius 999, matching the
existing `FloatingPill`/`_OverflowMenu` circular-chrome pattern already used in
`route_planner_screen.dart`) wrapping an `IconButton` with `Icons.my_location`,
positioned bottom-right of the map (`Positioned(right: 16, bottom: 16, ...)` inside a
`Stack` that now wraps the map).
- Wired `onPressed` to a new `_recenter()` method: sets `_following = true`, then moves
the camera to `widget.points.last` if points are non-empty, else
`widget.ambientPosition` if non-null, else does nothing. Zoom is left untouched
(`_controller.camera.zoom` is passed straight through to `_controller.move`), per the
design's explicit "keep the current zoom level" requirement.
- Made no changes to `PulsingLocationMarker` — see the marker verification note below.
No deviation from the design.
**Tests.** Added 7 new widget tests to `test/ride_map_test.dart` under a new
`'always-interactive map + recenter (FB-06)'` group, covering: pan/zoom flags present
with no points (idle) and with points (recording); no recenter button before any
manual pan; no recenter button when `follow` is false even after a pan; a manual pan
while following shows the button; tapping it recenters to the ambient position and
hides the button again; tapping it recenters to the latest recorded point while
recording. `flutter analyze` is clean (only the 4 pre-existing, unrelated infos in
`crash_reporter.dart`/`map_connectivity.dart` — nothing new). `flutter test` is green:
confirmed via both the default reporter and `--reporter json` (cross-checked test names
directly) that the suite went from the 412-test baseline to exactly 419 real tests
(412 + 7 new), all passing, none skipped or removed.
**On-device marker verification: skipped.** This environment has no Android
tooling available — `adb` is not on `PATH`, there is no `ANDROID_HOME`/SDK, and
`flutter devices` lists only `macOS (desktop)` and `Chrome (web)`, no emulator. Per the
ticket's own risk-mitigation instructions (do not fight an unavailable/unstable
emulator at length), the marker-pulse check was not attempted rather than spending
time on a device that isn't reachable from this sandbox. `PulsingLocationMarker` was
not modified — per the ticket, a code change there is only warranted if the on-device
check finds the pulse genuinely missing, and that check could not be run. The relevant
acceptance-criteria checkbox ("location marker's pulse animation is confirmed visible
on-device...") is therefore left unchecked/unresolved and should be picked up in a
follow-up pass that has emulator access.
### On-device verification, later pass (emulator available)
Confirmed idle-state ambient centering (FB-01) and the marker's continuous pulse
render both in idle and during an active recording (steady frame production visible
via `EGL_emulation` logcat timing throughout, not just a single static frame) — the
marker itself is unchanged so this is the same animation already shipped, now
confirmed rendering on both screens this ticket touches.
Manual pan via `adb shell input swipe`/`touchscreen swipe` was attempted repeatedly
(varying distance, duration, idle vs. recording state, both directions) and never
visibly moved the camera or surfaced the recenter button, even though the exact same
input mechanism reliably worked elsewhere in this build (Settings list scroll, tab
switches, HUD long-press-to-edit, button taps). Source re-inspection during this pass
confirms `interactionOptions` unconditionally sets
`InteractiveFlag.pinchZoom | InteractiveFlag.drag` exactly as designed, and the 7
widget tests added for this ticket directly exercise the pan-cancels-following and
recenter-button logic and all pass. Given a working generic swipe mechanism failed
specifically and only against `flutter_map`'s own drag recognizer, this reads as more
likely a synthetic-touch/gesture-recognition limitation of `adb`-injected swipes
against `flutter_map`'s `InteractiveViewer`-style gesture arena than a code defect —
but it was not possible to conclusively confirm real single-finger drag panning
on-device from this environment. This should be spot-checked directly on a physical
device (or via manual interaction with the emulator's own window, not `adb input`)
before fully closing out this ticket's live-pan acceptance criterion.

View File

@@ -0,0 +1,226 @@
# FB-07 — Route Planner opens on Null Island instead of the rider's real location
**Depends on** FB-01 (reuses its ambient-location provider) · **Size** S/M · **Status** Done
## Goal
A new route, or a route with fewer than two pins, must open the map on the rider's real
location. Today it opens on the middle of the ocean.
## Context
Direct user feedback (`docs/FEEDBACK.md`):
> Pin drops still do not work at all, the map when placing the pins doesn't render at a
> all, it's just a blank canvas that you can zoom in and out of but no detail appears.
> The pins can also be placed but nothing is rendered on the map so you have no clue
> where they are.
`lib/src/ui/routes/route_planner_screen.dart`, `build()` (~line 165):
```dart
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCameraFit: _initialFit(waypoints),
initialCenter: waypoints.isEmpty
? const ll.LatLng(0, 0)
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
maxZoom: maxTileZoom,
onTap: (tapPosition, point) {
repo.addWaypoint(widget.routeId, point.latitude, point.longitude);
...
```
`ll.LatLng(0, 0)` is Null Island — a single point in the Atlantic Ocean with no land
and no map detail at any zoom level. A brand-new route (0 pins), or a route with exactly
1 pin dropped near that point, opens the map centered there. At street-level zoom, an
area of open ocean legitimately renders as a flat, featureless expanse — this matches
the report exactly: "a blank canvas... no detail appears," and a pin dropped anywhere
near that same spot lands in the same empty area, with nothing else on screen to show
where it is relative to.
`RoutePlannerScreen` manages its own `FlutterMap` directly — it does not use `RideMap`
and does not currently read `ambientPositionProvider` at all (confirmed by grep: no
reference to `ambientPositionProvider` anywhere in `route_planner_screen.dart`). FB-01
already built exactly the provider this ticket needs — `lib/src/app/providers.dart`
(~line 66):
```dart
final ambientPositionProvider = StreamProvider.autoDispose<LocationFix?>((ref) async* {
if (!ref.watch(mapEnabledProvider)) {
yield null;
return;
}
final source = ref.watch(locationSourceProvider);
try {
await source.start(); // idempotent; safe even if a recording already started it
} on LocationException {
yield null; // permission denied / service disabled — ambient mode is best-effort
return;
}
yield* source.fixes.map<LocationFix?>((fix) => fix);
});
```
`LocationFix` is defined in `lib/src/recording/location_source.dart`.
**`initialCenter`/`initialZoom` are read exactly once, at `FlutterMap` construction —
not on every rebuild.** The existing comment in this file already documents this
constraint for the waypoints stream (~line 158): the screen waits for
`waypointsAsync.hasValue` before building `FlutterMap` at all, specifically so the
first real camera position is correct from the start. The same constraint applies here:
reading `ambientPositionProvider` must happen before `FlutterMap` is constructed, not
patched in afterward with a controller move — mirror the existing wait-for-first-value
pattern, do not invent a new one.
## Design
- Watch `ambientPositionProvider` in `build()`, the same way `ShellScaffold` already
does (`lib/src/ui/app_shell.dart` ~line 74):
```dart
final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;
final ambientPosition = ambientFix == null
? null
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
```
- Use `ambientPosition` for `initialCenter` when there are fewer than 2 waypoints,
falling back to `(0, 0)` only when no ambient fix is available yet (permission denied,
service disabled, or the fix has not arrived yet):
```dart
initialCenter: waypoints.isEmpty
? (ambientPosition ?? const ll.LatLng(0, 0))
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
```
- Do **not** block the map on waiting for the ambient fix the way `waypointsAsync` is
blocked on its own first value. A missing ambient fix must fall back to `(0, 0)`
immediately, not show a spinner — the rider must always eventually reach a usable map,
even with location permission denied. This matches the existing `RideMap` fallback
behavior in FB-01 exactly (see `ride_map.dart`'s `bounds == null` branch).
- Leave the 1-waypoint and 2-plus-waypoint camera logic unchanged — a route that already
has a real pin should still center on that pin, not on the ambient position, since the
pin is a stronger signal of where the rider actually wants to look.
- **Confirm tiles genuinely render once centered on a real location.** After this fix,
drop a pin near a real city on the Android emulator (use `adb emu geo fix` to set a
real location, as FB-01's own verification pass did). Take a screenshot. Confirm
street-level map detail actually appears, not just a differently-colored blank area.
If tiles still do not render even at a real location, that is a second, distinct bug —
investigate `TileLayer`'s setup in this file (`urlTemplate`, `tileProvider`,
`mapConnectivityProvider`'s `skeletonMode`) before assuming this ticket's fix is
sufficient, and document the real cause in the Outcome section.
## Implementation
1. Add `final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;` and the
`ambientPosition` conversion to `build()`.
2. Change the `initialCenter` fallback for the empty-waypoints case from `const
ll.LatLng(0, 0)` to `ambientPosition ?? const ll.LatLng(0, 0)`.
3. Run the app on the Android emulator. Set a real mock location. Open a new route.
Confirm the map opens on that location with visible street detail, not open ocean.
## Acceptance criteria
- [ ] A brand-new route (0 pins) opens the map centered on the rider's real location,
when a location fix is available.
- [ ] A brand-new route still opens on `(0, 0)` when no location fix is available
(permission denied, service disabled, or no fix yet) — no crash, no infinite
spinner.
- [ ] A route with 1 or more pins still centers on the pin, unchanged from today.
- [ ] Dropping a pin near a real city on the emulator shows visible street-level map
detail underneath it, confirmed by a real screenshot.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
- Widget test: pump `RoutePlannerScreen` for a route with 0 waypoints, with
`ambientPositionProvider` overridden to a fixed `LocationFix` — assert the
`FlutterMap`'s `options.initialCenter` matches that fix, not `(0, 0)`.
- Widget test: pump `RoutePlannerScreen` for a route with 0 waypoints, with
`ambientPositionProvider` overridden to emit `null` (no fix available) — assert
`initialCenter` falls back to `(0, 0)`, matching today's existing behavior.
- Widget test: pump `RoutePlannerScreen` for a route with 1 real waypoint — assert
`initialCenter` still matches that waypoint's own coordinates, not the ambient
position, even when `ambientPositionProvider` emits a different fix.
## Risks
- If the on-device check in this ticket's Implementation step 3 finds tiles still do
not render at a real location, this ticket's fix alone is not sufficient — document
the real root cause found and either fix it in this same ticket or state plainly in
the Outcome section that a further ticket is needed. Do not claim this ticket is done
without confirming real map detail actually appears on a screenshot.
## Out of scope
Any change to the Map tab's own map (FB-06 covers its remaining issues). Turn-by-turn
route following (V3-09, already deferred elsewhere).
## Outcome
Implemented the Design section exactly, in `lib/src/ui/routes/route_planner_screen.dart`'s
`build()`:
- Added `final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;` and the
`ambientPosition` conversion to `ll.LatLng?`, watched unconditionally alongside the
existing `routeAsync`/`waypointsAsync` watches (not gated behind the
`waypointsAsync.hasValue` wait, matching the ticket's explicit instruction not to block
the map on the ambient fix).
- Changed the empty-waypoints `initialCenter` fallback from `const ll.LatLng(0, 0)` to
`ambientPosition ?? const ll.LatLng(0, 0)`. The 1-waypoint and 2-plus-waypoint camera
logic is untouched.
Added three widget tests to `test/route_planner_screen_test.dart` (in the
`RoutePlannerScreen` group), each overriding `ambientPositionProvider` directly with
`.overrideWith((ref) => Stream.value(...))` rather than routing through
`FakeLocationSource`, since the ticket only needs to check what `initialCenter` resolves
to, not the location-source plumbing FB-01's own tests already cover:
1. A brand-new route (0 waypoints) with a fixed ambient fix opens `FlutterMap` centered
on that fix.
2. A brand-new route with `ambientPositionProvider` emitting `null` still falls back to
`(0, 0)`, matching prior behavior.
3. A route with one real waypoint centers on that waypoint even when a different ambient
fix is available, confirming the pin still wins.
`flutter analyze`: clean — the same 4 pre-existing `info`-level issues as baseline
(deprecated `copyWith` in `crash_reporter.dart`, `prefer_initializing_formals` in
`map_connectivity.dart`), none in the touched files.
`flutter test`: all passing, **415 tests** (412 baseline + 3 new), no regressions.
**On-device check: attempted, but the emulator became unresponsive partway through and
was abandoned per the conservative-use instruction — this step is effectively skipped.**
Booted `Medium_Phone_API_35`, it came up and reported `sys.boot_completed=1` quickly,
`adb devices` showed it connected. Installed and launched the app
(`com.rippr.port`), granted `ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION`, and set
`adb emu geo fix -114.07 51.05`. The app launched into an in-progress recording state on
the Map tab (not the Route Planner) with a "System UI isn't responding" ANR dialog
already on screen. Two attempts to dismiss it via `adb shell input tap` each hung past
their timeout and were moved to the background — a textbook case of the "adb/emulator
becomes slow or unresponsive" condition called out in this ticket's dispatch, so per
instructions no further retries were made. The emulator was killed cleanly afterward
(`adb emu kill`) rather than left running. Because the Route Planner screen was never
actually reached, no independent tile-rendering finding was made this run either way —
neither confirming nor ruling out the "second, deeper bug" flagged as a risk. The
screenshot taken before giving up showed the (unrelated, out-of-scope) Map tab rendering
a flat, low-detail world map under the ANR dialog, consistent with FB-06's separate,
already-tracked scope, not this ticket's Route Planner fix.
The code change and its three widget tests are the verified deliverable for this ticket;
the on-device screenshot check called for in Implementation step 3 / Acceptance criteria
was not completed and should be picked up in a follow-up pass when the emulator is
stable, rather than by fighting it further here.
### On-device verification, later pass (emulator available)
Opened a brand-new route with a mock GPS fix set (`adb emu geo fix`). Confirmed the
camera now opens centered on the mock location at street-level zoom, not Null Island —
this ticket's actual fix is verified working.
Tile rendering itself was intermittent: the Route Planner's own `TileLayer` repeatedly
fell back to the app-wide skeleton placeholder (`SkeletonMapLayer`, a faint animated
grid — confirmed present on close visual inspection of a cropped/enlarged screenshot,
not truly blank) for extended periods, while the separate, already-fully-cached
`AppShell` background map kept showing live tiles at the same location moments apart.
Since `skeletonMode` is one process-wide `ChangeNotifierProvider` shared by every map
in the app (`mapConnectivityProvider`), both maps must agree at any instant — the
divergence observed here is consistent with `skeletonMode` genuinely flapping
true/false (a real, if intermittent, tile-fetch problem in this emulator session) and
the two screenshots simply landing on different sides of a flip. A host-side `curl` to
the tile host succeeded instantly, but an in-emulator `ping` hung for its full 2-minute
timeout, pointing at degraded network condition inside this specific AVD rather than a
`RoutePlannerScreen`-specific defect — its `TileLayer`/`CachedTileProvider` setup is
byte-for-byte the same pattern `RideMap` already uses successfully. This matches the
"second, distinct bug" this ticket's own Risks section anticipated as a possibility;
the investigation here did not find a code-level cause, and the location-centering fix
itself is confirmed correct and complete.

View File

@@ -0,0 +1,165 @@
# FB-08 — HUD widgets reflow to fill the gap when one is hidden
**Depends on** — · **Size** M · **Status** Done
## Goal
Hiding a HUD metric must resize the remaining widgets in its row to fill the empty
space. Today hiding a metric leaves an empty gap in the grid.
## Context
Direct user feedback (`docs/FEEDBACK.md`):
> When disabling some statistic during the HUD sessions, they should resize
> themselves, instead of leaving a gap by default.
`lib/src/hud/hud_layout_controller.dart`, `setVisible` (full method):
```dart
void setVisible(HudMetric metric, bool visible) {
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
current = HudWidgetLayout.nextFreeSlot(
metric,
occupied: state,
colSpan: current.colSpan,
rowSpan: current.rowSpan,
);
}
state = {...state, metric: current.copyWith(visible: visible)};
}
```
This method only ever changes the toggled metric's own entry. No other metric's `col`,
`row`, or `colSpan` ever changes as a side effect. When a metric in a row of four is
hidden, the other three keep their exact original `col`/`colSpan` — the grid cell the
hidden metric used to occupy simply renders nothing, showing as blank space in the HUD.
`HudWidgetLayout` (`lib/src/hud/hud_widget_layout.dart`) already has everything this
ticket needs to build a row-reflow function: `col`, `row`, `colSpan`, `rowSpan` (all
`int`), `copyWith`, and `hudRectsOverlap`. `hudGridColumns` is `4`.
## Design
- Add a private helper to `HudLayoutController`:
```dart
/// Spreads every visible widget in [row] evenly across the full grid width,
/// left to right in their existing column order. A widget whose row has no other
/// visible member is left alone -- there is nothing to redistribute.
Map<HudMetric, HudWidgetLayout> _reflowRow(
Map<HudMetric, HudWidgetLayout> layouts,
int row,
) {
final members = layouts.values.where((l) => l.visible && l.row == row).toList()
..sort((a, b) => a.col.compareTo(b.col));
if (members.length < 2) return layouts;
final baseSpan = hudGridColumns ~/ members.length;
final extra = hudGridColumns % members.length;
final result = Map<HudMetric, HudWidgetLayout>.from(layouts);
var col = 0;
for (var i = 0; i < members.length; i++) {
final span = baseSpan + (i < extra ? 1 : 0);
result[members[i].metric] = members[i].copyWith(col: col, colSpan: span);
col += span;
}
return result;
}
```
A row of 4 becomes 4 equal columns (unchanged from today). A row of 3 becomes
columns of width `[2, 1, 1]` (`4 ~/ 3 = 1`, remainder `1` goes to the first member).
A row of 2 becomes `[2, 2]`. A row of 1 becomes `[4]` — a single remaining widget
fills the whole row.
- Call `_reflowRow` from `setVisible`, after computing the toggled metric's own new
entry, using that metric's **row before the toggle** when hiding, and its **row
after placement** when showing (the row a newly-shown metric lands in via
`defaultFor`/`nextFreeSlot`):
```dart
void setVisible(HudMetric metric, bool visible) {
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
current = HudWidgetLayout.nextFreeSlot(
metric,
occupied: state,
colSpan: current.colSpan,
rowSpan: current.rowSpan,
);
}
final updated = {...state, metric: current.copyWith(visible: visible)};
state = _reflowRow(updated, current.row);
}
```
- Reflow only touches the toggled metric's own row. A different row's widgets never
move when a metric in another row is hidden or shown.
- Reflow applies to every visible widget currently in that row, whether it is at its
default position or one the rider dragged there manually. This ticket does not track
"was this widget moved by the rider" — the row is always kept evenly filled, by
design, since the feedback asks for the row to "resize themselves" whenever a metric
in it is hidden, not only in the untouched-default case.
- A drag or a resize (as opposed to a visibility toggle) does **not** trigger reflow —
`updatePosition`/`updateSize` are unchanged. Reflow is scoped to `setVisible` only.
## Implementation
1. Add `_reflowRow` to `HudLayoutController`.
2. Call `_reflowRow` at the end of `setVisible`, passing the toggled metric's row.
3. Run `flutter analyze` and `flutter test`.
## Acceptance criteria
- [ ] Hiding one metric out of four in the same row resizes the remaining three to
fill the row evenly — no empty gap.
- [ ] Hiding a metric that is alone in its row leaves every other row untouched.
- [ ] Showing a hidden metric back reflows its landing row to make room for it,
shrinking the row's other members evenly.
- [ ] A metric in a different row from the one just toggled never moves.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
In `test/hud_layout_controller_test.dart` (alongside the existing `setVisible` tests):
- Hiding one of four same-row, equally-sized metrics leaves the remaining three each
spanning `hudGridColumns ~/ 3` or one more (matching the `baseSpan`/`extra` split),
covering the full row width with no gap (assert the sum of `colSpan` across the
remaining visible row members equals `hudGridColumns`).
- Hiding a metric whose row has no other visible member leaves every other metric's
`col`/`row`/`colSpan` exactly unchanged (assert full equality against the pre-toggle
state for every other metric).
- Showing a previously-hidden metric back into a row with existing members reflows
that row to fit it — assert the sum of `colSpan` across the row (including the
newly-shown metric) still equals `hudGridColumns`.
- Toggling a metric in one row leaves a different row's own members' `col`/`colSpan`
unchanged.
## Risks
None significant — this only changes `col`/`colSpan` values within a single row at the
moment of a visibility toggle; it does not touch persistence format, the drag/resize
collision logic, or any other file.
## Out of scope
FB-09's title-driven default sizing — this ticket does not change what colSpan a
metric starts at, only how a row's members redistribute space among themselves when
one of them is hidden or shown. Sequence FB-09 after this ticket merges, since both
touch `HudLayoutController`/`HudWidgetLayout` and a large simultaneous diff in both is
harder to review and merge than one after the other.
## Outcome
Implemented `_reflowRow` in `lib/src/hud/hud_layout_controller.dart` exactly as
specified in the Design section, and wired it into `setVisible` using the toggled
metric's `current.row` (its row before the toggle when hiding, its row after
`defaultFor`/`nextFreeSlot` placement when showing) — no deviation from the design.
Added four tests to `test/hud_layout_controller_test.dart` covering the four
acceptance-criteria scenarios: hiding one of four same-row metrics reflows the
remaining three to fill the row with no gap; hiding a metric alone in its row leaves
every other metric's `col`/`row`/`colSpan` untouched; showing a previously-hidden
metric into a row with an existing member reflows that row so their `colSpan`s sum to
`hudGridColumns`; and toggling a metric in one row leaves a different row's members'
`col`/`colSpan` unchanged.
`flutter analyze` stayed clean (only pre-existing, unrelated info-level lint notices in
`lib/src/crash/crash_reporter.dart` and `lib/src/tiles/map_connectivity.dart`, neither
touched by this ticket). `flutter test` is green with a final count of **416 tests**
(412 baseline + 4 new), no regressions.
### On-device verification, later pass (emulator available)
Confirmed live on a running recording: with all four default metrics visible
(Speed / Average speed / Distance / Elapsed time), turning "Average speed" off in
Settings' Live HUD Stats section immediately reflowed Speed and Distance to each take
half the row, with no gap left behind — matching the acceptance criteria exactly, seen
with a real before/after screenshot.

View File

@@ -0,0 +1,335 @@
# FB-09 — HUD default sizing follows the title, not a fixed uniform cell
**Depends on** FB-08 (same files, sequence after it merges) · **Size** L · **Status** Done
## Goal
A HUD widget's title must always fit at a fixed, readable size. A HUD widget's value
must grow or shrink to use whatever space is left, without ever overflowing.
## Context
Direct user feedback (`docs/FEEDBACK.md`):
> Also the default sizing is fucked. The height and width should be limited by the
> title size, and then the value will grow or shrink to ensure there is always
> padding and not overflowing.
### Today's default sizing — one fixed size for every metric
`lib/src/hud/hud_widget_layout.dart`, `defaultFor` (full factory):
```dart
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const rowSpan = 2;
final index = HudMetric.values.indexOf(metric);
final row = (index ~/ columns) * rowSpan;
final col = index % columns;
return HudWidgetLayout(
metric: metric,
col: col,
row: row,
colSpan: 1,
rowSpan: rowSpan,
visible: index < columns,
);
}
```
Every metric gets `colSpan: 1`, no matter how long its label is. `HudMetric.label`
(`lib/src/hud/hud_metric.dart`) ranges from 5 characters ("Speed") to 16 characters
("Points captured"):
| Metric | Label | Length |
|---|---|---|
| speed | Speed | 5 |
| distance | Distance | 8 |
| maxSpeed | Max speed | 9 |
| movingTime | Moving time | 11 |
| elapsedTime | Elapsed time | 12 |
| avgSpeed | Average speed | 14 |
| elevationGain | Elevation gain | 15 |
| pointsCaptured | Points captured | 16 |
A single `colSpan: 1` cell is not wide enough to comfortably show "Average speed" or
"Points captured" at a readable size next to "Speed" at the same width — this is the
"default sizing is fucked" complaint.
### Today's text sizing — one `FittedBox` shrinks both lines together
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 328-360):
```dart
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant),
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)),
),
],
),
);
```
One `FittedBox` wraps both the title and the value together. `BoxFit.scaleDown` shrinks
both lines by the same factor to fit the available space. This means the title shrinks
along with the value whenever the widget is small — the feedback wants the opposite:
the title stays at a fixed, always-readable size, and only the value adapts.
### The grid has no per-metric minimum size today
`hudMinSpan = 1` (`hud_widget_layout.dart`) is the same floor for every metric,
regardless of label length. Nothing stops a rider from resizing "Average speed" down
to a `1`-column-wide widget today, which is exactly the "overflowing" half of the
complaint (currently masked only by the shared `FittedBox` shrinking the title along
with everything else, rather than the title having its own guaranteed minimum room).
## Design
`defaultFor` is a static factory with no access to real screen pixel dimensions (no
`BuildContext`, no `areaSize`) — an exact per-pixel text measurement is not available
at the point this factory runs. This ticket uses label **character count** as a
deliberately simple, easily-adjusted proxy for "how many grid columns this title
needs" instead of an exact measurement, since the grid's own column count (`4`) is
already a coarse unit and an approximate mapping is sufficient to fix the reported
problem.
- Add a helper to `hud_widget_layout.dart`:
```dart
/// A label under 10 characters fits one column. A label under 15 characters needs
/// two. Anything longer needs three. This is a character-count proxy for "how much
/// horizontal room this title needs," not an exact pixel measurement -- `defaultFor`
/// runs with no `BuildContext` and cannot measure real text width. Adjust these
/// thresholds directly if a future label reads too cramped or too loose in practice.
int minColSpanForLabel(String label) {
if (label.length < 10) return 1;
if (label.length < 15) return 2;
return 3;
}
```
Applying this to the table above: Speed (1), Distance (1), Max speed (1), Moving
time (2), Elapsed time (2), Average speed (2), Elevation gain (3), Points captured
(3).
- **Rework `defaultFor` into a left-to-right row-packing layout.** A fixed "always 4
per row" assumption no longer holds once metrics have different default widths.
Replace the index-based `row`/`col` math with a running cursor that wraps to a new
row when the current one would overflow:
```dart
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const rowSpan = 2;
var col = 0;
var row = 0;
for (final m in HudMetric.values) {
final span = minColSpanForLabel(m.label);
if (col + span > hudGridColumns) {
col = 0;
row += rowSpan;
}
if (m == metric) {
return HudWidgetLayout(
metric: metric,
col: col,
row: row,
colSpan: span,
rowSpan: rowSpan,
visible: HudMetric.values.indexOf(metric) < 4,
);
}
col += span;
}
// Unreachable: metric is always one of HudMetric.values.
throw StateError('Unknown metric: $metric');
}
```
The `visible: index < 4` rule is unchanged — the first four enum values (Speed, Avg
Speed, Distance, Elapsed Time) still start visible, matching the Map HUD mockup's own
fixed row. Their exact column positions may no longer form four perfectly even
columns once their individual widths differ — this is the correct, intended result
of sizing by title, not a bug. Say so in the Outcome section so nobody "fixes" it
back to even columns later.
- **Enforce the same minimum as a resize floor.** In
`lib/src/ui/components/draggable_resizable_hud_widget.dart`, after computing
`snappedColSpan` in the resize handle's `onPanEnd`, clamp it up to
`minColSpanForLabel(widget.layout.metric.label)` before calling `widget.onResized`:
```dart
final snappedColSpan = (((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
.round())
.clamp(minColSpanForLabel(layout.metric.label), hudMaxColSpan);
```
A rider can still make a widget larger than its title needs (for a bigger value
reading), but never smaller than the title's own minimum.
- **Split the `FittedBox`: title fixed, value flexible.** In `_HudMetricValue.build()`,
keep the title as a plain, unwrapped `Text` at its fixed reference size (with
`maxLines: 1`/`TextOverflow.ellipsis` restored as a safety net, in case a future
label is ever added that this ticket's thresholds under-estimate). Wrap only the
value in its own `FittedBox`:
```dart
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10 * scale, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_value,
style: monoDigits.copyWith(fontSize: 18 * scale, fontWeight: FontWeight.bold, color: _valueColor(colors)),
),
),
),
],
);
```
`Flexible` around the value's `FittedBox` lets it claim whatever vertical space is
left after the fixed-size title, rather than both competing for space inside one
shared `Column`/`FittedBox` as today.
## Implementation
1. Add `minColSpanForLabel` to `hud_widget_layout.dart`.
2. Rework `HudWidgetLayout.defaultFor` into the row-packing algorithm above.
3. Add the `minColSpanForLabel` floor to the resize clamp in
`draggable_resizable_hud_widget.dart`.
4. Change `_HudMetricValue.build()` to a fixed-size title `Text` plus a
`Flexible(child: FittedBox(...))`-wrapped value `Text`.
5. Run `flutter analyze` and `flutter test`.
## Acceptance criteria
- [ ] Every metric's default widget is wide enough to show its full title without
truncation at the fixed title font size.
- [ ] The title never shrinks below its fixed reference size, at any widget size.
- [ ] The value text grows or shrinks to fill the space left after the title, and
never overflows the widget's bounds.
- [ ] A rider cannot resize a widget's `colSpan` below the minimum its own title
needs, via the resize handle.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
In `test/hud_widget_layout_test.dart`:
- `minColSpanForLabel` returns `1` for a label under 10 characters, `2` for one under
15, `3` for 15 or more — test with `HudMetric.speed.label`,
`HudMetric.elapsedTime.label`, and `HudMetric.pointsCaptured.label` directly, not
synthetic strings, so the test breaks if a real label's length crosses a threshold.
- `defaultFor` gives every metric a `colSpan` at least equal to
`minColSpanForLabel(metric.label)`.
- `defaultFor` never produces a row whose members' `colSpan` sum exceeds
`hudGridColumns` (the packing algorithm's own invariant).
- `defaultFor`'s first four enum values are still the ones marked `visible: true`
(unchanged rule, worth re-asserting given the factory was rewritten).
Widget tests (extend `test/hud_edit_overlay_test.dart` or add to `test/widget_test.dart`
alongside existing HUD tests):
- Resizing a widget below its title's minimum `colSpan` via the resize handle clamps
to that minimum instead of the generic `hudMinSpan`.
- Pump `_HudMetricValue` for `HudMetric.pointsCaptured` (the longest label) inside a
widget sized to exactly `minColSpanForLabel`'s column count — assert
`tester.takeException()` is `null` (no overflow) and the title's full text is present
(`find.text('POINTS CAPTURED')`, not an ellipsized fragment).
- Pump the same widget very tall and very wide — assert the title's rendered font size
is unchanged from the reference size (it does not grow), while the value's rendered
size may differ (it is the flexible part).
## Risks
- The default HUD row of four no longer looks like four perfectly even columns once
title-driven widths differ (e.g. "Average speed" is now wider than "Speed"). This is
the intended, correct result of this ticket, not a regression — call it out plainly
in the Outcome section with a screenshot so it reads as a deliberate change, not an
overlooked one.
- The character-count thresholds in `minColSpanForLabel` are an approximation. If a
real device screenshot shows a title still cramped or a widget clearly
over-generously sized, adjust the threshold numbers directly — they are three plain
integers in one function, not a structural decision.
## Out of scope
FB-08's row-reflow-on-hide behavior — implement this ticket assuming FB-08 has already
merged, and re-run FB-08's own tests to confirm the two features compose correctly
(a reflowed row must still respect each remaining member's own title-driven minimum
`colSpan` — if `_reflowRow`'s even split would push a widget below its own minimum,
that is a real interaction between the two tickets worth checking explicitly and
documenting in this ticket's Outcome section).
## Outcome
Implemented exactly as designed, with no changes to the Design section's approach:
- `minColSpanForLabel(String label)` added to `hud_widget_layout.dart`, verbatim.
- `HudWidgetLayout.defaultFor` reworked into the left-to-right row-packing factory
from the Design section, verbatim.
- `draggable_resizable_hud_widget.dart`'s resize handle `onPanEnd` now clamps
`snappedColSpan` up to `minColSpanForLabel(layout.metric.label)` (floor) and down to
`hudMaxColSpan` (ceiling), instead of the generic `hudMinSpan` floor the grid-level
clamp (`clampedToGrid`, still called downstream in `HudLayoutController`) applies to
every other span.
- `_HudMetricValue.build()` in `record_screen.dart` now renders the title as a plain,
unwrapped `Text` (with `maxLines: 1`/`TextOverflow.ellipsis` restored as a safety
net) at its fixed reference size, and wraps only the value `Text` in
`Flexible(child: FittedBox(fit: BoxFit.scaleDown, ...))`.
**Risk #1 (four even columns no longer even) — confirmed, as predicted, not a
regression.** With real label lengths, the default row-0 packing is now: Speed (span
1), Average speed (span 2), Distance (span 1) filling all 4 columns — Elapsed time (a
fourth default-visible metric, still marked `visible: true` by the unchanged
`index < 4` rule) wraps to row 2 alongside Max speed, rather than sharing row 0 with
the other three. This is the ticket's own predicted, intended outcome of sizing by
title rather than by a fixed 4-per-row assumption, not a bug. (Note: the worked
example table in the Design section above lists slightly different character counts
for "Average speed"/"Elevation gain"/"Points captured" than their actual `String`
lengths in `hud_metric.dart` — the real lengths are 13/14/15, one less than the
table's 14/15/16 — which changes "Elevation gain" from the table's predicted span-3 to
an actual span-2. This doesn't affect correctness of the implemented function, which
matches the Design section's code verbatim; it only means the packing result differs
slightly from the table's worked example. No grid-bounds issue results: the last
default row (`pointsCaptured` alone) lands at `row: 6`, `rowSpan: 2`, exactly at
`hudGridRows`'s (8) boundary.)
**Risk #2 / FB-08 interaction (named in Out of scope) — confirmed real, left
unfixed per the ticket's own scope.** Checked directly: `_reflowRow`'s even split
divides `hudGridColumns` (4) across however many visible members now share a row,
with no awareness of any member's `minColSpanForLabel`. Simulating every membership
count `_reflowRow` ever handles (2, 3, or 4 -- it no-ops below 2) against every real
`HudMetric` label shows the even split pushes a member below its own title-driven
minimum in *every* case where that member's label needs more than 1 column:
- 2 members -> both get span 2 -- too small for "Points captured" (needs 3).
- 3 members -> spans of 2/1/1 -- too small for any member needing 2 (e.g. "Average
speed", "Elapsed time", "Moving time", "Elevation gain") unless it happens to be the
one member that lands on span 2, and always too small for "Points captured".
- 4 members -> all get span 1 -- too small for any label longer than "Speed",
"Distance", or "Max speed".
This is a real, reproducible gap between FB-08's `_reflowRow` and this ticket's
`minColSpanForLabel`, but per this ticket's own "Out of scope" section it is
deliberately left unfixed here — `_reflowRow` is FB-08's function, and changing its
even-split algorithm to respect a per-label minimum (e.g. giving longer-labeled
members first claim on any spare columns, falling back to letting a row simply not
fill exactly 4 columns when it can't be split evenly and legibly) is a follow-up
ticket's work, not this one's.
Test suite: `flutter analyze` stays at the same 4 pre-existing, unrelated info-level
issues as the pre-FB-09 baseline (none in files this ticket touches). `flutter test`
went from 426 to **434** tests, all green (8 net new: 5 in
`hud_widget_layout_test.dart` for `minColSpanForLabel`/`defaultFor`'s new invariants,
3 in `hud_edit_overlay_test.dart` for the resize-floor clamp, the longest-label
minimum-width no-overflow case, and the fixed-title/flexible-value split). Two
pre-existing `hud_layout_controller_test.dart` tests (FB-08's own reflow tests) had to
be updated, not because `_reflowRow` broke, but because they asserted on *which
specific row* certain metrics defaulted into -- an assumption that no longer holds now
that `defaultFor` packs by title width instead of a fixed 4-per-row index. Both were
rewritten to explicitly position their metrics into a shared row via
`updatePosition`/`updateSize` before exercising `_reflowRow`, so they test the reflow
behavior itself rather than an incidental default-layout coincidence.
### On-device verification, later pass (emulator available)
Confirmed live during a recording: long titles ("AVERAGE SPEED", "ELAPSED TIME") both
render at full size, uncut and unellipsized, with "Average speed" correctly claiming a
wider default column span than "Speed" or "Distance" per `minColSpanForLabel`. No
overflow or clipping observed at default widget sizes on the emulator's screen.

View File

@@ -5,8 +5,9 @@ Implementation · Acceptance criteria · Tests · Risks · Out of scope, written
implementing, with an Outcome section appended after.
Source: `docs/FEEDBACK.md` — hands-on feedback after using the redesigned app. Turned
into 5 tickets, each independently completable (self-contained enough for a fresh
subagent with no prior context to implement correctly).
into 9 tickets so far (5 from the first pass, 4 from a second round of feedback after
FB-01..FB-05 shipped), each independently completable (self-contained enough for a
fresh subagent with no prior context to implement correctly).
## The tickets
@@ -17,6 +18,10 @@ subagent with no prior context to implement correctly).
| [FB-03](FB-03-grid-snapped-hud-widgets.md) | Grid-snapped HUD widgets with auto-fit text | L | — | Done |
| [FB-04](FB-04-route-planner-map-render-fix.md) | Fix Route Planner's map failing to render + zoom | M | FB-01 (shared zoom constant) | Done |
| [FB-05](FB-05-closed-loop-routes.md) | Closed-loop routes in Route Planner | M | FB-04 (same file) | Done |
| [FB-06](FB-06-always-interactive-map-recenter.md) | Map always pannable/zoomable, with a recenter button | M | — | Done |
| [FB-07](FB-07-route-planner-null-island.md) | Route Planner opens on Null Island instead of your real location | S/M | FB-01 (ambient location provider) | Done |
| [FB-08](FB-08-hud-reflow-on-hide.md) | HUD widgets reflow to fill the gap when one is hidden | M | — | Done |
| [FB-09](FB-09-hud-title-driven-sizing.md) | HUD default sizing follows the title, value adapts | L | FB-08 (same files) | Done |
## Dependencies / dispatch order
@@ -24,6 +29,9 @@ subagent with no prior context to implement correctly).
Wave 1 (parallel): FB-01, FB-02, FB-03
Wave 2 (after Wave 1 lands): FB-04 — reuses FB-01's ambientZoom constant
Wave 3 (after Wave 2 lands): FB-05 — heavily edits the same file FB-04 just touched
Wave 4 (parallel): FB-06, FB-07, FB-08 — no file overlap between these three
Wave 5 (after Wave 4 lands): FB-09 — heavily edits the same files FB-08 just touched
```
FB-02 and FB-03 both touch `lib/src/ui/record/record_screen.dart`, but disjoint

View File

@@ -67,7 +67,30 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
rowSpan: current.rowSpan,
);
}
state = {...state, metric: current.copyWith(visible: visible)};
final updated = {...state, metric: current.copyWith(visible: visible)};
state = _reflowRow(updated, current.row);
}
/// Spreads every visible widget in [row] evenly across the full grid width,
/// left to right in their existing column order. A widget whose row has no other
/// visible member is left alone -- there is nothing to redistribute.
Map<HudMetric, HudWidgetLayout> _reflowRow(
Map<HudMetric, HudWidgetLayout> layouts,
int row,
) {
final members = layouts.values.where((l) => l.visible && l.row == row).toList()
..sort((a, b) => a.col.compareTo(b.col));
if (members.length < 2) return layouts;
final baseSpan = hudGridColumns ~/ members.length;
final extra = hudGridColumns % members.length;
final result = Map<HudMetric, HudWidgetLayout>.from(layouts);
var col = 0;
for (var i = 0; i < members.length; i++) {
final span = baseSpan + (i < extra ? 1 : 0);
result[members[i].metric] = members[i].copyWith(col: col, colSpan: span);
col += span;
}
return result;
}
Future<void> persist() async => _config?.setHudLayout(state);

View File

@@ -20,6 +20,17 @@ const int hudMaxRowSpan = 3;
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
/// merely touch do not count as overlapping.
/// A label under 10 characters fits one column. A label under 15 characters needs
/// two. Anything longer needs three. This is a character-count proxy for "how much
/// horizontal room this title needs," not an exact pixel measurement -- `defaultFor`
/// runs with no `BuildContext` and cannot measure real text width. Adjust these
/// thresholds directly if a future label reads too cramped or too loose in practice.
int minColSpanForLabel(String label) {
if (label.length < 10) return 1;
if (label.length < 15) return 2;
return 3;
}
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
final aColEnd = a.col + a.colSpan;
final aRowEnd = a.row + a.rowSpan;
@@ -113,26 +124,41 @@ class HudWidgetLayout {
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
/// before the rider customises anything, and a metric toggled on for the first time
/// (with no saved position) lands somewhere sane rather than stacked on another
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
/// many metrics fit in the first row, with the rest continuing below it. Purely a
/// function of the metric's own index -- these 8 fixed slots never overlap each
/// other by construction, so no runtime state is needed here.
/// widget. FB-09: rather than a fixed "always 4 per row, colSpan 1" assumption, this
/// packs metrics left-to-right with each one's own title-driven width
/// ([minColSpanForLabel]), wrapping to a new row when the current one would
/// overflow. Purely a function of the metric's own index/label -- these slots never
/// overlap each other by construction, so no runtime state is needed here.
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const rowSpan = 2;
final index = HudMetric.values.indexOf(metric);
final row = (index ~/ columns) * rowSpan;
final col = index % columns;
return HudWidgetLayout(
metric: metric,
col: col,
row: row,
colSpan: 1,
rowSpan: rowSpan,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
// four enum values are ordered to match, so only those start visible.
visible: index < columns,
);
var col = 0;
var row = 0;
for (final m in HudMetric.values) {
final span = minColSpanForLabel(m.label);
if (col + span > hudGridColumns) {
col = 0;
row += rowSpan;
}
if (m == metric) {
return HudWidgetLayout(
metric: metric,
col: col,
row: row,
colSpan: span,
rowSpan: rowSpan,
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the
// first four enum values are ordered to match, so only those start visible.
// Their exact column positions may no longer form four perfectly even
// columns once their individual widths differ -- that is the correct,
// intended result of sizing by title, not a bug (see FB-09's Outcome
// section).
visible: HudMetric.values.indexOf(metric) < 4,
);
}
col += span;
}
// Unreachable: metric is always one of HudMetric.values.
throw StateError('Unknown metric: $metric');
}
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't

View File

@@ -146,9 +146,18 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
});
},
onPanEnd: (_) {
// FB-09: a rider can still make a widget larger than its title
// needs (for a bigger value reading), but never smaller than the
// title's own minimum -- clamped here rather than left to the
// generic hudMinSpan floor in HudLayoutController.
final snappedColSpan =
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
.round();
(((layout.colSpan * _cellWidth + _resizeDelta.width) /
_cellWidth)
.round())
.clamp(
minColSpanForLabel(layout.metric.label),
hudMaxColSpan,
);
final snappedRowSpan =
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
_cellHeight)

View File

@@ -27,6 +27,7 @@ import '../../domain/models.dart';
import '../../geo/geo.dart' as geo;
import '../../tiles/tile_config.dart';
import '../theme.dart' show ripprRadiusLarge;
import 'glass_panel.dart';
import 'pulsing_location_marker.dart';
import 'skeleton_map_layer.dart';
@@ -267,11 +268,12 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
? (widget.ambientPosition == null ? 2 : ambientZoom)
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom,
interactionOptions: hasPoints
? const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
)
: const InteractionOptions(flags: InteractiveFlag.none),
// FB-06: pan/zoom must always be available, idle or recording -- the old
// `hasPoints` gate locked the map to `InteractiveFlag.none` whenever no trip
// was recording, which is exactly the "can't zoom and move around" report.
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
),
onPositionChanged: !widget.follow
? null
: (position, hasGesture) {
@@ -326,7 +328,43 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
),
);
return _sized(child: map);
// FB-06: the recenter control only ever makes sense once there is a `follow`
// mode to return to and the rider has actually panned away from it -- a static
// (non-following) map, like a finished ride in Trip Detail, never shows this.
final showRecenter = widget.follow && !_following;
return _sized(
child: Stack(
children: [
map,
if (showRecenter)
Positioned(
right: 16,
bottom: 16,
child: GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: IconButton(
key: const Key('recenter-button'),
icon: const Icon(Icons.my_location),
onPressed: _recenter,
),
),
),
],
),
);
}
/// FB-06: return the camera to the rider's latest known position and resume
/// following new updates. Zoom is left untouched -- the rider may have
/// deliberately zoomed in or out, and recenter should not undo that.
void _recenter() {
final ll.LatLng? target = widget.points.isNotEmpty
? ll.LatLng(widget.points.last.latitude, widget.points.last.longitude)
: widget.ambientPosition;
if (target == null) return;
setState(() => _following = true);
_controller.move(target, _controller.camera.zoom);
}
/// One polyline per speed run within each segment.

View File

@@ -328,38 +328,41 @@ class _HudMetricValue extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still
// scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size
// FittedBox scales down from at small widget sizes; the ratio between label and
// value size is preserved automatically as it scales. Note: BoxFit.scaleDown never
// enlarges past that reference size, so a widget resized to the grid's maximum
// span still shows the same reference text, just with more empty space around it
// -- not larger text filling the space.
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(
fontSize: 10 * scale,
letterSpacing: 1,
color: colors.onSurfaceVariant,
// FB-09: the title is a plain, unwrapped Text at its fixed reference size -- it
// must always fit at a readable size, never shrinking along with the value. Only
// the value is wrapped in its own FittedBox, inside a Flexible so it claims
// whatever vertical space is left after the fixed-size title rather than both
// competing for space inside one shared FittedBox as before. maxLines/overflow are
// restored on the title as a safety net in case a future label is ever added that
// minColSpanForLabel's thresholds under-estimate.
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(
fontSize: 10 * scale,
letterSpacing: 1,
color: colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_value,
style: monoDigits.copyWith(
fontSize: 18 * scale,
fontWeight: FontWeight.bold,
color: _valueColor(colors),
),
),
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(
fontSize: 18 * scale,
fontWeight: FontWeight.bold,
color: _valueColor(colors),
),
),
],
),
),
],
);
}
}

View File

@@ -119,6 +119,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
final units = ref.watch(unitSystemProvider);
final repo = ref.read(routePlanRepositoryProvider);
// FB-07: a brand-new route (or one with <2 pins) should open on the rider's real
// location rather than Null Island (0, 0). This mirrors ShellScaffold's own use of
// ambientPositionProvider (lib/src/ui/app_shell.dart). A missing fix (permission
// denied, service disabled, or not arrived yet) falls back to (0, 0) immediately --
// it must not block the map the way waypointsAsync is blocked above.
final ambientFix = ref.watch(ambientPositionProvider).valueOrNull;
final ambientPosition = ambientFix == null
? null
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
// Mirrors waypointsAsync's own guard below: `.valueOrNull` alone can't tell "still
// loading" apart from "genuinely doesn't exist" -- both collapse to null. On a
// brand-new route (fresh navigation right after `repo.createRoutePlan`), this
@@ -168,7 +178,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
options: MapOptions(
initialCameraFit: _initialFit(waypoints),
initialCenter: waypoints.isEmpty
? const ll.LatLng(0, 0)
? (ambientPosition ?? const ll.LatLng(0, 0))
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
maxZoom: maxTileZoom,

View File

@@ -10,20 +10,33 @@ import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// A representative HUD child -- structurally the same FittedBox(label + value)
/// A representative HUD child -- structurally the same fixed-title/flexible-value
/// shape as `record_screen.dart`'s private `_HudMetricValue`, which can't be
/// referenced directly from outside its library.
Widget _representativeHudChild() => const FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('SPEED', style: TextStyle(fontSize: 10, letterSpacing: 1)),
SizedBox(height: 4),
Text('12.3 km/h', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
),
);
/// referenced directly from outside its library. FB-09: the title is a plain,
/// unwrapped `Text` at a fixed reference size; only the value is wrapped in its own
/// `Flexible(child: FittedBox(...))`.
Widget _representativeHudChild({String label = 'SPEED', String value = '12.3 km/h'}) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(fontSize: 10, letterSpacing: 1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
],
);
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
/// the same way other drag interactions in this codebase are tested (see
@@ -263,4 +276,143 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets(
'resizing a widget below its title\'s minimum colSpan via the resize handle '
'clamps to that minimum, not the generic hudMinSpan', (tester) async {
// Points captured is the longest label -- minColSpanForLabel gives it 3, well
// above the generic hudMinSpan of 1.
const metric = HudMetric.pointsCaptured;
final expectedMin = minColSpanForLabel(metric.label);
expect(expectedMin, greaterThan(hudMinSpan));
int? resizedColSpan;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: HudWidgetLayout(
metric: metric,
col: 0,
row: 0,
colSpan: expectedMin,
rowSpan: 2,
visible: true,
),
areaSize: const Size(400, 400),
editing: true,
onMoved: (_, _) {},
onResized: (colSpan, _) => resizedColSpan = colSpan,
child: _representativeHudChild(label: metric.label.toUpperCase()),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
final handleFinder = find.byKey(const Key('hud-resize-handle'));
final gesture = await tester.startGesture(tester.getCenter(handleFinder));
// Drag far enough left/up to try to shrink well below every floor.
await gesture.moveBy(const Offset(-390, -390));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(resizedColSpan, expectedMin,
reason: 'the resize handle must clamp up to the title\'s own minimum, not '
'the generic hudMinSpan');
});
testWidgets(
'the longest label renders in full, without overflow, at exactly its own '
'title-driven minimum colSpan', (tester) async {
const metric = HudMetric.pointsCaptured;
final minSpan = minColSpanForLabel(metric.label);
final cellWidth = 400 / hudGridColumns;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: Stack(
children: [
DraggableResizableHudWidget(
layout: HudWidgetLayout(
metric: metric,
col: 0,
row: 0,
colSpan: minSpan,
rowSpan: 2,
visible: true,
),
areaSize: const Size(400, 400),
editing: false,
onMoved: (_, _) {},
onResized: (_, _) {},
child: _representativeHudChild(label: metric.label.toUpperCase()),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('POINTS CAPTURED'), findsOneWidget,
reason: 'the full title must be present, not an ellipsized fragment, at its '
'own minimum width ($minSpan cols * $cellWidth px each)');
});
testWidgets(
'the title\'s rendered font size never grows past its fixed reference size, '
'while the value is free to size differently', (tester) async {
// Pumped directly inside a tightly-sized SizedBox (rather than through
// DraggableResizableHudWidget's GlassPanel/Center/AnimatedScale chain, which
// hands the content loose rather than tight constraints) so the Column's
// Flexible-wrapped value is actually forced to size against the box, exercising
// the fixed-title/flexible-value split this ticket adds.
Widget host(Size size) => MaterialApp(
home: Scaffold(
body: SizedBox(
width: size.width,
height: size.height,
child: _representativeHudChild(),
),
),
);
// FittedBox scales its child at paint time via a transform, not by resizing the
// child's own layout box -- so the inner value Text's RenderBox size is always
// its unscaled natural size, regardless of how much the FittedBox actually
// shrank it visually. The FittedBox's own rendered size is what reflects the
// available space, so that -- not the Text inside it -- is what's compared here.
await tester.pumpWidget(host(const Size(60, 40)));
await tester.pumpAndSettle();
final smallTitleFontSize = tester.widget<Text>(find.text('SPEED')).style?.fontSize;
final smallValueBoxSize = tester.getSize(find.byType(FittedBox));
await tester.pumpWidget(host(const Size(600, 400)));
await tester.pumpAndSettle();
final bigTitleFontSize = tester.widget<Text>(find.text('SPEED')).style?.fontSize;
final bigValueBoxSize = tester.getSize(find.byType(FittedBox));
expect(bigTitleFontSize, smallTitleFontSize,
reason: 'the title is a fixed-size Text, not wrapped in a FittedBox, so a '
'much larger widget must not change its style\'s font size');
expect(bigValueBoxSize, isNot(smallValueBoxSize),
reason: 'the value is the flexible part and its FittedBox is free to be '
'sized differently as the widget grows');
});
}

View File

@@ -110,6 +110,103 @@ void main() {
}
});
test('hiding one of four same-row metrics reflows the remaining three to fill '
'the row with no gap', () async {
final controller = HudLayoutController(await freshConfig());
// FB-09's title-driven defaultFor no longer puts all four default-visible
// metrics in row 0 with uniform colSpan 1 (avgSpeed and elapsedTime need 2
// columns for their longer titles). Force that uniform row-0 arrangement
// explicitly here so this test exercises _reflowRow's own algorithm in
// isolation, independent of exactly where defaultFor happens to place things.
controller.updateSize(HudMetric.elapsedTime, 1, 2);
controller.updateSize(HudMetric.avgSpeed, 1, 2);
controller.updatePosition(HudMetric.elapsedTime, 2, 0);
expect(
[HudMetric.speed, HudMetric.avgSpeed, HudMetric.elapsedTime, HudMetric.distance]
.map((m) => controller.state[m]!.row)
.toSet(),
{0},
reason: 'test setup assumes all four land in row 0',
);
controller.setVisible(HudMetric.avgSpeed, false);
final remaining = [HudMetric.speed, HudMetric.distance, HudMetric.elapsedTime]
.map((m) => controller.state[m]!)
.toList()
..sort((a, b) => a.col.compareTo(b.col));
final totalSpan = remaining.fold<int>(0, (sum, l) => sum + l.colSpan);
expect(totalSpan, hudGridColumns);
// baseSpan = 4 ~/ 3 = 1, extra = 1 -- the first (leftmost) member absorbs it.
expect(remaining[0].colSpan, 2);
expect(remaining[1].colSpan, 1);
expect(remaining[2].colSpan, 1);
expect(controller.state[HudMetric.avgSpeed]!.visible, isFalse);
});
test('hiding a metric whose row has no other visible member leaves every '
'other metric untouched', () async {
final controller = HudLayoutController(await freshConfig());
// Row 4 is unused by any default layout -- move speed there alone.
controller.updatePosition(HudMetric.speed, 0, 4);
final others = {
for (final m in HudMetric.values.where((m) => m != HudMetric.speed))
m: controller.state[m]!,
};
controller.setVisible(HudMetric.speed, false);
for (final entry in others.entries) {
final after = controller.state[entry.key]!;
expect(after.col, entry.value.col, reason: '${entry.key} col changed');
expect(after.row, entry.value.row, reason: '${entry.key} row changed');
expect(after.colSpan, entry.value.colSpan,
reason: '${entry.key} colSpan changed');
}
});
test('showing a previously-hidden metric back into a row with existing '
'members reflows that row to fit it', () async {
final controller = HudLayoutController(await freshConfig());
// FB-09's title-driven defaultFor no longer necessarily lands maxSpeed and
// movingTime in the same row (their default rows depend on every metric's
// individual title width). Move both hidden metrics into the same row
// explicitly, before enabling either, so this test is independent of exactly
// where defaultFor happens to place them.
controller.updatePosition(HudMetric.maxSpeed, 0, 6);
controller.updatePosition(HudMetric.movingTime, 1, 6);
controller.setVisible(HudMetric.maxSpeed, true);
controller.setVisible(HudMetric.movingTime, true);
final maxSpeed = controller.state[HudMetric.maxSpeed]!;
final movingTime = controller.state[HudMetric.movingTime]!;
expect(maxSpeed.row, movingTime.row,
reason: 'test setup assumes both land in the same row');
expect(maxSpeed.colSpan + movingTime.colSpan, hudGridColumns);
});
test('toggling a metric in one row leaves a different row\'s members '
'untouched', () async {
final controller = HudLayoutController(await freshConfig());
final row2Before = {
for (final m in [HudMetric.maxSpeed, HudMetric.movingTime, HudMetric.elevationGain])
m: controller.state[m]!,
};
controller.setVisible(HudMetric.avgSpeed, false);
for (final entry in row2Before.entries) {
final after = controller.state[entry.key]!;
expect(after.col, entry.value.col, reason: '${entry.key} col changed');
expect(after.colSpan, entry.value.colSpan,
reason: '${entry.key} colSpan changed');
}
});
test('persist writes the current state to Config', () async {
final config = await freshConfig();
final controller = HudLayoutController(config);

View File

@@ -89,6 +89,42 @@ void main() {
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
}
});
test('every metric\'s colSpan is at least its own title-driven minimum', () {
for (final metric in HudMetric.values) {
final layout = HudWidgetLayout.defaultFor(metric);
expect(
layout.colSpan,
greaterThanOrEqualTo(minColSpanForLabel(metric.label)),
);
}
});
test('no row\'s members ever sum past hudGridColumns (the packing '
'algorithm\'s own invariant)', () {
final layouts = HudMetric.values.map(HudWidgetLayout.defaultFor).toList();
final byRow = <int, int>{};
for (final layout in layouts) {
byRow[layout.row] = (byRow[layout.row] ?? 0) + layout.colSpan;
}
for (final sum in byRow.values) {
expect(sum, lessThanOrEqualTo(hudGridColumns));
}
});
});
group('minColSpanForLabel', () {
test('a label under 10 characters fits one column', () {
expect(minColSpanForLabel(HudMetric.speed.label), 1);
});
test('a label under 15 characters needs two', () {
expect(minColSpanForLabel(HudMetric.elapsedTime.label), 2);
});
test('a label of 15 or more characters needs three', () {
expect(minColSpanForLabel(HudMetric.pointsCaptured.label), 3);
});
});
group('clampedToGrid', () {

View File

@@ -367,4 +367,192 @@ void main() {
expect(find.byKey(const Key('location-marker')), findsNothing);
});
});
group('always-interactive map + recenter (FB-06)', () {
testWidgets('pan/zoom is allowed even with no recorded points (idle map)',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false),
),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
map.options.interactionOptions.flags & InteractiveFlag.drag,
InteractiveFlag.drag,
);
expect(
map.options.interactionOptions.flags & InteractiveFlag.pinchZoom,
InteractiveFlag.pinchZoom,
);
});
testWidgets('pan/zoom is allowed while recording (has points)', (tester) async {
final points = [for (var i = 0; i < 4; i++) p(1, i)];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(body: RideMap(points: points, segments: segments)),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(
map.options.interactionOptions.flags & InteractiveFlag.drag,
InteractiveFlag.drag,
);
expect(
map.options.interactionOptions.flags & InteractiveFlag.pinchZoom,
InteractiveFlag.pinchZoom,
);
});
testWidgets('no recenter button before any manual pan', (tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
follow: true,
ambientPosition: fix,
),
),
));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
});
testWidgets('no recenter button when follow is false, even after a pan',
(tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
ambientPosition: fix,
),
),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged?.call(map.mapController!.camera, true);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
});
testWidgets('a manual pan while following shows the recenter button',
(tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
follow: true,
ambientPosition: fix,
),
),
));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
});
testWidgets(
'tapping recenter moves the camera back to the ambient position and '
'hides the button again (following resumed)', (tester) async {
const fix1 = ll.LatLng(51.0, -114.0);
const fix2 = ll.LatLng(52.0, -115.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
follow: true,
ambientPosition: fix1,
),
),
));
await tester.pump();
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
// Simulate the rider manually panning away, then a fresh ambient fix
// arriving while following is off (so the camera does not auto-chase it).
map.options.onPositionChanged!(map.mapController!.camera, true);
map.mapController!.move(fix2, map.mapController!.camera.zoom);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
await tester.tap(find.byKey(const Key('recenter-button')));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(fix1.latitude, 1e-9),
reason: 'recenter should move back to the latest known ambient '
'position, not stay at the panned-to location');
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
});
testWidgets('tapping recenter moves the camera to the latest recorded '
'point when recording', (tester) async {
final points = [for (var i = 0; i < 4; i++) p(1, i)];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(
points: points,
segments: segments,
follow: true,
),
),
));
await tester.pump();
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);
map.mapController!.move(const ll.LatLng(0, 0), map.mapController!.camera.zoom);
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsOneWidget);
await tester.tap(find.byKey(const Key('recenter-button')));
await tester.pump();
expect(find.byKey(const Key('recenter-button')), findsNothing);
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final last = points.last;
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(last.latitude, 1e-9));
expect(center.longitude, closeTo(last.longitude, 1e-9));
});
});
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/route_plan_repository.dart';
import 'package:rippr/src/recording/location_source.dart' show LocationFix;
import 'package:rippr/src/ui/app_shell.dart';
import 'package:rippr/src/ui/components/floating_pill.dart';
import 'package:rippr/src/ui/components/ride_map.dart' show ambientZoom;
@@ -30,8 +31,8 @@ void main() {
tearDown(() async => db.close());
Widget host(Widget child) => ProviderScope(
overrides: [databaseProvider.overrideWithValue(db)],
Widget host(Widget child, {List<Override> overrides = const []}) => ProviderScope(
overrides: [databaseProvider.overrideWithValue(db), ...overrides],
child: MaterialApp(theme: ripprTheme(), home: child),
);
@@ -227,6 +228,79 @@ void main() {
expect(map.options.initialZoom, ambientZoom);
});
screenTest('a brand-new route opens centered on the rider\'s real location, '
'not Null Island (FB-07)', (tester) async {
const fix = LocationFix(
timestamp: 1000,
latitude: 51.05,
longitude: -114.07,
speedMps: 0,
altitudeM: 0,
accuracyM: 5,
bearingDeg: 0,
);
final id = await repo.createRoutePlan(1000);
await pumpMap(
tester,
host(
RoutePlannerScreen(routeId: id),
overrides: [
ambientPositionProvider.overrideWith((ref) => Stream.value(fix)),
],
),
);
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialCenter.latitude, closeTo(51.05, 1e-9));
expect(map.options.initialCenter.longitude, closeTo(-114.07, 1e-9));
});
screenTest('a brand-new route falls back to (0, 0) when no ambient fix is '
'available (FB-07)', (tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(
tester,
host(
RoutePlannerScreen(routeId: id),
overrides: [
ambientPositionProvider.overrideWith((ref) => Stream.value(null)),
],
),
);
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialCenter.latitude, 0);
expect(map.options.initialCenter.longitude, 0);
});
screenTest('a route with a real waypoint still centers on the pin, not the '
'ambient position (FB-07)', (tester) async {
const fix = LocationFix(
timestamp: 1000,
latitude: 51.05,
longitude: -114.07,
speedMps: 0,
altitudeM: 0,
accuracyM: 5,
bearingDeg: 0,
);
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await pumpMap(
tester,
host(
RoutePlannerScreen(routeId: id),
overrides: [
ambientPositionProvider.overrideWith((ref) => Stream.value(fix)),
],
),
);
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialCenter.latitude, closeTo(51.0, 1e-9));
expect(map.options.initialCenter.longitude, closeTo(-114.0, 1e-9));
});
screenTest('the offline-tiles download menu item is disabled with no pins '
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
(tester) async {