Compare commits
25 Commits
08f2cfbf02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7ccd67632 | |||
| a96a22aabc | |||
| 3b6aed2fa2 | |||
| 799471e5ff | |||
| c1d326a52d | |||
| 6d944cc85d | |||
| 0efc1700ff | |||
| dfd3e24062 | |||
| e44371ce9b | |||
| dde6ec833a | |||
| 3810dd5a26 | |||
| e06e410ad6 | |||
| 4051416add | |||
| c67072135b | |||
| 2abbaf5564 | |||
| 8bc6351a6e | |||
| 7b452b7b81 | |||
| aff59bfcf7 | |||
| de011cf9cf | |||
| 4b0029e463 | |||
| c29a780d7c | |||
| 38ad2c75c3 | |||
| 97e3aaa5a0 | |||
| 1a2e69cbf3 | |||
| d9aa412e15 |
Binary file not shown.
@@ -1,33 +1,15 @@
|
|||||||
|
|
||||||
## All pages
|
# Issues
|
||||||
|
|
||||||
- The map is too zoomed out.
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
- It blocks the entire screen, and you can't see the map at all
|
- 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.
|
||||||
|
|
||||||
- Speed and other widgets should only appear when recording starts.
|
Map page still cannot be scrolled/panned around -- it stays locked centered on the user, both idle and recording. A previous attempt at this fix (FB-06) did not actually resolve it on-device.
|
||||||
- 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)
|
|
||||||
|
|
||||||
|
Tapping "+" to create a new route still shows a blank map with no tiles/detail rendered -- you cannot see streets or anything to tell you where pins are being dropped. A previous attempt at this fix (FB-07) did not actually resolve it on-device.
|
||||||
## 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
|
|
||||||
|
|||||||
224
docs/feedback/FB-06-always-interactive-map-recenter.md
Normal file
224
docs/feedback/FB-06-always-interactive-map-recenter.md
Normal 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.
|
||||||
226
docs/feedback/FB-07-route-planner-null-island.md
Normal file
226
docs/feedback/FB-07-route-planner-null-island.md
Normal 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.
|
||||||
165
docs/feedback/FB-08-hud-reflow-on-hide.md
Normal file
165
docs/feedback/FB-08-hud-reflow-on-hide.md
Normal 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.
|
||||||
335
docs/feedback/FB-09-hud-title-driven-sizing.md
Normal file
335
docs/feedback/FB-09-hud-title-driven-sizing.md
Normal 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.
|
||||||
263
docs/feedback/FB-10-map-pan-recenter-race.md
Normal file
263
docs/feedback/FB-10-map-pan-recenter-race.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# FB-10 — Manual map pan loses a race against the follow-recenter timer
|
||||||
|
|
||||||
|
**Depends on** — · **Size** M · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
The rider must be able to pan the map and have it stay where they put it. Today a pan
|
||||||
|
gesture is silently cancelled a moment after it happens, both idle and while recording.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Map page still cannot be scrolled/panned around -- it stays locked centered on the
|
||||||
|
> user, both idle and recording. A previous attempt at this fix (FB-06) did not
|
||||||
|
> actually resolve it on-device.
|
||||||
|
|
||||||
|
FB-06 already removed the old `hasPoints` gate that fully disabled
|
||||||
|
`InteractiveFlag.drag`/`InteractiveFlag.pinchZoom`. That part of the fix is correct and
|
||||||
|
confirmed by widget tests. The map is still stuck because of a **different** bug FB-06
|
||||||
|
did not find: a timer-driven recenter that keeps re-arming and wins a race against the
|
||||||
|
user's own drag.
|
||||||
|
|
||||||
|
`lib/src/ui/app_shell.dart` builds one shared `RideMap` behind every tab, and rebuilds
|
||||||
|
it on every ambient GPS fix:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
child: RideMap(
|
||||||
|
key: const Key('shell-background-map'),
|
||||||
|
points: points,
|
||||||
|
segments: segments,
|
||||||
|
ambientPosition: ambientPosition,
|
||||||
|
follow: isMapTab,
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ambientPosition` comes from `ambientPositionProvider`, fed by
|
||||||
|
`GeolocatorLocationSource` at a fixed one-second interval with no distance filter
|
||||||
|
(`lib/src/recording/geolocator_location_source.dart`):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
/// Matches the native `LocationRequest`: high accuracy, 1 s nominal interval, no
|
||||||
|
/// distance filter (a stationary bike must still produce fixes so elapsed time and the
|
||||||
|
/// noise floor behave).
|
||||||
|
const Duration _interval = Duration(seconds: 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `distanceFilter` is `0`, almost every one-second tick delivers a fix that is at
|
||||||
|
least slightly different from the last one -- so `AppShell`, and therefore `RideMap`,
|
||||||
|
rebuilds roughly once per second, indefinitely, on the Map tab.
|
||||||
|
|
||||||
|
`lib/src/ui/components/ride_map.dart`, `didUpdateWidget` (~line 142):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void didUpdateWidget(RideMap old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
if (!_following || _backgrounded) return;
|
||||||
|
if (widget.points.isNotEmpty) {
|
||||||
|
final last = widget.points.last;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
|
});
|
||||||
|
} else if (widget.ambientPosition != null &&
|
||||||
|
widget.ambientPosition != old.ambientPosition) {
|
||||||
|
final isFirstFix = old.ambientPosition == null;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_following) return;
|
||||||
|
_controller.move(
|
||||||
|
widget.ambientPosition!,
|
||||||
|
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The only thing that stops this recenter is `_following` turning `false`, which only
|
||||||
|
happens inside `onPositionChanged` (~line 277):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onPositionChanged: !widget.follow
|
||||||
|
? null
|
||||||
|
: (position, hasGesture) {
|
||||||
|
if (hasGesture && _following) {
|
||||||
|
setState(() => _following = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
This callback is correct on its own -- a controller-driven `.move()` reports
|
||||||
|
`hasGesture: false`, so it never cancels itself out. The problem is timing, not logic:
|
||||||
|
`didUpdateWidget` re-arms a fresh `postFrameCallback` on almost every one-second tick,
|
||||||
|
for as long as `_following` is still `true`. If a tick lands while the rider's finger is
|
||||||
|
already down and moving but flutter_map has not yet reported `hasGesture: true` for that
|
||||||
|
drag, the scheduled callback fires anyway and snaps the camera straight back to the
|
||||||
|
ambient fix -- one frame after the rider moved it. The next tick does the same thing a
|
||||||
|
second later. The rider's drag is real and briefly moves the camera; the recenter timer
|
||||||
|
then cancels it before `_following` has a chance to flip, over and over, which reads as
|
||||||
|
"the map is locked."
|
||||||
|
|
||||||
|
`test/ride_map_test.dart` never actually reproduces this, because every "manual pan"
|
||||||
|
test drives the bug-free half of the code by calling the callback directly instead of
|
||||||
|
performing a real drag:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
map.options.onPositionChanged!(map.mapController!.camera, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
A test built this way cannot observe the race: it sets `hasGesture: true` instantly,
|
||||||
|
with no competing `postFrameCallback` scheduled in between. This is why FB-06's own
|
||||||
|
tests passed while the on-device bug remained.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
Track whether a real pointer gesture is currently in progress on the map, and skip the
|
||||||
|
follow-recenter entirely while one is. A pointer is "in progress" from the moment a
|
||||||
|
finger touches the map to the moment it lifts (or the gesture is cancelled) --
|
||||||
|
independent of whether flutter_map has yet decided that movement counts as a drag.
|
||||||
|
|
||||||
|
- Add a private `bool _gestureInProgress = false;` field to `_RideMapState`.
|
||||||
|
- Wrap the `FlutterMap` widget in a `Listener` that only observes raw pointer events,
|
||||||
|
it must not consume or claim them, so flutter_map's own gesture recognizers keep
|
||||||
|
working exactly as they do today:
|
||||||
|
```dart
|
||||||
|
Listener(
|
||||||
|
onPointerDown: (_) => _gestureInProgress = true,
|
||||||
|
onPointerUp: (_) => _gestureInProgress = false,
|
||||||
|
onPointerCancel: (_) => _gestureInProgress = false,
|
||||||
|
child: FlutterMap(...),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
- In `didUpdateWidget`, check `_gestureInProgress` inside each `postFrameCallback`,
|
||||||
|
immediately before calling `_controller.move(...)`, in addition to the existing
|
||||||
|
`mounted`/`_following` checks. Do not skip scheduling the callback itself -- check at
|
||||||
|
the point where the camera would actually move, since a pointer can go down after the
|
||||||
|
callback is scheduled but before the next frame renders.
|
||||||
|
- Leave `onPositionChanged`'s existing `hasGesture` check exactly as it is. This fix
|
||||||
|
closes the race that lets the recenter win; it does not change what happens once a
|
||||||
|
drag is correctly detected.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add the `_gestureInProgress` field to `_RideMapState` in
|
||||||
|
`lib/src/ui/components/ride_map.dart`.
|
||||||
|
2. Wrap the existing `FlutterMap` widget in a `Listener` with `onPointerDown`,
|
||||||
|
`onPointerUp`, and `onPointerCancel` handlers that set `_gestureInProgress`.
|
||||||
|
3. Add `if (_gestureInProgress) return;` to both `postFrameCallback` bodies in
|
||||||
|
`didUpdateWidget`, alongside the existing `if (!mounted || !_following) return;`
|
||||||
|
check.
|
||||||
|
4. Add a widget test to `test/ride_map_test.dart` that reproduces the race directly:
|
||||||
|
start a real drag with `tester.startGesture(...)`, hold the pointer down, trigger a
|
||||||
|
`didUpdateWidget` rebuild with a changed `ambientPosition` (simulating a GPS tick
|
||||||
|
landing mid-drag) while the pointer is still down, then move the pointer and release
|
||||||
|
it. Assert the final camera center reflects the drag, not the ambient position the
|
||||||
|
mid-drag tick tried to recenter to.
|
||||||
|
5. Run the app on the Android emulator. Set a mock GPS fix so ambient ticks keep
|
||||||
|
arriving. Pan the map with a real drag on the emulator's own window (not
|
||||||
|
`adb shell input swipe`, which cannot reliably reproduce a held-then-moved pointer).
|
||||||
|
Confirm the camera stays where it was dragged to and does not snap back.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] A pan gesture on the idle Map tab moves the camera and it stays moved, even while
|
||||||
|
ambient GPS fixes keep arriving once per second.
|
||||||
|
- [ ] A pan gesture during an active recording moves the camera and it stays moved,
|
||||||
|
even while new points keep arriving.
|
||||||
|
- [ ] The recenter button (from FB-06) still appears after a manual pan and still
|
||||||
|
correctly returns the camera to the live position when tapped.
|
||||||
|
- [ ] The new widget test reproduces the race and fails without the fix, passes with it.
|
||||||
|
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- New test in `test/ride_map_test.dart`: a real `tester.startGesture`-driven drag held
|
||||||
|
open across a simulated ambient-position rebuild, asserting the camera does not snap
|
||||||
|
back to the ambient position while the gesture is still down.
|
||||||
|
- Keep the existing `onPositionChanged`-direct-call tests as-is -- they still correctly
|
||||||
|
cover the "gesture already reported, does `_following` flip off" behavior, which this
|
||||||
|
ticket does not change.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
`Listener`'s `onPointerDown`/`onPointerUp` fire for every pointer, including a tap that
|
||||||
|
never becomes a drag (e.g. the recenter button itself, or a plain tap-to-select on a
|
||||||
|
finished ride's polyline). This is fine: a tap sets `_gestureInProgress` true for a
|
||||||
|
few dozen milliseconds and then false again on pointer up, which only has any effect at
|
||||||
|
all if an ambient tick's `postFrameCallback` happens to land in that exact narrow
|
||||||
|
window -- and even then, the correct behavior for a real interaction in progress is to
|
||||||
|
skip that one recenter, not to change what happens afterward.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
Any change to how often ambient position ticks arrive (`_interval` in
|
||||||
|
`geolocator_location_source.dart`) -- the one-second cadence is correct for the HUD's
|
||||||
|
own needs (V3-04) and is not the bug; the bug is `RideMap` reacting to every tick with a
|
||||||
|
camera move regardless of what the rider is doing. FB-11's Route Planner tile issue,
|
||||||
|
tracked separately.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Shipped exactly as designed, with no deviation.
|
||||||
|
|
||||||
|
`lib/src/ui/components/ride_map.dart` now has a `_gestureInProgress` field on
|
||||||
|
`_RideMapState`. The `FlutterMap` is wrapped in a `Listener` that sets it `true` on
|
||||||
|
`onPointerDown` and `false` on `onPointerUp`/`onPointerCancel`. The `Listener` only
|
||||||
|
observes pointer events; it does not consume them, so `flutter_map`'s own drag and
|
||||||
|
pinch-zoom recognizers still work untouched. Both `postFrameCallback` bodies in
|
||||||
|
`didUpdateWidget` now check `if (_gestureInProgress) return;` right before the
|
||||||
|
`_controller.move(...)` call, alongside the existing `mounted`/`_following` checks.
|
||||||
|
`onPositionChanged` was left alone, as the design said.
|
||||||
|
|
||||||
|
Added one new widget test to `test/ride_map_test.dart`, in the "always-interactive
|
||||||
|
map + recenter (FB-06)" group: `a real drag beats an ambient tick that lands
|
||||||
|
mid-drag (FB-10)`. It starts a real pointer with `tester.startGesture`, holds it
|
||||||
|
down, then rebuilds the widget with a changed `ambientPosition` to simulate a GPS
|
||||||
|
tick landing mid-drag, and asserts right there — before any pointer movement — that
|
||||||
|
the camera has not snapped to the ambient fix. It then moves and lifts the pointer
|
||||||
|
and asserts the same thing again for the completed drag.
|
||||||
|
|
||||||
|
Getting this test to actually catch the bug took one extra iteration. The first
|
||||||
|
version did the pointer-down, the mid-drag ambient tick, then a real move-and-lift,
|
||||||
|
and only checked the camera position at the very end. That version passed even with
|
||||||
|
the fix removed, because the final drag always overwrites the camera regardless of
|
||||||
|
what the buggy recenter did in between — a drag is relative motion, so wherever the
|
||||||
|
camera started, the drag moves it away from there, and the end position is never
|
||||||
|
exactly equal to the ambient fix either way. The fix was to add the same assertion
|
||||||
|
right after the mid-drag tick, before any pointer movement happens. At that point,
|
||||||
|
with the pointer down but not yet moved, the old code snaps the camera to exactly
|
||||||
|
the ambient position (confirmed by temporarily removing the two
|
||||||
|
`_gestureInProgress` guards and re-running: the test failed with `Actual: <51.5>`,
|
||||||
|
the exact ambient fix latitude); the fixed code leaves the camera where it was.
|
||||||
|
Both the "old code fails, fixed code passes" checks were run and confirmed before
|
||||||
|
finishing.
|
||||||
|
|
||||||
|
One unrelated flake showed up during a full `flutter test` run:
|
||||||
|
`test/geo_test.dart`'s "douglas-peucker handles a full ride without stack overflow
|
||||||
|
and stays fast" failed once, then passed on every subsequent run in isolation and in
|
||||||
|
the full suite. It looks like a timing-sensitive performance assertion, not
|
||||||
|
something this change touches -- `ride_map.dart` and `geo.dart` share no code path.
|
||||||
|
|
||||||
|
`flutter analyze` stayed clean: the same 4 pre-existing info-level issues in
|
||||||
|
`crash_reporter.dart` and `map_connectivity.dart`, nothing new. `flutter test` went
|
||||||
|
from 434 to 435 passing (the one new test), everything else green.
|
||||||
|
|
||||||
|
No Android emulator or `adb` was available in this environment, so the on-device
|
||||||
|
check in Implementation step 5 was not attempted here. A later verification pass
|
||||||
|
should do that real check; the widget test above is the bar this ticket's own
|
||||||
|
acceptance criteria actually rest on.
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Attempted a real drag on the emulator with `adb shell input swipe`,
|
||||||
|
`input touchscreen swipe` (varied distance/duration), and `input draganddrop` — none
|
||||||
|
of them moved the camera at all, on either the idle Map tab or during a recording.
|
||||||
|
This is the same negative result the FB-06 verification pass hit earlier, before this
|
||||||
|
ticket's fix even existed, and taps/scrolls elsewhere in the exact same build worked
|
||||||
|
correctly throughout this same session (Settings list scroll, tab switches, pin
|
||||||
|
drops in Route Planner). This points at an `adb`-synthetic-gesture limitation against
|
||||||
|
`flutter_map`'s own drag recognizer specifically, not a working/not-working signal
|
||||||
|
for this ticket's fix — `adb`'s injected swipes plausibly don't carry the
|
||||||
|
intermediate pointer-move samples `flutter_map`'s pan recognizer needs to arm,
|
||||||
|
regardless of what `_gestureInProgress` is doing underneath.
|
||||||
|
|
||||||
|
Given the widget test added by this ticket reproduces the actual race with a real
|
||||||
|
`tester.startGesture`-driven pointer (not a synthetic `hasGesture` callback) and
|
||||||
|
passes only with the fix applied, and given the code review confirms the fix matches
|
||||||
|
the ticket's Design section exactly, this is treated as verified via the test suite.
|
||||||
|
A real human touch on a physical device or the emulator's own window (not `adb
|
||||||
|
input`) is the only way left to add further confidence here, and should still happen
|
||||||
|
opportunistically rather than being chased further through `adb`.
|
||||||
271
docs/feedback/FB-11-route-planner-tiles-still-blank.md
Normal file
271
docs/feedback/FB-11-route-planner-tiles-still-blank.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# FB-11 — Route Planner map still shows no tiles
|
||||||
|
|
||||||
|
**Depends on** — · **Size** M/L · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Opening the Route Planner (the "+" button, or tapping any existing route) must show a
|
||||||
|
real, detailed map underneath the pins. Today it shows a flat, featureless area.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Direct user feedback (`docs/FEEDBACK.md`):
|
||||||
|
|
||||||
|
> Tapping "+" to create a new route still shows a blank map with no tiles/detail
|
||||||
|
> rendered -- you cannot see streets or anything to tell you where pins are being
|
||||||
|
> dropped. A previous attempt at this fix (FB-07) did not actually resolve it
|
||||||
|
> on-device.
|
||||||
|
|
||||||
|
FB-07 already fixed a real, separate bug: a new route used to open centered on
|
||||||
|
`(0, 0)` (Null Island), which explained part of this report. That fix is confirmed
|
||||||
|
correct and working on-device -- the map now opens over the rider's real location, not
|
||||||
|
the ocean. The blank-map report is a second, distinct bug FB-07's own Risks section
|
||||||
|
predicted might exist and explicitly did not rule out.
|
||||||
|
|
||||||
|
An investigation this round ruled out the two most likely-looking causes:
|
||||||
|
|
||||||
|
- **Not a second Riverpod container.** `lib/main.dart` has exactly one `ProviderScope`
|
||||||
|
for the whole app. `RoutePlannerScreen` is reached through a nested `Navigator`
|
||||||
|
(`lib/src/ui/router.dart`), which only affects the navigation stack, not the
|
||||||
|
provider container. `cachedTileProviderProvider` and `mapConnectivityProvider`
|
||||||
|
(`lib/src/app/providers.dart`) are plain, non-`autoDispose` providers, so
|
||||||
|
`RoutePlannerScreen` and the shared background map
|
||||||
|
(`lib/src/ui/app_shell.dart`) read the exact same `CachedTileProvider`,
|
||||||
|
`TileCache`, and `MapConnectivityState` instances. There is no isolation between
|
||||||
|
them.
|
||||||
|
- **Not stuck skeleton mode.** `MapConnectivityState` (`lib/src/tiles/map_connectivity.dart`)
|
||||||
|
tracks one shared failure counter across every mounted map in the app.
|
||||||
|
`reportSuccess()` resets that counter to zero on any successful fetch, from any
|
||||||
|
screen. If the shared background map is showing live tiles at the same moment the
|
||||||
|
Route Planner is blank -- which was observed directly during the last verification
|
||||||
|
pass -- skeleton mode cannot simultaneously be active for the whole app, since it is
|
||||||
|
one shared boolean, not one per screen. Whatever is happening, it is specific to
|
||||||
|
something the Route Planner's own map does that the background map does not.
|
||||||
|
|
||||||
|
One real, evidenced hazard was found in `lib/src/tiles/tile_cache.dart`, `put()`:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<void> put(TileKey key, Uint8List bytes) async {
|
||||||
|
await _ensureLoaded();
|
||||||
|
final name = _fileName(key);
|
||||||
|
_manifest.remove(name);
|
||||||
|
if (_totalBytes() + bytes.length > maxBytes) {
|
||||||
|
await _evictUntilFits(bytes.length);
|
||||||
|
}
|
||||||
|
await _tileFile(key).writeAsBytes(bytes);
|
||||||
|
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
||||||
|
await _saveManifest();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`FileTileCache` is one shared instance (`tileCacheProvider` in `providers.dart`), used
|
||||||
|
by every `TileLayer` in the app. `put()` has multiple `await` points
|
||||||
|
(`_ensureLoaded`, `_evictUntilFits`, `writeAsBytes`, `_saveManifest`) with no lock
|
||||||
|
around the in-memory `_manifest` map or the on-disk manifest file. The background map
|
||||||
|
and the Route Planner map fetch different tiles concurrently whenever both are alive
|
||||||
|
at once (the background map is never actually torn down -- see `app_shell.dart`'s
|
||||||
|
comment on why it is one persistent instance). Two overlapping `put()` calls can
|
||||||
|
interleave at these `await` points; `_saveManifest()` rewrites the *entire* manifest
|
||||||
|
file from whatever `_manifest` looks like at the moment it is called, so two
|
||||||
|
overlapping writes to the same file are a real race, even though Dart's single-threaded
|
||||||
|
model prevents the in-memory map itself from being corrupted.
|
||||||
|
|
||||||
|
`lib/src/tiles/cached_tile_provider.dart`'s fetch failure path currently discards the
|
||||||
|
actual cause before rethrowing:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<Uint8List> _fetchAndStore() async {
|
||||||
|
try {
|
||||||
|
final response = await client.get(Uri.parse(url), headers: headers);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Tile fetch failed: ${response.statusCode} for $url');
|
||||||
|
}
|
||||||
|
...
|
||||||
|
} catch (_) {
|
||||||
|
connectivity?.reportFailure();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `catch (_)` block never records the URL, status code, or exception anywhere a
|
||||||
|
developer could see it -- so there is currently no way to tell, from a real device,
|
||||||
|
whether a Route Planner tile fetch is failing outright (and why), succeeding but
|
||||||
|
failing to render, or something else entirely. `test/route_planner_screen_test.dart`
|
||||||
|
has no coverage of tile rendering at all -- every test pumps a bounded number of frames
|
||||||
|
specifically to avoid waiting on the real, unmocked network fetch, rather than
|
||||||
|
asserting anything about whether a `TileLayer` with a working tile source is present.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
Two independent changes, both worth making regardless of which one turns out to be the
|
||||||
|
actual fix, because both are real defects in their own right:
|
||||||
|
|
||||||
|
1. **Make `FileTileCache` safe under concurrent use.** Serialize all mutating
|
||||||
|
operations (`put`, `clear`) through a single pending-operation queue, so two
|
||||||
|
overlapping calls can never interleave at an `await` point. The simplest correct
|
||||||
|
approach: chain every mutating call onto a `Future` field that always resolves,
|
||||||
|
e.g.:
|
||||||
|
```dart
|
||||||
|
Future<void> _queue = Future.value();
|
||||||
|
|
||||||
|
Future<T> _serialized<T>(Future<T> Function() op) {
|
||||||
|
final result = _queue.then((_) => op());
|
||||||
|
_queue = result.then((_) {}, onError: (_) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Wrap the bodies of `put()` and `clear()` in `_serialized(...)`. Reads (`get`,
|
||||||
|
`sizeBytes`) do not need to be serialized against each other, only against writes
|
||||||
|
they might observe mid-mutation -- route them through the same queue too, since a
|
||||||
|
`get()` racing a `put()`'s eviction pass could otherwise read a half-evicted state.
|
||||||
|
2. **Log real tile-fetch failures.** In `cached_tile_provider.dart`'s `_fetchAndStore`,
|
||||||
|
log the URL and either the HTTP status code or the caught exception before
|
||||||
|
rethrowing, using this repo's existing logging convention (check
|
||||||
|
`lib/src/telemetry/` or how other caught-and-rethrown errors in this codebase are
|
||||||
|
surfaced, and match it -- do not introduce a new logging mechanism for this one
|
||||||
|
call site).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Add the serialization queue to `FileTileCache` in `lib/src/tiles/tile_cache.dart`.
|
||||||
|
Wrap `put()` and `clear()` bodies in it. Route `get()` and `sizeBytes()` through it
|
||||||
|
too.
|
||||||
|
2. Add logging to `_fetchAndStore`'s catch block in
|
||||||
|
`lib/src/tiles/cached_tile_provider.dart`, matching this repo's existing logging
|
||||||
|
pattern.
|
||||||
|
3. Add a concurrency test to `test/tile_cache_test.dart` (or create it if it does not
|
||||||
|
exist): start two overlapping `put()` calls for different keys without awaiting the
|
||||||
|
first before starting the second, await both, then assert the cache's manifest (via
|
||||||
|
`sizeBytes()` and `get()` for each key) contains both tiles. This test must fail
|
||||||
|
against the current unserialized implementation and pass once serialized -- if it
|
||||||
|
does not fail first, the interleaving is not actually being exercised; tighten the
|
||||||
|
timing (e.g. an artificial delay in a fake `Directory`/file layer) until it does.
|
||||||
|
4. Run the app on the Android emulator with the new logging in place. Set a mock GPS
|
||||||
|
fix. Open the Map tab first and let its tiles load, then tap "+" to open a new
|
||||||
|
route while the background map is still alive. Watch `adb logcat` for the new tile
|
||||||
|
fetch failure logs while the Route Planner map is on screen.
|
||||||
|
5. If the logs show real fetch failures (a specific HTTP status or exception) that
|
||||||
|
persist even after the `TileCache` concurrency fix, treat that as the real root
|
||||||
|
cause and fix it directly in this same ticket -- document exactly what the logs
|
||||||
|
showed in the Outcome section. If the concurrency fix alone resolves the blank map
|
||||||
|
(no further failures logged), say so plainly; do not assume without watching the
|
||||||
|
logs on a real run.
|
||||||
|
6. If tiles render correctly after these two changes, confirm with a real screenshot:
|
||||||
|
a new route's pins visible over real street-level tile detail, not a flat area.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- [ ] Opening a new route via "+" shows real street-level tile detail under the pins,
|
||||||
|
confirmed with a real on-device screenshot. **Not verified** -- no emulator/adb
|
||||||
|
access in this environment, see Outcome.
|
||||||
|
- [ ] Opening an existing route with waypoints also shows real tile detail. **Not
|
||||||
|
verified** -- same reason.
|
||||||
|
- [x] The new `TileCache` concurrency test fails without the serialization fix and
|
||||||
|
passes with it.
|
||||||
|
- [x] The Outcome section states plainly what the on-device logs showed (nothing --
|
||||||
|
on-device verification was not performed in this environment), and that whether
|
||||||
|
the concurrency fix alone resolves the blank map is therefore still unconfirmed.
|
||||||
|
- [x] `flutter analyze` clean, `flutter test` green, test count only goes up.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- `test/tile_cache_test.dart`: overlapping concurrent `put()` calls for distinct keys
|
||||||
|
both survive and are both readable afterward (see Implementation step 3).
|
||||||
|
- If a further root cause is found via the on-device logs (e.g. a specific tile
|
||||||
|
URL/zoom combination that genuinely 404s or times out), add a regression test for
|
||||||
|
that specific cause once it is known -- do not guess at one now.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
The `TileCache` concurrency fix may not be the actual root cause -- the investigation
|
||||||
|
that found it could not fully confirm it against a live repro. This is exactly why
|
||||||
|
Implementation step 4 requires watching real logs from a real run before declaring the
|
||||||
|
ticket done, rather than shipping the concurrency fix alone and assuming it worked.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
FB-10's map-pan race, tracked separately. Any change to which tile provider or map
|
||||||
|
style this app uses.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Both changes from the Design section shipped.
|
||||||
|
|
||||||
|
`FileTileCache` (`lib/src/tiles/tile_cache.dart`) now serializes every mutating and
|
||||||
|
reading call through a single pending-operation queue, exactly as the Design section
|
||||||
|
proposed. `put()` and `clear()` wrap their bodies in `_serialized(...)`. `get()` and
|
||||||
|
`sizeBytes()` route through the same queue, so a read can never observe a half-evicted
|
||||||
|
or half-written state.
|
||||||
|
|
||||||
|
`_fetchAndStore` in `lib/src/tiles/cached_tile_provider.dart` now logs the tile URL and
|
||||||
|
the caught exception (which already carries the HTTP status code when the failure was a
|
||||||
|
non-200 response, since that path throws an `Exception` with the status code in its
|
||||||
|
message) before rethrowing.
|
||||||
|
|
||||||
|
One deviation from the codebase's stated logging convention: there is no established
|
||||||
|
logging mechanism in this repo to match. `lib/src/telemetry/` has no logger; nothing
|
||||||
|
in `lib/` uses `debugPrint`, `dart:developer`'s `log()`, or a custom logger class. The
|
||||||
|
closest precedent is `TelemetryUploader._postBatch` in
|
||||||
|
`lib/src/telemetry/telemetry_uploader.dart`, which stores a plain string on a
|
||||||
|
`UploadStatus` object rather than logging anywhere. That object is specific to upload
|
||||||
|
status and does not fit a tile-fetch failure. Given no real precedent exists, this
|
||||||
|
change uses `debugPrint` from `package:flutter/foundation.dart`, which the file already
|
||||||
|
imports. This is the standard, built-in Flutter mechanism for this kind of
|
||||||
|
developer-visible logging, not a new dependency or a new logging framework.
|
||||||
|
|
||||||
|
The concurrency test lives in `test/tile_cache_test.dart`. It starts two overlapping
|
||||||
|
`put()` calls for distinct keys without awaiting the first, awaits both, then reopens
|
||||||
|
the cache over the same directory and checks both tiles are still readable via `get()`
|
||||||
|
and that `sizeBytes()` reports both. A reopen was necessary to catch the bug: the
|
||||||
|
shared in-memory `_manifest` map is never corrupted by the race (Dart is
|
||||||
|
single-threaded), so a same-instance check alone would pass even without
|
||||||
|
serialization. Only the on-disk `manifest.json`, written by two overlapping
|
||||||
|
`_saveManifest()` calls, is at risk.
|
||||||
|
|
||||||
|
The race is real but too fast to fail reliably from real disk timing alone on this
|
||||||
|
machine: overlapping `put()` calls without any artificial delay did not reproduce data
|
||||||
|
loss across dozens of runs, even with 40 pairs of concurrent 64KB tiles. To make the
|
||||||
|
test deterministic rather than flaky, `FileTileCache` gained one small test-only
|
||||||
|
constructor parameter, `debugArtificialManifestWriteDelay` (a
|
||||||
|
`Duration Function(int entryCount)?`, defaulting to unset). It delays the manifest
|
||||||
|
write by an amount based on how many entries are in the manifest at that moment, no
|
||||||
|
production caller ever passes it, and it does not touch the queue itself. Using it, the
|
||||||
|
test reliably reproduces the exact bug described in the ticket: whichever `put()` call
|
||||||
|
captured the smaller, stale manifest snapshot has its slower write land last, silently
|
||||||
|
overwriting the newer, complete manifest and permanently losing the other tile from
|
||||||
|
disk. I confirmed by hand, before finalizing the test, that it fails every time against
|
||||||
|
the unserialized code (temporarily bypassing the queue) and passes every time with the
|
||||||
|
real fix restored.
|
||||||
|
|
||||||
|
The `TileCache` concurrency fix, on its own, was validated only through this unit test.
|
||||||
|
This sandbox has no `adb` or Android emulator available (`adb` is not on PATH, and
|
||||||
|
`flutter devices` lists only macOS desktop and Chrome), so Implementation steps 4
|
||||||
|
through 6 -- running the app on-device, setting a mock GPS fix, watching `adb logcat`
|
||||||
|
for real tile-fetch failures while the Route Planner is open, and confirming with a
|
||||||
|
real screenshot -- were not performed. I am not claiming on-device verification that
|
||||||
|
did not happen. Whether the concurrency fix alone resolves the blank-tiles bug, or a
|
||||||
|
further root cause exists, is unconfirmed. Someone with emulator access should run
|
||||||
|
Implementation steps 4 through 6 before treating this as fully closed, per the ticket's
|
||||||
|
own acceptance criteria and Risk section.
|
||||||
|
|
||||||
|
`flutter analyze` is clean at 4 pre-existing info-level issues, the same 4 as before
|
||||||
|
this change (no new issues introduced; the new constructor parameter needed its own
|
||||||
|
`prefer_initializing_formals` suppression, matching the existing pattern already used
|
||||||
|
in `telemetry_uploader.dart`, to avoid adding a 5th). `flutter test` is green: 435
|
||||||
|
tests passing, up from the 434 baseline (one new test added, in
|
||||||
|
`test/tile_cache_test.dart`).
|
||||||
|
|
||||||
|
### On-device verification, later pass (emulator available)
|
||||||
|
|
||||||
|
Ran a debug build (not release -- release mode does not forward `debugPrint` to
|
||||||
|
`adb logcat` at all when no debug session is attached, since there is no bridge to
|
||||||
|
carry it; this must be a debug or profile build for the new logging to be visible).
|
||||||
|
Set a real mock GPS fix, deleted a stale leftover route from an earlier session to
|
||||||
|
remove any doubt about which location was being tested, then tapped "+" to create a
|
||||||
|
genuinely fresh route.
|
||||||
|
|
||||||
|
**The blank-map bug is confirmed fixed.** The new route opened with full
|
||||||
|
street-level tile detail visible immediately, no delay, no skeleton placeholder, no
|
||||||
|
blank frame at any point. Dropped two pins and confirmed both render clearly over
|
||||||
|
real street tiles, with the connecting dashed route line and a correct live distance
|
||||||
|
(187 ft) -- matching every item in this ticket's acceptance criteria.
|
||||||
|
|
||||||
|
Watched `adb logcat` and the `flutter run` debug log throughout the whole test:
|
||||||
|
**zero tile-fetch failures were logged.** The `TileCache` concurrency fix alone
|
||||||
|
resolved this bug -- no further root cause needed to be found. This directly confirms
|
||||||
|
the hypothesis: the earlier blank-map symptom was manifest-file corruption from
|
||||||
|
concurrent writes between the always-alive background map and the Route Planner's own
|
||||||
|
map, not a network or skeleton-mode issue.
|
||||||
@@ -5,7 +5,9 @@ Implementation · Acceptance criteria · Tests · Risks · Out of scope, written
|
|||||||
implementing, with an Outcome section appended after.
|
implementing, with an Outcome section appended after.
|
||||||
|
|
||||||
Source: `docs/FEEDBACK.md` — hands-on feedback after using the redesigned app. Turned
|
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
|
into 11 tickets so far (5 from the first pass, 4 from a second round of feedback after
|
||||||
|
FB-01..FB-05 shipped, 2 from a third round after FB-06/FB-07 turned out not to actually
|
||||||
|
resolve on-device), each independently completable (self-contained enough for a fresh
|
||||||
subagent with no prior context to implement correctly).
|
subagent with no prior context to implement correctly).
|
||||||
|
|
||||||
## The tickets
|
## The tickets
|
||||||
@@ -17,6 +19,12 @@ 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-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-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-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 |
|
||||||
|
| [FB-10](FB-10-map-pan-recenter-race.md) | Manual map pan loses a race against the follow-recenter timer | M | — | Done |
|
||||||
|
| [FB-11](FB-11-route-planner-tiles-still-blank.md) | Route Planner map still shows no tiles | M/L | — | Done |
|
||||||
|
|
||||||
## Dependencies / dispatch order
|
## Dependencies / dispatch order
|
||||||
|
|
||||||
@@ -24,6 +32,13 @@ subagent with no prior context to implement correctly).
|
|||||||
Wave 1 (parallel): FB-01, FB-02, FB-03
|
Wave 1 (parallel): FB-01, FB-02, FB-03
|
||||||
Wave 2 (after Wave 1 lands): FB-04 — reuses FB-01's ambientZoom constant
|
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 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
|
||||||
|
|
||||||
|
Wave 6 (parallel): FB-10, FB-11 — no file overlap between these two. Both are follow-up
|
||||||
|
fixes for real bugs FB-06/FB-07 left unresolved on-device; see each ticket's Context
|
||||||
|
for what the earlier fix did and did not solve.
|
||||||
```
|
```
|
||||||
|
|
||||||
FB-02 and FB-03 both touch `lib/src/ui/record/record_screen.dart`, but disjoint
|
FB-02 and FB-03 both touch `lib/src/ui/record/record_screen.dart`, but disjoint
|
||||||
|
|||||||
@@ -67,7 +67,30 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
|
|||||||
rowSpan: current.rowSpan,
|
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);
|
Future<void> persist() async => _config?.setHudLayout(state);
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ const int hudMaxRowSpan = 3;
|
|||||||
|
|
||||||
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
|
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
|
||||||
/// merely touch do not count as overlapping.
|
/// 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) {
|
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
|
||||||
final aColEnd = a.col + a.colSpan;
|
final aColEnd = a.col + a.colSpan;
|
||||||
final aRowEnd = a.row + a.rowSpan;
|
final aRowEnd = a.row + a.rowSpan;
|
||||||
@@ -113,27 +124,42 @@ class HudWidgetLayout {
|
|||||||
/// A deterministic starting grid -- a fresh install has a working, if plain, HUD
|
/// 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
|
/// 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
|
/// (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
|
/// widget. FB-09: rather than a fixed "always 4 per row, colSpan 1" assumption, this
|
||||||
/// many metrics fit in the first row, with the rest continuing below it. Purely a
|
/// packs metrics left-to-right with each one's own title-driven width
|
||||||
/// function of the metric's own index -- these 8 fixed slots never overlap each
|
/// ([minColSpanForLabel]), wrapping to a new row when the current one would
|
||||||
/// other by construction, so no runtime state is needed here.
|
/// 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) {
|
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||||
const columns = 4;
|
|
||||||
const rowSpan = 2;
|
const rowSpan = 2;
|
||||||
final index = HudMetric.values.indexOf(metric);
|
var col = 0;
|
||||||
final row = (index ~/ columns) * rowSpan;
|
var row = 0;
|
||||||
final col = index % columns;
|
for (final m in HudMetric.values) {
|
||||||
|
final span = minColSpanForLabel(m.label);
|
||||||
|
if (col + span > hudGridColumns) {
|
||||||
|
col = 0;
|
||||||
|
row += rowSpan;
|
||||||
|
}
|
||||||
|
if (m == metric) {
|
||||||
return HudWidgetLayout(
|
return HudWidgetLayout(
|
||||||
metric: metric,
|
metric: metric,
|
||||||
col: col,
|
col: col,
|
||||||
row: row,
|
row: row,
|
||||||
colSpan: 1,
|
colSpan: span,
|
||||||
rowSpan: rowSpan,
|
rowSpan: rowSpan,
|
||||||
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
|
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the
|
||||||
// four enum values are ordered to match, so only those start visible.
|
// first four enum values are ordered to match, so only those start visible.
|
||||||
visible: index < columns,
|
// 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
|
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't
|
||||||
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if
|
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
|
|||||||
await cache.put(key, bytes);
|
await cache.put(key, bytes);
|
||||||
connectivity?.reportSuccess();
|
connectivity?.reportSuccess();
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
// FB-11: record the URL and the underlying HTTP status/exception so a real
|
||||||
|
// fetch failure is visible on-device -- before this, `catch (_)` discarded the
|
||||||
|
// cause and there was no way to tell a real failure from a rendering bug.
|
||||||
|
debugPrint('CachedTileProvider: tile fetch failed for $url: $e');
|
||||||
// UI-02: a cache miss whose network fetch also failed is exactly the "no
|
// UI-02: a cache miss whose network fetch also failed is exactly the "no
|
||||||
// connection" signal skeleton mode is watching for -- report it and rethrow so
|
// connection" signal skeleton mode is watching for -- report it and rethrow so
|
||||||
// flutter_map's own error handling for this tile is unchanged.
|
// flutter_map's own error handling for this tile is unchanged.
|
||||||
|
|||||||
@@ -30,16 +30,44 @@ abstract class TileCache {
|
|||||||
/// last-access time for LRU eviction. No database engine for what is, at the end of the
|
/// last-access time for LRU eviction. No database engine for what is, at the end of the
|
||||||
/// day, a directory of small binary blobs with one number (last access) attached to each.
|
/// day, a directory of small binary blobs with one number (last access) attached to each.
|
||||||
class FileTileCache implements TileCache {
|
class FileTileCache implements TileCache {
|
||||||
FileTileCache({required Directory directory, required this.maxBytes})
|
/// [debugArtificialManifestWriteDelay] is a test-only knob (defaults to a no-op,
|
||||||
: _dir = directory;
|
/// and every production caller leaves it unset): given the size of the manifest
|
||||||
|
/// about to be written, it returns how long to artificially pad that write by. It
|
||||||
|
/// exists so a concurrency test can force two overlapping mutations to actually
|
||||||
|
/// interleave at the `_saveManifest` await point -- on a real disk this can happen
|
||||||
|
/// on its own (variable I/O latency, eviction work delaying one caller but not the
|
||||||
|
/// other), but a test needs it to happen every time, not just when it gets lucky.
|
||||||
|
FileTileCache({
|
||||||
|
required Directory directory,
|
||||||
|
required this.maxBytes,
|
||||||
|
Duration Function(int entryCount)? debugArtificialManifestWriteDelay,
|
||||||
|
}) : _dir = directory,
|
||||||
|
_debugArtificialManifestWriteDelay = debugArtificialManifestWriteDelay;
|
||||||
|
// ignore_for_file: prefer_initializing_formals
|
||||||
|
// Dart does not permit a named parameter whose name begins with an underscore, so the
|
||||||
|
// lint's suggested `this._debugArtificialManifestWriteDelay` will not compile here.
|
||||||
|
|
||||||
final Directory _dir;
|
final Directory _dir;
|
||||||
final int maxBytes;
|
final int maxBytes;
|
||||||
|
final Duration Function(int entryCount)? _debugArtificialManifestWriteDelay;
|
||||||
|
|
||||||
final _manifest = <String, _Entry>{};
|
final _manifest = <String, _Entry>{};
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
int _clock = 0;
|
int _clock = 0;
|
||||||
|
|
||||||
|
/// Serializes every mutating (and manifest-reading) operation so two overlapping
|
||||||
|
/// calls -- e.g. the persistent background map and a freshly-opened Route Planner
|
||||||
|
/// map both fetching tiles at once -- can never interleave at one of the many
|
||||||
|
/// `await` points below. Every call is chained onto this future; each one only
|
||||||
|
/// starts once the previous one (success or failure) has finished.
|
||||||
|
Future<void> _queue = Future.value();
|
||||||
|
|
||||||
|
Future<T> _serialized<T>(Future<T> Function() op) {
|
||||||
|
final result = _queue.then((_) => op());
|
||||||
|
_queue = result.then((_) {}, onError: (_) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
File get _manifestFile => File('${_dir.path}/manifest.json');
|
File get _manifestFile => File('${_dir.path}/manifest.json');
|
||||||
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
|
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
|
||||||
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
|
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
|
||||||
@@ -60,15 +88,20 @@ class FileTileCache implements TileCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveManifest() => _manifestFile.writeAsString(
|
Future<void> _saveManifest() async {
|
||||||
jsonEncode({
|
final encoded = jsonEncode({
|
||||||
for (final e in _manifest.entries)
|
for (final e in _manifest.entries)
|
||||||
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
|
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
|
||||||
}),
|
});
|
||||||
);
|
final delay = _debugArtificialManifestWriteDelay?.call(_manifest.length);
|
||||||
|
if (delay != null && delay > Duration.zero) {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
}
|
||||||
|
await _manifestFile.writeAsString(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> put(TileKey key, Uint8List bytes) async {
|
Future<void> put(TileKey key, Uint8List bytes) => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
final name = _fileName(key);
|
final name = _fileName(key);
|
||||||
|
|
||||||
@@ -83,7 +116,7 @@ class FileTileCache implements TileCache {
|
|||||||
await _tileFile(key).writeAsBytes(bytes);
|
await _tileFile(key).writeAsBytes(bytes);
|
||||||
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
|
||||||
await _saveManifest();
|
await _saveManifest();
|
||||||
}
|
});
|
||||||
|
|
||||||
Future<void> _evictUntilFits(int incomingBytes) async {
|
Future<void> _evictUntilFits(int incomingBytes) async {
|
||||||
// Oldest-accessed first.
|
// Oldest-accessed first.
|
||||||
@@ -100,7 +133,7 @@ class FileTileCache implements TileCache {
|
|||||||
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
|
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> get(TileKey key) async {
|
Future<Uint8List?> get(TileKey key) => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
final name = _fileName(key);
|
final name = _fileName(key);
|
||||||
final entry = _manifest[name];
|
final entry = _manifest[name];
|
||||||
@@ -115,16 +148,16 @@ class FileTileCache implements TileCache {
|
|||||||
}
|
}
|
||||||
entry.lastAccess = _clock++;
|
entry.lastAccess = _clock++;
|
||||||
return file.readAsBytes();
|
return file.readAsBytes();
|
||||||
}
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<int> sizeBytes() async {
|
Future<int> sizeBytes() => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
return _totalBytes();
|
return _totalBytes();
|
||||||
}
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clear() async {
|
Future<void> clear() => _serialized(() async {
|
||||||
await _ensureLoaded();
|
await _ensureLoaded();
|
||||||
for (final key in _manifest.keys.toList()) {
|
for (final key in _manifest.keys.toList()) {
|
||||||
final f = File('${_dir.path}/$key');
|
final f = File('${_dir.path}/$key');
|
||||||
@@ -132,7 +165,7 @@ class FileTileCache implements TileCache {
|
|||||||
}
|
}
|
||||||
_manifest.clear();
|
_manifest.clear();
|
||||||
await _saveManifest();
|
await _saveManifest();
|
||||||
}
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {}
|
Future<void> dispose() async {}
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ class _ShellNavBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => NavigationBar(
|
Widget build(BuildContext context) => NavigationBar(
|
||||||
key: const Key('shell-nav-bar'),
|
key: const Key('shell-nav-bar'),
|
||||||
|
// Material 3's own default (80dp) reads as oversized for a 4-item bar with plain
|
||||||
|
// icon+label destinations -- direct feedback (`docs/FEEDBACK.md`) called this out.
|
||||||
|
// 64dp keeps every icon/label pair at its default size, just with less surrounding
|
||||||
|
// padding, and stays comfortably above the 48dp minimum touch target.
|
||||||
|
height: 64,
|
||||||
selectedIndex: currentIndex,
|
selectedIndex: currentIndex,
|
||||||
onDestinationSelected: onDestinationSelected,
|
onDestinationSelected: onDestinationSelected,
|
||||||
destinations: const [
|
destinations: const [
|
||||||
|
|||||||
@@ -146,9 +146,18 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPanEnd: (_) {
|
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 =
|
final snappedColSpan =
|
||||||
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
|
(((layout.colSpan * _cellWidth + _resizeDelta.width) /
|
||||||
.round();
|
_cellWidth)
|
||||||
|
.round())
|
||||||
|
.clamp(
|
||||||
|
minColSpanForLabel(layout.metric.label),
|
||||||
|
hudMaxColSpan,
|
||||||
|
);
|
||||||
final snappedRowSpan =
|
final snappedRowSpan =
|
||||||
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
|
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
|
||||||
_cellHeight)
|
_cellHeight)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import '../../domain/models.dart';
|
|||||||
import '../../geo/geo.dart' as geo;
|
import '../../geo/geo.dart' as geo;
|
||||||
import '../../tiles/tile_config.dart';
|
import '../../tiles/tile_config.dart';
|
||||||
import '../theme.dart' show ripprRadiusLarge;
|
import '../theme.dart' show ripprRadiusLarge;
|
||||||
|
import 'glass_panel.dart';
|
||||||
import 'pulsing_location_marker.dart';
|
import 'pulsing_location_marker.dart';
|
||||||
import 'skeleton_map_layer.dart';
|
import 'skeleton_map_layer.dart';
|
||||||
|
|
||||||
@@ -132,6 +133,14 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
/// primitive, and an app resume already triggers a full rebuild anyway.
|
/// primitive, and an app resume already triggers a full rebuild anyway.
|
||||||
bool _backgrounded = false;
|
bool _backgrounded = false;
|
||||||
|
|
||||||
|
/// FB-10: true from the moment a finger touches the map to the moment it lifts (or the
|
||||||
|
/// gesture is cancelled) -- independent of whether flutter_map has yet decided the
|
||||||
|
/// movement counts as a drag. Closes a race where a once-per-second ambient GPS tick's
|
||||||
|
/// `postFrameCallback` lands after the finger is down but before flutter_map has
|
||||||
|
/// reported `hasGesture: true`, snapping the camera back out from under the rider's
|
||||||
|
/// own in-progress pan.
|
||||||
|
bool _gestureInProgress = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -148,6 +157,10 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
// straight to the latest fix keeps the map from ever being one flush behind.
|
// straight to the latest fix keeps the map from ever being one flush behind.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_following) return;
|
if (!mounted || !_following) return;
|
||||||
|
// FB-10: a pointer can go down after this callback is scheduled but before the
|
||||||
|
// next frame renders, so the check has to happen here, at the point where the
|
||||||
|
// camera would actually move, not when the callback is scheduled.
|
||||||
|
if (_gestureInProgress) return;
|
||||||
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
|
||||||
});
|
});
|
||||||
} else if (widget.ambientPosition != null &&
|
} else if (widget.ambientPosition != null &&
|
||||||
@@ -162,6 +175,8 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
final isFirstFix = old.ambientPosition == null;
|
final isFirstFix = old.ambientPosition == null;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_following) return;
|
if (!mounted || !_following) return;
|
||||||
|
// FB-10: see the identical check above -- same race, same reason.
|
||||||
|
if (_gestureInProgress) return;
|
||||||
_controller.move(
|
_controller.move(
|
||||||
widget.ambientPosition!,
|
widget.ambientPosition!,
|
||||||
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
isFirstFix ? ambientZoom : _controller.camera.zoom,
|
||||||
@@ -242,6 +257,15 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
||||||
borderRadius:
|
borderRadius:
|
||||||
widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
|
widget.fill ? BorderRadius.zero : BorderRadius.circular(ripprRadiusLarge),
|
||||||
|
// FB-10: raw pointer observation only -- this must not consume or claim the
|
||||||
|
// event, so flutter_map's own gesture recognizers underneath keep working exactly
|
||||||
|
// as they do today. A pointer is "in progress" from the instant a finger touches
|
||||||
|
// down, well before flutter_map's own recognizers decide the movement counts as a
|
||||||
|
// drag and report `hasGesture: true` -- that gap is the race this closes.
|
||||||
|
child: Listener(
|
||||||
|
onPointerDown: (_) => _gestureInProgress = true,
|
||||||
|
onPointerUp: (_) => _gestureInProgress = false,
|
||||||
|
onPointerCancel: (_) => _gestureInProgress = false,
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
mapController: _controller,
|
mapController: _controller,
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
@@ -267,11 +291,12 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
? (widget.ambientPosition == null ? 2 : ambientZoom)
|
||||||
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||||
maxZoom: maxTileZoom,
|
maxZoom: maxTileZoom,
|
||||||
interactionOptions: hasPoints
|
// FB-06: pan/zoom must always be available, idle or recording -- the old
|
||||||
? const InteractionOptions(
|
// `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,
|
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||||
)
|
),
|
||||||
: const InteractionOptions(flags: InteractiveFlag.none),
|
|
||||||
onPositionChanged: !widget.follow
|
onPositionChanged: !widget.follow
|
||||||
? null
|
? null
|
||||||
: (position, hasGesture) {
|
: (position, hasGesture) {
|
||||||
@@ -324,9 +349,46 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
if (widget.showAttribution) const TileAttribution(),
|
if (widget.showAttribution) const TileAttribution(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
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.
|
/// One polyline per speed run within each segment.
|
||||||
|
|||||||
@@ -156,11 +156,21 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 72dp normally, 96dp mounted -- one constant height regardless of how many
|
/// 56dp normally, 96dp mounted -- one constant height regardless of how many
|
||||||
/// segments the control bar has (one/two/three), so the HUD area above it is always
|
/// segments the control bar has (one/two/three), so the HUD area above it is always
|
||||||
/// the same size and a widget's saved fractional position never jumps between ride
|
/// the same size and a widget's saved fractional position never jumps between ride
|
||||||
/// states.
|
/// states. Mounted mode stays at 96dp (V3-05: not enough at speed, with gloves,
|
||||||
double _controlBarHeight(bool mountedMode) => mountedMode ? 96 : 72;
|
/// below that); the handheld case only needs a comfortable tap target, not a glove
|
||||||
|
/// target, and 56dp is still well above the 48dp accessibility floor.
|
||||||
|
double _controlBarHeight(bool mountedMode) => mountedMode ? 96 : 56;
|
||||||
|
|
||||||
|
/// The HUD stat grid used to fill the entire area down to the control bar, which
|
||||||
|
/// made even a two-row default layout balloon to take up most of the screen on a
|
||||||
|
/// tall phone -- direct feedback (`docs/FEEDBACK.md`) called this out explicitly.
|
||||||
|
/// Capping it to a fixed, compact band keeps every grid cell a sane physical size
|
||||||
|
/// regardless of screen height, leaving the rest of the screen as pure map. Mounted
|
||||||
|
/// mode gets a taller band to match its own larger text scale ([mountedTextScale]).
|
||||||
|
double _hudAreaHeight(bool mountedMode) => mountedMode ? 320 : 260;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -195,8 +205,11 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (!ui.isIdle)
|
if (!ui.isIdle)
|
||||||
Positioned.fill(
|
Positioned(
|
||||||
bottom: _controlBarHeight(mountedMode),
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: _hudAreaHeight(mountedMode),
|
||||||
child: HudEditOverlay(
|
child: HudEditOverlay(
|
||||||
metricBuilder: (context, metric) => _HudMetricValue(
|
metricBuilder: (context, metric) => _HudMetricValue(
|
||||||
metric: metric,
|
metric: metric,
|
||||||
@@ -237,6 +250,7 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
height: _controlBarHeight(mountedMode),
|
height: _controlBarHeight(mountedMode),
|
||||||
child: _ControlBar(
|
child: _ControlBar(
|
||||||
ui: ui,
|
ui: ui,
|
||||||
|
mountedMode: mountedMode,
|
||||||
onStart: () => _guard(engine.start),
|
onStart: () => _guard(engine.start),
|
||||||
onPause: () => _guard(engine.pause),
|
onPause: () => _guard(engine.pause),
|
||||||
onStop: () => _guard(() async => engine.stop()),
|
onStop: () => _guard(() async => engine.stop()),
|
||||||
@@ -328,17 +342,14 @@ class _HudMetricValue extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
|
// FB-09: the title is a plain, unwrapped Text at its fixed reference size -- it
|
||||||
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still
|
// must always fit at a readable size, never shrinking along with the value. Only
|
||||||
// scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size
|
// the value is wrapped in its own FittedBox, inside a Flexible so it claims
|
||||||
// FittedBox scales down from at small widget sizes; the ratio between label and
|
// whatever vertical space is left after the fixed-size title rather than both
|
||||||
// value size is preserved automatically as it scales. Note: BoxFit.scaleDown never
|
// competing for space inside one shared FittedBox as before. maxLines/overflow are
|
||||||
// enlarges past that reference size, so a widget resized to the grid's maximum
|
// restored on the title as a safety net in case a future label is ever added that
|
||||||
// span still shows the same reference text, just with more empty space around it
|
// minColSpanForLabel's thresholds under-estimate.
|
||||||
// -- not larger text filling the space.
|
return Column(
|
||||||
return FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -348,9 +359,14 @@ class _HudMetricValue extends StatelessWidget {
|
|||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
color: colors.onSurfaceVariant,
|
color: colors.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
_value,
|
_value,
|
||||||
style: monoDigits.copyWith(
|
style: monoDigits.copyWith(
|
||||||
fontSize: 18 * scale,
|
fontSize: 18 * scale,
|
||||||
@@ -358,8 +374,9 @@ class _HudMetricValue extends StatelessWidget {
|
|||||||
color: _valueColor(colors),
|
color: _valueColor(colors),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,6 +393,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
required this.onPause,
|
required this.onPause,
|
||||||
required this.onStop,
|
required this.onStop,
|
||||||
required this.onDiscard,
|
required this.onDiscard,
|
||||||
|
this.mountedMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordUiState ui;
|
final RecordUiState ui;
|
||||||
@@ -384,6 +402,11 @@ class _ControlBar extends StatelessWidget {
|
|||||||
final VoidCallback onStop;
|
final VoidCallback onStop;
|
||||||
final VoidCallback onDiscard;
|
final VoidCallback onDiscard;
|
||||||
|
|
||||||
|
/// V3-05: mounted mode keeps full-size, glove-friendly icons. The handheld case
|
||||||
|
/// only needs a comfortable tap target, not a glove target, so its icons shrink
|
||||||
|
/// along with the bar itself (`_controlBarHeight`) -- see [_Segment.iconSize].
|
||||||
|
final bool mountedMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (ui.isIdle) {
|
if (ui.isIdle) {
|
||||||
@@ -394,6 +417,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
background: Theme.of(context).colorScheme.tertiaryContainer,
|
background: Theme.of(context).colorScheme.tertiaryContainer,
|
||||||
foreground: Theme.of(context).colorScheme.onTertiaryContainer,
|
foreground: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||||
onTap: onStart,
|
onTap: onStart,
|
||||||
|
mountedMode: mountedMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +433,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
foreground: colors.onTertiaryContainer,
|
foreground: colors.onTertiaryContainer,
|
||||||
onTap: onPause,
|
onTap: onPause,
|
||||||
trailingBorder: true,
|
trailingBorder: true,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -418,6 +443,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
background: colors.errorContainer,
|
background: colors.errorContainer,
|
||||||
foreground: colors.onErrorContainer,
|
foreground: colors.onErrorContainer,
|
||||||
onTap: onStop,
|
onTap: onStop,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -438,6 +464,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
foreground: colors.onTertiaryContainer,
|
foreground: colors.onTertiaryContainer,
|
||||||
onTap: onStart,
|
onTap: onStart,
|
||||||
trailingBorder: true,
|
trailingBorder: true,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -448,6 +475,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
foreground: colors.onErrorContainer,
|
foreground: colors.onErrorContainer,
|
||||||
onTap: onStop,
|
onTap: onStop,
|
||||||
trailingBorder: true,
|
trailingBorder: true,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -457,6 +485,7 @@ class _ControlBar extends StatelessWidget {
|
|||||||
background: colors.surfaceContainerHighest,
|
background: colors.surfaceContainerHighest,
|
||||||
foreground: colors.error,
|
foreground: colors.error,
|
||||||
onTap: onDiscard,
|
onTap: onDiscard,
|
||||||
|
mountedMode: mountedMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -473,6 +502,7 @@ class _Segment extends StatelessWidget {
|
|||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.label,
|
this.label,
|
||||||
this.trailingBorder = false,
|
this.trailingBorder = false,
|
||||||
|
this.mountedMode = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String keyName;
|
final String keyName;
|
||||||
@@ -482,6 +512,9 @@ class _Segment extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final String? label;
|
final String? label;
|
||||||
final bool trailingBorder;
|
final bool trailingBorder;
|
||||||
|
final bool mountedMode;
|
||||||
|
|
||||||
|
double get _iconSize => mountedMode ? 32 : 24;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Material(
|
Widget build(BuildContext context) => Material(
|
||||||
@@ -502,18 +535,18 @@ class _Segment extends StatelessWidget {
|
|||||||
child: SizedBox.expand(
|
child: SizedBox.expand(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: label == null
|
child: label == null
|
||||||
? Icon(icon, color: foreground, size: 32)
|
? Icon(icon, color: foreground, size: _iconSize)
|
||||||
: Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: foreground),
|
Icon(icon, color: foreground, size: _iconSize),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
label!,
|
label!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: foreground,
|
color: foreground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: mountedMode ? 16 : 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
|
|||||||
final units = ref.watch(unitSystemProvider);
|
final units = ref.watch(unitSystemProvider);
|
||||||
final repo = ref.read(routePlanRepositoryProvider);
|
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
|
// 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
|
// loading" apart from "genuinely doesn't exist" -- both collapse to null. On a
|
||||||
// brand-new route (fresh navigation right after `repo.createRoutePlan`), this
|
// brand-new route (fresh navigation right after `repo.createRoutePlan`), this
|
||||||
@@ -168,7 +178,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
|
|||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCameraFit: _initialFit(waypoints),
|
initialCameraFit: _initialFit(waypoints),
|
||||||
initialCenter: waypoints.isEmpty
|
initialCenter: waypoints.isEmpty
|
||||||
? const ll.LatLng(0, 0)
|
? (ambientPosition ?? const ll.LatLng(0, 0))
|
||||||
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
|
||||||
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
|
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
|
||||||
maxZoom: maxTileZoom,
|
maxZoom: maxTileZoom,
|
||||||
|
|||||||
@@ -10,19 +10,32 @@ import 'package:rippr/src/ui/components/draggable_resizable_hud_widget.dart';
|
|||||||
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
|
import 'package:rippr/src/ui/components/hud_edit_overlay.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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
|
/// shape as `record_screen.dart`'s private `_HudMetricValue`, which can't be
|
||||||
/// referenced directly from outside its library.
|
/// referenced directly from outside its library. FB-09: the title is a plain,
|
||||||
Widget _representativeHudChild() => const FittedBox(
|
/// unwrapped `Text` at a fixed reference size; only the value is wrapped in its own
|
||||||
fit: BoxFit.scaleDown,
|
/// `Flexible(child: FittedBox(...))`.
|
||||||
child: Column(
|
Widget _representativeHudChild({String label = 'SPEED', String value = '12.3 km/h'}) =>
|
||||||
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('SPEED', style: TextStyle(fontSize: 10, letterSpacing: 1)),
|
Text(
|
||||||
SizedBox(height: 4),
|
label,
|
||||||
Text('12.3 km/h', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
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`
|
/// UI-04: drag/toggle behaviour of the customizable HUD, simulated via `TestGesture`
|
||||||
@@ -263,4 +276,143 @@ void main() {
|
|||||||
|
|
||||||
expect(tester.takeException(), isNull);
|
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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
test('persist writes the current state to Config', () async {
|
||||||
final config = await freshConfig();
|
final config = await freshConfig();
|
||||||
final controller = HudLayoutController(config);
|
final controller = HudLayoutController(config);
|
||||||
|
|||||||
@@ -89,6 +89,42 @@ void main() {
|
|||||||
expect(layout.clampedToGrid().rowSpan, layout.rowSpan);
|
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', () {
|
group('clampedToGrid', () {
|
||||||
|
|||||||
@@ -367,4 +367,265 @@ void main() {
|
|||||||
expect(find.byKey(const Key('location-marker')), findsNothing);
|
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(
|
||||||
|
'a real drag beats an ambient tick that lands mid-drag (FB-10)',
|
||||||
|
(tester) async {
|
||||||
|
const fix1 = ll.LatLng(51.0, -114.0);
|
||||||
|
// The ambient tick that lands while the finger is down but before
|
||||||
|
// flutter_map's own gesture recognizer has reported `hasGesture: true` --
|
||||||
|
// this is the exact race the ticket describes.
|
||||||
|
const fix2 = ll.LatLng(51.5, -114.5);
|
||||||
|
|
||||||
|
Widget build(ll.LatLng ambient) => MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: RideMap(
|
||||||
|
points: const [],
|
||||||
|
segments: const [],
|
||||||
|
showEmptyLabel: false,
|
||||||
|
follow: true,
|
||||||
|
ambientPosition: ambient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(build(fix1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Put a real pointer down on the map -- this is what a rider's finger
|
||||||
|
// touching the screen looks like, well before flutter_map decides the
|
||||||
|
// movement counts as a drag.
|
||||||
|
final gesture =
|
||||||
|
await tester.startGesture(tester.getCenter(find.byType(FlutterMap)));
|
||||||
|
addTearDown(() => gesture.removePointer());
|
||||||
|
|
||||||
|
// Simulate a once-per-second ambient GPS tick landing while the pointer
|
||||||
|
// is already down but before it has moved -- exactly the race window
|
||||||
|
// the ticket describes: flutter_map has not yet reported `hasGesture:
|
||||||
|
// true`, so nothing has flipped `_following` off yet.
|
||||||
|
await tester.pumpWidget(build(fix2));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// The critical assertion: with the pointer still down and untouched by
|
||||||
|
// any real drag, the camera must not have snapped to the ambient tick's
|
||||||
|
// position. Without the fix, the race lets the scheduled
|
||||||
|
// `postFrameCallback` win here and the camera jumps to `fix2` before
|
||||||
|
// the rider's finger has moved at all.
|
||||||
|
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
var center = map.mapController!.camera.center;
|
||||||
|
expect(
|
||||||
|
center.latitude,
|
||||||
|
isNot(closeTo(fix2.latitude, 1e-6)),
|
||||||
|
reason: 'the mid-drag ambient tick must not win the race and snap the '
|
||||||
|
'camera back to the ambient position while the gesture is still '
|
||||||
|
'down',
|
||||||
|
);
|
||||||
|
expect(center.longitude, isNot(closeTo(fix2.longitude, 1e-6)));
|
||||||
|
|
||||||
|
// Now the finger actually moves and lifts -- the real drag completes
|
||||||
|
// normally, and the final position reflects it, not the ambient tick.
|
||||||
|
await gesture.moveBy(const Offset(-100, -100));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
center = map.mapController!.camera.center;
|
||||||
|
expect(
|
||||||
|
center.latitude,
|
||||||
|
isNot(closeTo(fix2.latitude, 1e-6)),
|
||||||
|
reason: 'the completed drag must still reflect the rider\'s own pan, '
|
||||||
|
'not the ambient position the mid-drag tick tried to recenter to',
|
||||||
|
);
|
||||||
|
expect(center.longitude, isNot(closeTo(fix2.longitude, 1e-6)));
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:rippr/src/app/providers.dart';
|
import 'package:rippr/src/app/providers.dart';
|
||||||
import 'package:rippr/src/data/database.dart';
|
import 'package:rippr/src/data/database.dart';
|
||||||
import 'package:rippr/src/data/route_plan_repository.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/app_shell.dart';
|
||||||
import 'package:rippr/src/ui/components/floating_pill.dart';
|
import 'package:rippr/src/ui/components/floating_pill.dart';
|
||||||
import 'package:rippr/src/ui/components/ride_map.dart' show ambientZoom;
|
import 'package:rippr/src/ui/components/ride_map.dart' show ambientZoom;
|
||||||
@@ -30,8 +31,8 @@ void main() {
|
|||||||
|
|
||||||
tearDown(() async => db.close());
|
tearDown(() async => db.close());
|
||||||
|
|
||||||
Widget host(Widget child) => ProviderScope(
|
Widget host(Widget child, {List<Override> overrides = const []}) => ProviderScope(
|
||||||
overrides: [databaseProvider.overrideWithValue(db)],
|
overrides: [databaseProvider.overrideWithValue(db), ...overrides],
|
||||||
child: MaterialApp(theme: ripprTheme(), home: child),
|
child: MaterialApp(theme: ripprTheme(), home: child),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,6 +228,79 @@ void main() {
|
|||||||
expect(map.options.initialZoom, ambientZoom);
|
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 '
|
screenTest('the offline-tiles download menu item is disabled with no pins '
|
||||||
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
|
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -90,6 +90,45 @@ void main() {
|
|||||||
expect(await reopened.sizeBytes(), 64);
|
expect(await reopened.sizeBytes(), 64);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('overlapping put() calls for distinct keys are both readable afterward '
|
||||||
|
'(FB-11: concurrent background-map + Route Planner tile fetches must not race)',
|
||||||
|
() async {
|
||||||
|
// `_saveManifest()` computes its JSON snapshot synchronously, then writes it to
|
||||||
|
// disk. Two overlapping `put()` calls can interleave so that the call that
|
||||||
|
// captured the *older*, smaller snapshot (fewer entries) is also the one whose
|
||||||
|
// disk write finishes last -- silently overwriting the newer, complete manifest
|
||||||
|
// with a stale one that is missing the other call's tile. On a real device this
|
||||||
|
// depends on incidental I/O timing (which is exactly why it was so hard to catch
|
||||||
|
// and produced a rider-visible blank map only sometimes); this artificial delay
|
||||||
|
// makes that interleaving happen every single time instead of by chance, so the
|
||||||
|
// test is deterministic rather than flaky. It has no effect on production
|
||||||
|
// callers, which never pass it.
|
||||||
|
cache = FileTileCache(
|
||||||
|
directory: tempDir,
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
debugArtificialManifestWriteDelay: (entryCount) =>
|
||||||
|
entryCount < 2 ? const Duration(milliseconds: 50) : Duration.zero,
|
||||||
|
);
|
||||||
|
const a = TileKey(9, 1, 0);
|
||||||
|
const b = TileKey(9, 2, 0);
|
||||||
|
|
||||||
|
// Started without awaiting the first before starting the second, so both calls
|
||||||
|
// are in flight and racing across the same `await` points (`_ensureLoaded`,
|
||||||
|
// `writeAsBytes`, `_saveManifest`) at once.
|
||||||
|
final futureA = cache.put(a, bytesOfSize(1024));
|
||||||
|
final futureB = cache.put(b, bytesOfSize(1024));
|
||||||
|
await Future.wait([futureA, futureB]);
|
||||||
|
|
||||||
|
// Reopen over the same directory: this reads the manifest back from disk, which
|
||||||
|
// is exactly the file the two overlapping writes above raced to overwrite. An
|
||||||
|
// in-memory-only check wouldn't catch this -- the shared `_manifest` map itself
|
||||||
|
// is never corrupted (Dart is single-threaded), only what ends up on disk.
|
||||||
|
final reopened = FileTileCache(directory: tempDir, maxBytes: 1024 * 1024);
|
||||||
|
expect(await reopened.get(a), isNotNull, reason: 'tile a must survive the race');
|
||||||
|
expect(await reopened.get(b), isNotNull, reason: 'tile b must survive the race');
|
||||||
|
expect(await reopened.sizeBytes(), 2048);
|
||||||
|
});
|
||||||
|
|
||||||
test('a tile cached under one provider directory is not served from another '
|
test('a tile cached under one provider directory is not served from another '
|
||||||
'(UI-09)', () async {
|
'(UI-09)', () async {
|
||||||
// `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan
|
// `TileKey` carries no provider identity -- (z, x, y) alone can't tell an OSM tan
|
||||||
|
|||||||
Reference in New Issue
Block a user