FB-01: ambient GPS position drives the shared background map while idle

Adds ambientPositionProvider (never calls LocationSource.stop(), only the
idempotent start()) and wires it into ShellScaffold whenever no trip is
active, so the persistent background map centers and follows the device's
live GPS position at street-level zoom instead of sitting at (0,0)/zoom 2
until a recording starts. RideMap gains an ambientPosition param that the
existing chase-camera/pan-cancel/location-marker logic falls back to
whenever there are no recorded points, with recorded points always taking
priority. Also brings in the docs/feedback ticket set (FB-01..FB-05, README,
FEEDBACK.md) that this worktree's branch point predated.

Adds 10 tests (374 -> 384): RideMap-level ambient centering/chase/pan-cancel/
marker coverage in test/ride_map_test.dart, plus shell-wiring and
provider-level (never-calls-stop, mapEnabledProvider-off) coverage in
test/widget_test.dart. flutter analyze remains clean.
This commit is contained in:
2026-08-24 15:58:45 -05:00
parent 04127d7237
commit 756e3a9f53
12 changed files with 2234 additions and 15 deletions

33
docs/FEEDBACK.md Normal file
View File

@@ -0,0 +1,33 @@
## All pages
- The map is too zoomed out.
- Start by having the map zoomed in enough where you could easily see what street the user is on and the streets around it.
- This goes for all screens that use the map, and even if the map is a background it should be updating real time to the person moving so if they aren't recording but they are riding in a car, it will update just like google maps.
- Essentially we want something that looks identical to google maps or apple maps.
## Map Page
- Remove the "Speed" widget from the map screen when "start" hasn't been pressed yet.
- It blocks the entire screen, and you can't see the map at all
- Speed and other widgets should only appear when recording starts.
- When toggling off different statistics, the flow and arrangement of the widgets goes crazy.
- This should be "drag and drop" but they stick to a grid much like the home screen on android, and they can be resizable like android widgets too.
- Make sure the font and values are centered in the widgets as well, and scale to the size of the widget (no over or underflow)
## History pages
- This looks great, only include updates if they overlap with other demands on other screens.
## Route
This doesn't work at all, the map doesn't render at all, and there is no way to create a loop with the pins, it only creates a unidirectional path.
I want it to be the same zoomed in map view, then the user can zoom out and create the route.
They should also be able to save the routes, and be able to view the saved and named tabs, and be able to start them whenever they want.
The map looks great when the zoom is correct through

View File

@@ -0,0 +1,383 @@
# FB-01 — Live, street-level map everywhere
**Depends on** — · **Size** L · **Status** Done
## Goal
The map is too zoomed out on every screen, and the shared background map has zero
location awareness while idle — it should behave like Google/Apple Maps: zoomed in
enough to see the street you're on and the streets around it, and continuously tracking
the device's real position in real time, whether or not a ride is being recorded.
## Context
Direct user feedback (`docs/FEEDBACK.md`, "All pages" section):
> The map is too zoomed out. Start by having the map zoomed in enough where you could
> easily see what street the user is on and the streets around it. This goes for all
> screens that use the map, and even if the map is a background it should be updating
> real time to the person moving so if they aren't recording but they are riding in a
> car, it will update just like google maps. Essentially we want something that looks
> identical to google maps or apple maps.
Today's behavior, exactly:
`lib/src/ui/components/ride_map.dart`, `_RideMapState.build()` (~lines 200-237):
```dart
geo.Bounds? bounds;
if (hasPoints) {
bounds = geo.bounds([
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
]);
}
...
options: MapOptions(
initialCameraFit: (bounds == null || bounds.isDegenerate)
? null
: CameraFit.bounds(
bounds: LatLngBounds(...),
padding: const EdgeInsets.all(24),
maxZoom: maxTileZoom,
),
initialCenter: bounds == null
? const ll.LatLng(0, 0)
: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds == null
? 2
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom,
...
```
`bounds == null` happens whenever `widget.points` is empty — which is exactly the state
of the shared background map (`ShellScaffold` in `lib/src/ui/app_shell.dart`) any time
no trip is actively recording. In that state the map centers on **`(0, 0)` (Null
Island) at zoom 2** — the literal opposite of "zoomed in enough to see your street."
The shared background map's data source, `ShellScaffold.build()` (`app_shell.dart`
~lines 59-72):
```dart
final trip = ref.watch(activeTripProvider).valueOrNull;
final points = trip == null
? const <TrackPoint>[]
: ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const <TrackPoint>[];
final segments = trip == null
? const <Segment>[]
: ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const <Segment>[];
```
`livePointsProvider`/`liveSegmentsProvider` are DB-backed streams of a trip's *stored*
points — they only ever produce data while `RecordingEngine` is actively writing to a
trip. There is no code path anywhere that feeds the background map a raw GPS position
independent of an active recording — confirmed by grep: `locationSourceProvider`
(`lib/src/app/providers.dart` ~line 54) is only ever watched by `recordingEngineProvider`
(~lines 79-89). **The background map is blind while idle**, which is the root cause of
both complaints at once (wrong zoom AND no live tracking) — there's simply no location
signal reaching it until a trip starts.
`RideMap` already has exactly the chase-camera logic this needs, just scoped to
recorded points — `_RideMapState` (`ride_map.dart` ~lines 113-147):
```dart
class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
final _controller = MapController();
late bool _following = widget.follow;
bool _backgrounded = false;
@override
void didUpdateWidget(RideMap old) {
super.didUpdateWidget(old);
if (!_following || widget.points.isEmpty || _backgrounded) return;
final last = widget.points.last;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_following) return;
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
});
}
...
```
And the `onPositionChanged` cancel-on-manual-pan guard (`ride_map.dart` ~lines 243-252):
```dart
onPositionChanged: !widget.follow
? null
: (position, hasGesture) {
if (hasGesture && _following) {
setState(() => _following = false);
}
},
```
This is the exact "chase the rider, but a real pan/pinch cancels it" behavior the
feedback is asking for — it just needs a second data source (ambient GPS) wired in for
when there are no recorded points to chase.
**The shared `LocationSource` instance is process-wide — do not call `stop()` on it
from ambient code.** `lib/src/app/providers.dart`:
```dart
final locationSourceProvider = Provider<LocationSource>((ref) {
final source = GeolocatorLocationSource();
ref.onDispose(source.dispose);
return source;
});
```
One `GeolocatorLocationSource` for the whole app. Its `start()` is idempotent (safe to
call from two places — `lib/src/recording/geolocator_location_source.dart`:
`if (_subscription != null) return; // idempotent`), but its `stop()` is **not**
reference-counted — it unconditionally cancels the one underlying platform subscription:
```dart
@override
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
```
If ambient-mode code ever calls `locationSource.stop()` (e.g. from a provider's
`ref.onDispose`, or when the Map tab becomes invisible), and a real recording happens to
be in progress at that moment, **it would silently kill GPS delivery to the active
recording** — the engine has no way to know its location source was just stopped out
from under it by an unrelated consumer. This must not be possible. See Design below.
## Design
- **New constant** in `lib/src/ui/components/ride_map.dart`, near `maxTileZoom`/
`shortRideZoom`: `const double ambientZoom = 17.0;` — a distinct name from
`shortRideZoom` even though the value happens to match, since they mean different
things (one is "a very short recorded ride," the other is "no ride at all, just
ambient GPS").
- **New provider**, in `lib/src/app/providers.dart`:
```dart
/// The device's current position when nothing is being recorded — drives the shared
/// background map's "look like Google Maps while idle" behavior. Deliberately NOT
/// gated through `recordingEngineProvider`/`RecordingEngine.start()` — this must work
/// whether or not a ride is ever recorded. Never calls `LocationSource.stop()`: the
/// underlying `locationSourceProvider` instance is shared with the recording engine,
/// and `stop()` is not reference-counted (see FB-01's ticket for why).
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);
// No `stop()` call, ever, on dispose — see the doc comment above. Only this
// provider's own subscription to the broadcast `fixes` stream ends; the shared
// platform subscription is left exactly as it was.
});
```
`LocationFix` and `LocationException` are both already imported/available via
`lib/src/recording/location_source.dart` (already imported in `providers.dart`).
`autoDispose` is correct here — this should stop listening the moment nothing watches
it (e.g. the app backgrounded, or a trip starts and `ShellScaffold` stops watching
this provider — see next bullet), same lifecycle discipline as every other
`StreamProvider.autoDispose` in this file.
- **`ShellScaffold.build()`** (`lib/src/ui/app_shell.dart`): only watch
`ambientPositionProvider` while idle, so it's never even subscribed during an active
recording:
```dart
final trip = ref.watch(activeTripProvider).valueOrNull;
final ambientFix = trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null;
final ambientPosition = ambientFix == null
? null
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
```
(needs `import 'package:latlong2/latlong.dart' as ll;`) then pass `ambientPosition:
ambientPosition` into the `RideMap(...)` constructor call alongside the existing
`points`/`segments`/`follow`/etc. Leave `follow: isMapTab` and `showLocationMarker:
isMapTab` exactly as they are — ambient following should only run on the visible Map
tab, matching the existing recording-follow rationale already in that file's comments.
- **`RideMap`** (`lib/src/ui/components/ride_map.dart`):
- Add `final ll.LatLng? ambientPosition;` to the widget's fields (with a doc comment
explaining it's only meaningful when `points` is empty — a recorded path always
takes priority) and thread it through the constructor.
- In `build()`, change the `bounds == null` branch to use it:
```dart
initialCenter: bounds == null
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds == null
? (widget.ambientPosition == null ? 2 : ambientZoom)
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
```
(`initialCenter`/`initialZoom` are read once at `FlutterMap` construction by
flutter_map — this only fixes the *first* placement; live tracking needs the
`didUpdateWidget` change below.)
- In `didUpdateWidget`, extend the chase logic to fall back to ambient position when
there are no recorded points:
```dart
@override
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) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_following) return;
_controller.move(widget.ambientPosition!, _controller.camera.zoom);
});
}
}
```
- `showLocationMarker`: today it only ever reads `widget.points.last`
(`MarkerLayer`/`PulsingLocationMarker` at ~lines 275-289). Add a fallback so the
pulsing marker also shows on ambient position:
```dart
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
MarkerLayer(
markers: [
Marker(
key: const Key('location-marker'),
point: hasPoints
? ll.LatLng(widget.points.last.latitude, widget.points.last.longitude)
: widget.ambientPosition!,
width: 40,
height: 40,
child: const PulsingLocationMarker(),
),
],
),
```
- **Permission prompt timing**: `LocationSource.start()` requests permission if not
already granted (`_ensurePermission` in `geolocator_location_source.dart`). This means
the very first time a user opens the app (or opens the Map tab) they may see a real OS
location-permission dialog before ever pressing Start — this is intentional per the
feedback ("even if they aren't recording... it will update just like Google Maps") and
matches how a real maps app behaves. A denial must degrade gracefully: catch
`LocationException` and yield `null` (already shown above) so the map simply falls
back to today's `(0,0)`/zoom-2 behavior rather than crashing or showing an error.
## Implementation
1. Add `ambientZoom` constant to `ride_map.dart`.
2. Add `ambientPosition` field + constructor param to `RideMap`.
3. Update the `bounds == null` branch of `initialCenter`/`initialZoom`.
4. Update `didUpdateWidget` to chase `ambientPosition` when there are no recorded
points.
5. Update the `showLocationMarker` `MarkerLayer` to fall back to `ambientPosition`.
6. Add `ambientPositionProvider` to `lib/src/app/providers.dart`.
7. Wire it into `ShellScaffold.build()` in `app_shell.dart`, gated on `trip == null`.
## Acceptance criteria
- [ ] With no active trip and no ambient fix yet available, the background map still
falls back to today's `(0,0)`/zoom-2 (no regression / no crash on first frame
before permission resolves).
- [ ] Once an ambient fix arrives (simulated via `FakeLocationSource.emit` in tests),
the background map centers on it at `ambientZoom` (street level) and continues to
re-center as new fixes arrive, exactly like the existing recorded-path chase
behavior.
- [ ] A manual pan/pinch on the Map tab cancels ambient following the same way it
cancels recording-follow today (reuses `_following`/`onPositionChanged` unchanged).
- [ ] The moment a trip starts recording, the background map switches to following the
trip's own recorded points (unchanged priority — `hasPoints` already wins in
`didUpdateWidget`), and `ShellScaffold` stops watching `ambientPositionProvider`
entirely (`trip == null` gate) so there's no duplicate GPS consumer during a ride.
- [ ] Turning `mapEnabledProvider` off stops the ambient GPS subscription (via the
provider's own `ref.watch(mapEnabledProvider)` short-circuit) — no tile or
location request fires while the map is disabled, matching the existing map-toggle
guarantee.
- [ ] `LocationSource.stop()` is never called by any code this ticket adds — grep the
diff to confirm.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
- Widget test: `ShellScaffold` with `activeTripProvider` returning `null` and
`ambientPositionProvider` overridden/fed a fake `LocationFix` — assert the background
`RideMap`'s effective camera center/zoom reflects the ambient fix (read via
`MapController.camera` or by asserting the constructor args passed to `RideMap`,
whichever is more direct given the existing shell test patterns in
`test/widget_test.dart`'s `shell nav bar` group).
- Widget test: manually panning the map while an ambient fix is active stops further
auto-recentering on subsequent fixes (mirrors any existing recording-follow-cancel
test, if one exists — check `test/widget_test.dart`/`test/ride_map_test.dart`).
- Widget/provider test: `ambientPositionProvider` never calls `LocationSource.stop()` —
drive a `FakeLocationSource`, dispose the provider (e.g. via `container.dispose()` in
a `ProviderContainer`-based test), and assert `fakeSource.stopCalls == 0`.
- Widget test: `activeTripProvider` returning a non-null trip means the shell never
reads from `ambientPositionProvider` (e.g. assert no permission/`start()` call
happens when a trip is active and the fake source's `startCalls` was already
incremented by the recording path only).
- Existing `RideMap`/shell background tests continue to pass unmodified except where
they need a new `ambientPosition: null` default (should be a no-op given it's an
optional/nullable constructor param).
## Risks
- **Battery**: `ambientPositionProvider` calling `start()` means GPS may run continuously
any time the Map tab (or the app in general, given the shell map is always mounted)
is open and the map is enabled, even with no ride ever recorded. This ticket
deliberately does not add a "stop after N minutes idle" or reference-counted shutdown
— that's a real product decision better made with actual battery data, not guessed at
here. Flag it in the Outcome section rather than solving it silently.
- **Permission timing**: as noted above, this may surface a permission dialog earlier
in the app's lifecycle than before (first Map-tab view rather than first Start press).
Confirmed intentional per the feedback; call out if it feels wrong in practice.
## Out of scope
Route Planner's own map (it manages its own `FlutterMap` directly, not through
`RideMap`) — covered by FB-04, which should reuse `ambientZoom` from this ticket for
zoom-consistency but does not need ambient location following (Route Planner already
centers on the route's own waypoints, which is correct). HUD widget changes (FB-03).
The idle Speed panel (FB-02) — unrelated, but note FB-02's idle screen will now show a
genuinely live, moving map underneath once this ticket lands, which is the whole point.
## Outcome
Implemented exactly as designed, with no deviations from the Design section:
- `ambientZoom` constant added to `ride_map.dart`.
- `RideMap.ambientPosition` field/constructor param added; `bounds == null` branch of
`initialCenter`/`initialZoom`, the `didUpdateWidget` chase logic, and the
`showLocationMarker` `MarkerLayer` all updated exactly per the ticket's diffs.
- `ambientPositionProvider` added to `providers.dart`, verbatim from the ticket's own
code block — never calls `LocationSource.stop()`, only `start()` (idempotent) and its
own subscription's implicit cancellation of the broadcast `fixes` stream on
`autoDispose`.
- `ShellScaffold.build()` wired to watch `ambientPositionProvider` only while
`activeTripProvider` is null, passing the resulting `ll.LatLng?` into `RideMap` as
`ambientPosition`, alongside the unchanged `follow: isMapTab` /
`showLocationMarker: isMapTab`.
Confirmed via `grep -n "\.stop()" $(git diff --name-only -- lib)` that no code this
ticket added calls `LocationSource.stop()` anywhere.
One nuance surfaced by testing, not a deviation from the design but worth recording:
`activeTripProvider` is a `StreamProvider`, so on the very first frame after the shell
mounts its `valueOrNull` is `null` (still loading) even when a trip is already active in
the database — this is pre-existing behavior identical to how `points`/`segments`
already fell back to empty lists before their own streams' first emission. In practice
this means `ambientPositionProvider` may be watched, and `LocationSource.start()` called,
for one transient frame during cold start even if a recording turns out to already be
active. It self-corrects the instant the trip stream emits (the shell stops watching
`ambientPositionProvider` from then on), and `start()` is idempotent regardless, so this
has no observable effect on recording — but it means the "no duplicate GPS consumer"
guarantee is a steady-state guarantee, not literally true for the first frame. Tests were
written to reflect this (see `test/widget_test.dart`'s "once a trip starts recording, the
shell stops watching ambient GPS" test, which starts idle, lets the ambient provider
engage, then starts a trip and asserts no further `start()` calls — rather than asserting
zero calls from t=0).
Both risks flagged in the ticket (continuous background GPS while idle with the Map tab
mounted, and an earlier permission-dialog timing) were left unresolved as instructed —
no reference-counted shutdown or idle timeout was added.
**Tests**: `flutter analyze` clean (same 4 pre-existing infos as baseline, no new
issues). `flutter test` green, 384 passing (up from 374 baseline; +10 new tests: 6 in
`test/ride_map_test.dart`'s new "ambient position (FB-01)" group covering
`RideMap`-level centering/chase/pan-cancel/marker-fallback behavior, and 4 in
`test/widget_test.dart` — two widget-level (`ShellScaffold` following an ambient fix
with no trip active; ambient watching stopping once a trip starts) and two
provider-level, driven directly against a `ProviderContainer` (never calls `stop()`;
`mapEnabledProvider` off never starts location and yields null)).

View File

@@ -0,0 +1,193 @@
# FB-02 — Hide the idle Speed panel until recording starts
**Depends on** — · **Size** S · **Status** Not started
## Goal
The Record screen's idle (not-recording) state currently shows a live "SPEED" readout
plus status text before the rider has pressed Start at all. It should show nothing but
the (live, per FB-01) map and the START control until a ride actually begins.
## Context
Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section):
> Remove the "Speed" widget from the map screen when "start" hasn't been pressed yet. It
> blocks the entire screen, and you can't see the map at all. Speed and other widgets
> should only appear when recording starts.
Today's idle branch — `lib/src/ui/record/record_screen.dart`, inside `build()`'s
`Stack` (~lines 196-223):
```dart
child: Stack(
children: [
if (ui.isIdle)
Center(
child: GlassPanel(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Current speed, not max: a max figure only moves when you beat
// it, which reads as a frozen screen while riding steadily.
// This is the 2.0.1 fix and it must not regress.
BigStat(
key: const Key('speed'),
label: 'SPEED',
value: speedValue,
unit: speedUnit,
scale: mountedMode ? mountedTextScale : 1.0,
),
const SizedBox(height: 16),
// A resting state, not a zeroed ride -- otherwise the screen
// looks like a recording that is going nowhere.
const StatRow(label: 'Status', value: 'Ready'),
const StatRow(label: 'Last ride', value: 'See Rides'),
],
),
),
)
else
Positioned.fill(
bottom: _controlBarHeight(mountedMode),
child: HudEditOverlay(
metricBuilder: (context, metric) =>
_HudMetricValue(metric: metric, ui: ui, units: units),
),
),
...
```
`ui.isIdle` is `trip == null` (`RecordUiState.isIdle`, ~line 42). The `GlassPanel` sizes
to its content (`mainAxisSize: MainAxisSize.min`) so it isn't literally full-screen, but
it's centered, opaque-ish (`GlassPanel` is a near-opaque translucent fill per its own
doc comment elsewhere in this codebase), and shows a real live speed figure
(`_liveSpeed`, updated every GPS tick via `liveTelemetryProvider` — see `initState`
~lines 86-88) before any ride exists — which is exactly what the feedback is describing
as "blocking the screen" and confusing to see before pressing Start.
## Design
Remove the entire `if (ui.isIdle) ... else ...` branching for HUD content — always
render the `HudEditOverlay` branch, and have the idle state simply show nothing there
(the HUD overlay area is empty when there's no trip, since there's nothing to edit/no
metrics to place — confirm `HudEditOverlay` degrades gracefully with an idle
`RecordUiState`, or gate it explicitly: only mount `HudEditOverlay` when `!ui.isIdle`,
and render an empty `SizedBox.shrink()` (or nothing at all) for the idle case, so the
full-bleed live map (per FB-01) is the entire idle screen behind the START control bar.
Concretely:
```dart
child: Stack(
children: [
if (!ui.isIdle)
Positioned.fill(
bottom: _controlBarHeight(mountedMode),
child: HudEditOverlay(
metricBuilder: (context, metric) =>
_HudMetricValue(metric: metric, ui: ui, units: units),
),
),
if (ui.isPaused)
...
```
(i.e. drop the idle `Center(child: GlassPanel(...))` block entirely — no replacement
content, not even a "Ready" label. The now-live background map, per FB-01, is the idle
screen's entire content above the control bar.)
`speedValue`/`speedUnit` (`formatSpeedParts(_liveSpeed, unit: units)`, ~line 176) become
unused once the idle panel is removed — check whether `_HudMetricValue`'s own
`HudMetric.speed` case (`formatSpeedParts(ui.currentSpeedKmh, unit: units).$1` at
~line 310) already computes this independently (it does — `ui.currentSpeedKmh` is
`_liveSpeed`), so the `final (speedValue, speedUnit) = ...` line in `build()` should
simply be deleted rather than left as dead code. Run `flutter analyze` to confirm no
other lingering unused-variable warnings from this removal.
`BigStat`/`StatRow` imports in `record_screen.dart` (from `../components/stats.dart`)
may become unused if nothing else in this file still uses them — check before removing
the import; `confirmDialog` (also from `stats.dart`) is used elsewhere in this file for
the discard confirmation, so the import itself likely stays, just drop `BigStat`/
`StatRow` specifically if nothing else references them.
## Implementation
1. Remove the idle `Center(child: GlassPanel(...))` block from `record_screen.dart`'s
`build()`.
2. Gate `HudEditOverlay` on `!ui.isIdle` (it was previously the `else` branch of the
idle check — now it's the only branch, wrapped in its own `if`).
3. Delete the now-unused `final (speedValue, speedUnit) = formatSpeedParts(...)` line.
4. Run `flutter analyze` and remove any import that's now unused as a result.
## Acceptance criteria
- [ ] Idle state (no active trip) shows no Speed/Status/"Last ride" content at all —
just the live background map and the control bar with only "START RECORDING"
visible (unchanged from today).
- [ ] The moment a trip starts recording, the full HUD (`HudEditOverlay` with the
default-visible metrics: Speed, Avg Speed, Distance, Elapsed Time) appears exactly
as it does today — this ticket only changes the idle state, not recording/paused.
- [ ] `flutter analyze` clean (no unused-variable/import warnings from the removal).
- [ ] `flutter test` green, test count only goes up (net effect after the test
migrations below should still be positive or flat, never negative).
## Tests
Several existing tests assert against the idle Speed panel specifically and must be
migrated — not deleted — to seed an active recording and assert against the HUD's own
`Speed` widget instead, since the underlying regressions they guard (black-on-black
text, mounted-mode contrast/scale) are real risks that still apply once the same figure
lives inside `_HudMetricValue` instead of the idle `BigStat`. The HUD's per-metric
widgets are keyed `ValueKey(entry.key)` where `entry.key` is the `HudMetric`
(`lib/src/ui/components/hud_edit_overlay.dart` ~line 63) — use
`find.byKey(const ValueKey(HudMetric.speed))` to locate the speed widget in a recording
state, then descend into it the same way the old tests descended into `Key('speed')`.
In `test/widget_test.dart`:
- **`'idle shows Ready and only START'`** (~line 126): remove the
`expect(find.text('Ready'), findsOneWidget);` assertion (the "Ready"/"Status" text no
longer exists at all); keep the START/PAUSE/STOP key assertions and the
`find.text('Distance')` findsNothing check — both remain valid (this test's title may
want updating to `'idle shows only START'`).
- **`'the headline is live speed, not max speed'`** (~line 138): change to seed a
recording trip first (`await repo.startTrip(1000); await pumpLive(tester,
host(const RecordScreen(), map: false));` — see the identical pattern already used at
~line 205-206 in `'tapping PAUSE and then STOP...'`), then assert
`find.text('SPEED')` findsOneWidget / `find.text('MAX SPEED')` findsNothing exactly as
before (Max Speed is not one of the four default-visible metrics —
`HudWidgetLayout.defaultFor`'s `visible: index < columns` with `columns = 4` and
`HudMetric.values` ordered `speed, avgSpeed, distance, elapsedTime, maxSpeed, ...` —
so this assertion still holds true in the recording state).
- **`'text is legible against the dark ground'`** (~line 235): seed a recording trip the
same way, then replace
`find.descendant(of: find.byKey(const Key('speed')), matching: find.text('0'))` with
`find.descendant(of: find.byKey(const ValueKey(HudMetric.speed)), matching:
find.text('0'))`. Same downstream assertions (`colour != ripprBackground`,
`colour != Colors.black`) unchanged.
- **`'the mounted theme is high-contrast and text scales up (V3-05)'`** (~line 380) and
**`'the mounted-mode speed digit is legible against GlassPanel's own translucent
surface...'`** (~line 400): both currently pump `RecordScreen()` idle with
`mountedMode: true` and inspect `Key('speed')`. Migrate both to seed a recording trip
first, then use `ValueKey(HudMetric.speed)` the same way. Confirm `_HudMetricValue`'s
value `Text` actually honors `mountedMode`'s scale/theme the same way the old idle
`BigStat` did (check `_HudMetricValue.build()` — if it currently has no mounted-mode
awareness at all, that's a **pre-existing gap this migration would newly expose**, not
something to paper over: if the mounted-mode font-size/contrast assertions fail
against the HUD widget, that's a real, separate bug worth calling out plainly in this
ticket's Outcome section rather than weakening the assertion to make it pass.
- **`'HUD widgets render over the map, not replacing it (UI-05)'`** (~line 219) already
seeds a recording trip and asserts `find.text('SPEED')`/`find.text('DISTANCE')` — no
change needed, but worth a quick sanity check that it still passes unmodified.
## Risks
- The mounted-mode migration above may surface that `_HudMetricValue` never actually
had mounted-mode-aware styling (it was only ever exercised via the idle `BigStat`
before now) — if so, this ticket's Outcome section must say so explicitly rather than
silently deleting or weakening the assertion. Fixing that gap (if real) is reasonable
to fold into this ticket since it's directly caused by this change, but keep the fix
minimal (reuse whatever scale/theme mechanism `BigStat` already used) rather than
redesigning `_HudMetricValue`.
## Out of scope
The HUD grid/drag-resize/text-auto-fit rework (FB-03) — this ticket only removes idle
content, it does not touch how the recording-state HUD widgets are laid out or sized.
The live-map-while-idle behavior itself (FB-01) — this ticket assumes it lands
separately; if FB-01 hasn't merged yet, the idle screen will just show today's
`(0,0)`/zoom-2 map, which is still a strict improvement over a blocking Speed panel.

View File

@@ -0,0 +1,603 @@
# FB-03 — Grid-snapped HUD widgets with auto-fit, centered text
**Depends on** — · **Size** L · **Status** Not started
## Goal
Rework the customizable HUD telemetry widgets (built in UI-04) from free-form fractional
positioning into an Android-home-screen-style snapped grid — drag and resize stick to
grid cells, a placement that would overlap another widget is rejected rather than
silently stacking — and make each widget's label/value text scale to fill its own
current size instead of a fixed font that can overflow when small or look lost when
large.
## Context
Direct user feedback (`docs/FEEDBACK.md`, "Map Page" section):
> Speed and other widgets should only appear when recording starts. When toggling off
> different statistics, the flow and arrangement of the widgets goes crazy. This should
> be "drag and drop" but they stick to a grid much like the home screen on android, and
> they can be resizable like android widgets too. Make sure the font and values are
> centered in the widgets as well, and scale to the size of the widget (no over or
> underflow).
(The "only appear when recording starts" half is FB-02, already scoped separately —
this ticket is the grid/drag/resize/text-fit half.)
### Today's model — free-form fractions, no grid at all
`lib/src/hud/hud_widget_layout.dart` (full file, 122 lines):
```dart
const double hudMinWidthFraction = 0.20;
const double hudMaxWidthFraction = 0.70;
const double hudMinHeightFraction = 0.08;
const double hudMaxHeightFraction = 0.40;
class HudWidgetLayout {
const HudWidgetLayout({
required this.metric,
required this.x,
required this.y,
required this.width,
required this.height,
required this.visible,
});
final HudMetric metric;
final double x; // top-left, fraction of the HUD area
final double y;
final double width;
final double height;
final bool visible;
HudWidgetLayout copyWith({double? x, double? y, double? width, double? height, bool? visible}) => ...;
HudWidgetLayout clamped() {
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction);
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction);
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
return HudWidgetLayout(metric: metric, x: clampedX, y: clampedY, width: clampedWidth, height: clampedHeight, visible: visible);
}
Map<String, dynamic> toJson() => {'x': x, 'y': y, 'width': width, 'height': height, 'visible': visible};
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
try {
return HudWidgetLayout(metric: metric, x: (json['x'] as num).toDouble(), y: (json['y'] as num).toDouble(),
width: (json['width'] as num).toDouble(), height: (json['height'] as num).toDouble(), visible: json['visible'] as bool).clamped();
} catch (_) {
return HudWidgetLayout.defaultFor(metric);
}
}
factory HudWidgetLayout.defaultFor(HudMetric metric) {
const columns = 4;
const cellWidth = 0.22;
const cellHeight = 0.12;
const gap = 0.02;
final index = HudMetric.values.indexOf(metric);
final row = index ~/ columns;
final col = index % columns;
return HudWidgetLayout(
metric: metric,
x: 0.02 + col * (cellWidth + gap),
y: 0.06 + row * (cellHeight + gap),
width: cellWidth,
height: cellHeight,
visible: index < columns, // only the first 4 (Speed/Avg Speed/Dist/Time) start visible
);
}
}
```
`HudMetric.values` order (`lib/src/hud/hud_metric.dart`, load-bearing — `defaultFor` uses
this indexing directly): `speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime,
elevationGain, pointsCaptured` (8 total; first 4 start visible).
`lib/src/hud/hud_layout_controller.dart` (full file, 52 lines) — the in-memory,
authoritative layout during an edit session, persisted to `Config` only on exit:
```dart
class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>> {
HudLayoutController(this._config)
: super(_config?.hudLayout ?? {for (final m in HudMetric.values) m: HudWidgetLayout.defaultFor(m)});
final Config? _config;
void updatePosition(HudMetric metric, double x, double y) {
final current = state[metric];
if (current == null) return;
state = {...state, metric: current.copyWith(x: x, y: y).clamped()};
}
void updateSize(HudMetric metric, double width, double height) {
final current = state[metric];
if (current == null) return;
state = {...state, metric: current.copyWith(width: width, height: height).clamped()};
}
void setVisible(HudMetric metric, bool visible) {
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
state = {...state, metric: current.copyWith(visible: visible)};
}
Future<void> persist() async => _config?.setHudLayout(state);
}
```
**Why "toggling widgets goes crazy" happens today**: `Config.hudLayout`'s getter
(`lib/src/config/config.dart` ~lines 95-113) always returns an entry for *every*
`HudMetric`, falling back to `HudWidgetLayout.defaultFor` for any metric never
explicitly saved — so `setVisible`'s `state[metric] ?? defaultFor(metric)` fallback is
essentially dead code; every metric already has a stored layout the moment the
controller exists. That stored layout is whatever it was the last time that metric was
visible (or its untouched `defaultFor` position if it's never been shown/moved). Nothing
about `setVisible`, `defaultFor`, or `clamped()` is aware of what other widgets
currently occupy — `clamped()` only keeps a single widget's own rectangle inside the
0.0–1.0 area, it has no concept of a sibling. So: drag widget A to overlap where widget
B's (currently hidden) default position sits, then re-enable B in Settings — B pops up
directly on top of A. Resize A larger (up to `hudMaxWidthFraction = 0.70`) and it can
swallow B's or C's default slot the same way. This reads as "the whole layout goes
crazy" even though, strictly, no *other* widget's own stored position ever actually
changes — the illusion is caused by newly-visible widgets landing on top of
already-visible ones with zero collision awareness.
### Drag/resize gesture wiring (continuous, per-frame, into shared state)
`lib/src/ui/components/draggable_resizable_hud_widget.dart` (`_DraggableResizableHudWidgetState.build`,
~lines 54-132) computes pixel position/size from `widget.layout`'s fractions × `widget.areaSize`
every build, and the drag/resize gesture handlers call the parent's callbacks **on every
frame of movement**, not just at gesture end:
```dart
onLongPressMoveUpdate: (details) {
widget.onMoved(
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
);
},
...
onPanUpdate: (details) { // the resize handle
widget.onResized(
layout.width + details.delta.dx / widget.areaSize.width,
layout.height + details.delta.dy / widget.areaSize.height,
);
},
```
`onMoved`/`onResized` are wired straight to `HudLayoutController.updatePosition`/
`updateSize` in `lib/src/ui/components/hud_edit_overlay.dart` (~lines 62-69):
```dart
DraggableResizableHudWidget(
key: ValueKey(entry.key),
layout: entry.value,
areaSize: areaSize,
editing: _editing,
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
child: widget.metricBuilder(context, entry.key),
),
```
So every pixel of drag movement recomputes and commits a new fraction to the shared
`StateNotifier` (though `persist()` to `SharedPreferences` still only happens on exit —
that part is fine and unchanged). This continuous-commit model does not translate
directly to a grid: an integer `col`/`row` can't represent "40% of the way toward the
next cell," so this ticket changes drag/resize to track a **local, uncommitted pixel
offset** during the gesture and only calls the parent callback once, with final snapped
grid coordinates, at gesture end. See Design below.
### Fixed-size text, no auto-fit
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()` (~lines 321-346):
```dart
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
```
Font sizes are hardcoded regardless of the widget's actual current size — at the
smallest allowed size this can ellipsize; at the largest allowed size the same small
text looks lost in a big glass panel. `DraggableResizableHudWidget` just centers
whatever child it's given (`GlassPanel(child: Center(child: widget.child))`,
~line 64) — no text-scaling logic exists anywhere in this chain.
### Persistence — no migration needed
`HudWidgetLayout.fromJson`'s existing `try { ... } catch (_) { return
HudWidgetLayout.defaultFor(metric); }` already handles a field-shape change for free: an
old saved JSON blob has `x`/`y`/`width`/`height` keys (doubles); this ticket's new
`fromJson` reads `col`/`row`/`colSpan`/`rowSpan` (ints) and will throw on old data
(missing key, or a cast failure), falling back to `defaultFor` automatically — no schema
version, no explicit migration code. Confirmed reasonable: this is local
`SharedPreferences`, not a shared/synced format, and there's no production data to
preserve.
## Design
### Grid model
Fixed **4 columns × 8 rows** spanning the HUD area (matches the current 4-column
default-row assumption and gives ample vertical room for the 8-metric list). Replace
`HudWidgetLayout`'s `x`/`y`/`width`/`height` doubles with:
```dart
const int hudGridColumns = 4;
const int hudGridRows = 8;
const int hudMinSpan = 1;
const int hudMaxColSpan = 4;
const int hudMaxRowSpan = 3;
class HudWidgetLayout {
const HudWidgetLayout({
required this.metric,
required this.col,
required this.row,
required this.colSpan,
required this.rowSpan,
required this.visible,
});
final HudMetric metric;
final int col;
final int row;
final int colSpan;
final int rowSpan;
final bool visible;
...
}
```
- **`clampedToGrid()`** replaces `clamped()` — a *self-contained* bounds check with no
awareness of siblings (kept as its own method because `fromJson` and
`defaultFor`/`nextFreeSlot` all still need "is this rectangle even inside the grid"
independent of collision):
```dart
HudWidgetLayout clampedToGrid() {
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
return HudWidgetLayout(metric: metric, col: clampedCol, row: clampedRow,
colSpan: clampedColSpan, rowSpan: clampedRowSpan, visible: visible);
}
```
- **Collision helper**, a static/top-level function usable by both the layout file and
the controller:
```dart
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
final aColEnd = a.col + a.colSpan;
final aRowEnd = a.row + a.rowSpan;
final bColEnd = b.col + b.colSpan;
final bRowEnd = b.row + b.rowSpan;
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
}
```
- **`defaultFor`/`nextFreeSlot`**: keep `defaultFor(metric)` for the *static* initial
layout (unchanged conceptually — still purely a function of the metric's own index,
no runtime state needed, since the 8 fixed default slots below never overlap each
other by construction):
```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,
);
}
```
(Speed/AvgSpeed/Distance/ElapsedTime → row 0, cols 0-3, visible; MaxSpeed/MovingTime/
ElevationGain/PointsCaptured → row 2, cols 0-3, hidden — 8 distinct, non-overlapping
slots, same invariant the existing `defaultFor` test already checks.)
Add a **new** function for the actual re-enable-without-collision fix:
```dart
/// Scans row-major from (0,0) for the first colSpan×rowSpan slot that doesn't
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally
/// if the grid is fully packed -- a stacked default is better than a crash or an
/// exception the rider can't do anything about.
static HudWidgetLayout nextFreeSlot(
HudMetric metric, {
required Map<HudMetric, HudWidgetLayout> occupied,
int colSpan = 1,
int rowSpan = 2,
}) {
final candidate = (int col, int row) => HudWidgetLayout(
metric: metric, col: col, row: row, colSpan: colSpan, rowSpan: rowSpan, visible: true,
);
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
final c = candidate(col, row);
final overlapsAny = occupied.values
.where((l) => l.visible && l.metric != metric)
.any((other) => hudRectsOverlap(c, other));
if (!overlapsAny) return c;
}
}
return candidate(0, 0);
}
```
### Controller — collision-aware position/size updates, free-slot re-enable
`lib/src/hud/hud_layout_controller.dart`:
```dart
void updatePosition(HudMetric metric, int col, int row) {
final current = state[metric];
if (current == null) return;
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
if (_overlapsAnyOther(metric, candidate)) return; // reject: no-op, widget stays put
state = {...state, metric: candidate};
}
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
final current = state[metric];
if (current == null) return;
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
if (_overlapsAnyOther(metric, candidate)) return;
state = {...state, metric: candidate};
}
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
.where((l) => l.visible && l.metric != metric)
.any((other) => hudRectsOverlap(candidate, other));
void setVisible(HudMetric metric, bool visible) {
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
// The metric's own saved/default slot is now occupied by something else (the
// "goes crazy" bug this ticket exists to fix) -- find a genuinely free one
// instead of popping up on top of whatever's there.
current = HudWidgetLayout.nextFreeSlot(
metric, occupied: state, colSpan: current.colSpan, rowSpan: current.rowSpan,
);
}
state = {...state, metric: current.copyWith(visible: visible)};
}
```
**"Reject and no-op" is the collision rule for drag/resize** — simplest correct
behavior, and it's the same operation as "revert to last valid" here since `state` is
never mutated until the candidate passes the check (there's nothing to revert *from*,
the old value was never overwritten). A drag/resize that would overlap another visible
widget simply has no effect for that frame/gesture; the widget stays at its last valid
position. `setVisible` gets the one exception — it actively finds a free slot rather
than rejecting, since "the metric just doesn't turn on" would be a much worse user
experience than briefly landing somewhere else on the grid.
### Drag/resize gesture — snap on gesture end, not live
`lib/src/ui/components/draggable_resizable_hud_widget.dart`: track a **local, transient
pixel offset** in `_DraggableResizableHudWidgetState` during the gesture (not committed
to the controller), paint the widget at `layout position (from grid) + local offset`
so the drag still feels smooth and continuous under the finger — then on
`onLongPressEnd`/`onPanEnd`, convert the final raw pixel position/size to the nearest
grid cell and call `widget.onMoved`/`onResized` exactly once with the snapped integer
coordinates, then reset the local offset to zero.
```dart
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
bool _grabbed = false;
Offset _dragOffset = Offset.zero; // pixels, uncommitted, live during a move gesture
Size _resizeDelta = Size.zero; // pixels, uncommitted, live during a resize gesture
double get _cellWidth => widget.areaSize.width / hudGridColumns;
double get _cellHeight => widget.areaSize.height / hudGridRows;
@override
Widget build(BuildContext context) {
final layout = widget.layout;
final left = layout.col * _cellWidth + _dragOffset.dx;
final top = layout.row * _cellHeight + _dragOffset.dy;
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
...
onLongPressMoveUpdate: (details) => setState(() => _dragOffset = details.offsetFromOrigin),
onLongPressEnd: (_) {
final snappedCol = ((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
final snappedRow = ((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
widget.onMoved(snappedCol, snappedRow);
setState(() { _grabbed = false; _dragOffset = Offset.zero; });
},
onLongPressCancel: () => setState(() { _grabbed = false; _dragOffset = Offset.zero; }),
...
// resize handle:
onPanUpdate: (details) => setState(() =>
_resizeDelta = Size(_resizeDelta.width + details.delta.dx, _resizeDelta.height + details.delta.dy)),
onPanEnd: (_) {
final snappedColSpan = ((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth).round();
final snappedRowSpan = ((layout.rowSpan * _cellHeight + _resizeDelta.height) / _cellHeight).round();
widget.onResized(snappedColSpan, snappedRowSpan);
setState(() => _resizeDelta = Size.zero);
},
```
If the parent rejects the move/resize (collision), the widget simply rebuilds at its
unchanged `layout` — since `_dragOffset`/`_resizeDelta` are reset to zero right after
calling `onMoved`/`onResized` regardless of whether the controller accepted it, the
widget visually snaps back to wherever it actually ended up (its last accepted grid
cell) the instant the gesture ends. No separate "was it accepted?" callback needed.
`widget.onMoved`/`onResized` function signatures change to `void Function(int col, int
row)` / `void Function(int colSpan, int rowSpan)`.
`lib/src/ui/components/hud_edit_overlay.dart`: update the two callback wire-ups
(`onMoved: (col, row) => notifier.updatePosition(entry.key, col, row)`, `onResized:
(colSpan, rowSpan) => notifier.updateSize(entry.key, colSpan, rowSpan)`) — no other
change needed in this file; `areaSize` is still the raw pixel `Size` from its own
`LayoutBuilder`, cell-size division now happens inside
`DraggableResizableHudWidget` as shown above.
### Text auto-fit — `FittedBox`, not a computed font size
`lib/src/ui/record/record_screen.dart`, `_HudMetricValue.build()`: wrap the existing
label+value `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop `maxLines`/
`TextOverflow.ellipsis` on both `Text`s (structurally impossible to overflow once
`FittedBox` owns sizing), and keep the existing `fontSize: 10`/`18` values as the
"reference" size `FittedBox` scales down from — the ratio between label and value size
is preserved automatically as it scales:
```dart
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
metric.label.toUpperCase(),
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
),
const SizedBox(height: 4),
Text(
_value,
style: monoDigits.copyWith(fontSize: 18, fontWeight: FontWeight.bold, color: _valueColor(colors)),
),
],
),
);
}
```
`FittedBox` only scales *down* (`BoxFit.scaleDown` never enlarges past the reference
size) — so a widget resized to the grid's maximum span (4 cols × 3 rows) shows text at
its natural 10/18px reference size, not blown up to fill the space. That's an
intentional, reasonable simplification for this ticket (matching "no overflow" exactly
as asked; "looks proportionally larger in a bigger widget" is a nice-to-have, not named
in the feedback) — call this trade-off out plainly in the Outcome section rather than
silently under-delivering on it.
`DraggableResizableHudWidget`'s own centering (`GlassPanel(child: Center(child:
widget.child))`) already satisfies "centered" — no change needed there.
## Implementation
1. Rewrite `lib/src/hud/hud_widget_layout.dart`: grid constants, `col`/`row`/`colSpan`/
`rowSpan` fields, `clampedToGrid()`, `hudRectsOverlap()`, updated `toJson`/
`fromJson`, updated `defaultFor`, new `nextFreeSlot`.
2. Rewrite `lib/src/hud/hud_layout_controller.dart`: `updatePosition`/`updateSize` take
ints and reject-on-collision; `setVisible` uses `nextFreeSlot` when the metric's
current slot collides.
3. Rewrite `DraggableResizableHudWidget`: local pixel-offset drag state, snap-on-end,
updated callback signatures, pixel math from `areaSize / hudGridColumns` /
`hudGridRows`.
4. Update `HudEditOverlay`'s two callback closures to the new int signatures.
5. Wrap `_HudMetricValue`'s `Column` in `FittedBox(fit: BoxFit.scaleDown)`, drop
`maxLines`/`overflow` on its two `Text`s.
6. `flutter analyze`, fix any resulting type errors elsewhere that referenced the old
`x`/`y`/`width`/`height` fields (grep for `.x`/`.y`/`.width`/`.height` on
`HudWidgetLayout` instances across `lib/` and `test/` to be thorough).
## Acceptance criteria
- [ ] Dragging a HUD widget in edit mode snaps to a grid cell on release; the widget
tracks the finger smoothly during the drag itself (no per-frame jump/snap while
still moving).
- [ ] Resizing via the handle snaps to whole grid cells on release, same smooth-during-
drag behavior.
- [ ] Dragging or resizing a widget onto a cell already occupied by another *visible*
widget rejects the move — the widget returns to its last valid position/size,
no crash, no silent overlap.
- [ ] Re-enabling a hidden metric whose saved/default slot is now occupied by another
visible widget places it in the next free grid cell instead of stacking on top of
the occupier — this is the concrete fix for the "goes crazy" feedback.
- [ ] Re-enabling a hidden metric whose slot is still free keeps its exact prior
position (unchanged from today's guarantee, still worth re-verifying under the
new model).
- [ ] HUD widget text (label + value) never overflows or gets ellipsized at any allowed
grid size, and stays visually centered within its widget, at both the smallest
(`1×1`) and largest (`hudMaxColSpan × hudMaxRowSpan`) allowed sizes.
- [ ] An old-shaped saved layout (pre-this-ticket JSON with `x`/`y`/`width`/`height`
keys) loads without crashing — falls back to `defaultFor` per metric, exactly as
`fromJson`'s existing try/catch already guarantees.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
`test/hud_widget_layout_test.dart` needs a full rewrite for the new field names/types —
keep the same test *intents*, translated to grid coordinates:
- JSON round trip (encode/decode a `col`/`row`/`colSpan`/`rowSpan`/`visible` layout
exactly).
- A malformed/missing-field JSON entry falls back to `defaultFor` (unchanged intent,
new field names in the malformed input).
- `defaultFor`: every metric gets a distinct (non-overlapping, via `hudRectsOverlap`)
default rect; the first four metrics start visible, the rest hidden (unchanged
intent).
- `clampedToGrid`: a position/span pushed outside `[0, hudGridColumns)`/
`[0, hudGridRows)` or `[hudMinSpan, hudMaxColSpan/RowSpan]` is corrected back inside
(parallel structure to the old `clamped` tests: past the right/bottom edge, past the
left/top edge, below the legibility floor, above the ceiling, shrink-before-reposition,
already-valid-is-unchanged).
- New: `hudRectsOverlap` — two identical rects overlap; two rects sharing only an edge
(touching, not overlapping) do not; two disjoint rects don't; a rect fully containing
another does.
- New: `nextFreeSlot` — given a set of `occupied` visible layouts that collide with a
metric's own `defaultFor` position, returns some other rect that doesn't collide with
any of them; given an empty/all-hidden `occupied` map, returns the same rect
`defaultFor` would have (or at least a valid non-colliding one at `(0,0)`).
`test/hud_layout_controller_test.dart` needs a parallel rewrite for the new int
signatures and the new collision behavior:
- `updatePosition`/`updateSize` update only the given metric, still true.
- New: `updatePosition` to a cell already occupied by another visible metric is a
no-op (state for the target metric is unchanged).
- New: `updateSize` that would make a widget overlap a sibling is a no-op.
- `setVisible(false)` then `setVisible(true)` restores the last position **when that
position is still free** (keep this test, using coordinates guaranteed not to
collide with anything else default-visible).
- New: `setVisible(true)` when the metric's last position now collides with another
visible widget places it somewhere else instead (assert the resulting `state[metric]`
doesn't overlap anything, not a specific coordinate).
- `persist`/pre-persist-not-visible-to-Config tests carry over unchanged in spirit
(just using int fields).
Widget-level: extend or add to `test/hud_edit_overlay_test.dart` — a drag gesture ending
over an occupied cell leaves the dragged widget's rendered position unchanged from
before the gesture; a resize past the point of colliding with a sibling leaves the
widget at its pre-gesture size. Also add a widget test putting `_HudMetricValue` (or a
representative HUD child) inside a very small `DraggableResizableHudWidget` (1×1 grid
cell) and a very large one (`hudMaxColSpan`×`hudMaxRowSpan`) and asserting no
`RenderFlex overflowed` exception/error is recorded by `FlutterError.onError` during
the pump (the standard way to assert "no overflow" in a widget test — check
`test/widget_test.dart`/other existing tests in this repo for the established pattern,
if any, otherwise use `tester.takeException()` after pumping and assert it's `null`).
## Risks
- `FittedBox(fit: BoxFit.scaleDown)` never enlarges text past its reference size, so a
widget resized to the grid's maximum span shows the same 10/18px reference text as a
1×1 widget, just with more empty space around it — not larger text filling the space.
This satisfies "no overflow" and "centered" exactly as asked; "text should grow to
fill a bigger widget" is not explicitly requested and is treated as future work, not a
silent gap — document this trade-off in the Outcome section.
- Existing saved HUD layouts (from anyone who ran a build before this ticket) reset to
defaults the first time they're loaded post-update, per the fromJson fallback. No
user-facing warning is added for this — acceptable given there's no real user base
yet; flag it anyway in Outcome for completeness.
## Out of scope
FB-02 (hiding the idle Speed panel — separate ticket, this one only touches the
recording-state HUD widgets themselves). Making widget text grow to actually fill a
larger grid span (see Risks). Any change to which metrics exist or their default
visibility set — `HudMetric`'s own enum and `label`s are untouched.

View File

@@ -0,0 +1,216 @@
# FB-04 — Fix Route Planner's map failing to render + match street-level zoom
**Depends on** FB-01 (reuses its `ambientZoom` constant) · **Size** M · **Status** Not started
## Goal
The Route Planner screen's map doesn't render at all for the user. Find the real cause
and fix it, and bring this screen's zoom in line with the rest of the app's new
street-level default (FB-01).
## Context
Direct user feedback (`docs/FEEDBACK.md`, "Route" section):
> This doesn't work at all, the map doesn't render at all... I want it to be the same
> zoomed in map view, then the user can zoom out and create the route.
### High-confidence root cause
`lib/src/ui/routes/route_planner_screen.dart`, `build()` (~lines 112-139):
```dart
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final route = ref.watch(routePlanProvider(widget.routeId)).valueOrNull;
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
final waypoints = waypointsAsync.valueOrNull ?? const [];
final units = ref.watch(unitSystemProvider);
final repo = ref.read(routePlanRepositoryProvider);
if (route == null) {
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Center(
child: Text(
'This route no longer exists.',
style: TextStyle(color: colors.outline),
),
),
),
);
}
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: !waypointsAsync.hasValue
? const Center(child: CircularProgressIndicator())
: Stack(
children: [
Positioned.fill(
child: FlutterMap(
...
```
Both `route` and `waypointsAsync` come from the same kind of provider —
`routePlanProvider`/`routeWaypointsProvider` are both `StreamProvider.autoDispose.family`
(`lib/src/app/providers.dart` ~lines 198-203) — so both start in `AsyncLoading` on a
fresh navigation (e.g. tapping "+ New route" on `RoutesListScreen`, which calls
`repo.createRoutePlan` then immediately `onOpenRoute?.call(id)` — a brand-new
`routePlanProvider(id)` family instance with a DB stream that hasn't delivered its
first row yet). **`waypointsAsync` is correctly guarded** — `!waypointsAsync.hasValue`
shows a spinner instead of building `FlutterMap` prematurely, with a comment explaining
exactly why (`initialCenter`/`initialZoom` are read once at construction; building too
early freezes the camera at null-island forever). **`route` has no equivalent guard** —
`.valueOrNull` collapses "still loading" and "genuinely doesn't exist" into the same
`null`, so on that same cold navigation, this screen renders the **text-only "This route
no longer exists." fallback with zero `FlutterMap` in the tree** — for a route that is
completely valid, simply because its stream's first emission hasn't arrived yet. This is
a real, reproducible-on-a-slow-cold-start bug, and it would read to a user as exactly
"the map doesn't render at all" — because for however long that loading window lasts,
there is no map, no pins, nothing to interact with.
This is the same shape of bug UI-06's own documented Outcome already found once in this
same file (a hardcoded, stale tile URL in `_DownloadDialog`): a place where near-
identical logic exists twice nearby and only one copy got the correct guard.
### Zoom special-cases to replace
Same `build()` method, ~line 157:
```dart
initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3,
```
`initialZoom` is only ever used by flutter_map when `initialCameraFit` is null.
`initialCameraFit: _initialFit(waypoints)` (line ~153) is non-null for 2+ waypoints
(see `_initialFit`, ~lines 92-102, which returns `null` only for `waypoints.length < 2`
or degenerate bounds) — so `maxTileZoom - 3` is **dead, misleading code today**: it can
only ever apply when `_initialFit` already returned null, which for 2+ waypoints only
happens on a degenerate (single-point) bounds, in which case `waypoints.length <= 1` is
false but the fit is still null — meaning `maxTileZoom - 3` actually *does* fire for
that one edge case (all waypoints at the same spot). Still, `14` for 0-1 waypoints is
the literal reason a fresh, empty route (or one with a single pin) opens zoomed out
far past street level, exactly matching the "same zoomed in map view" ask.
## Design
- **Fix the render bug**: change `route` from `.valueOrNull` to keeping the full
`AsyncValue<RoutePlan?>`, and add a `hasValue` guard mirroring `waypointsAsync`'s
existing pattern:
```dart
final routeAsync = ref.watch(routePlanProvider(widget.routeId));
final waypointsAsync = ref.watch(routeWaypointsProvider(widget.routeId));
final waypoints = waypointsAsync.valueOrNull ?? const [];
final units = ref.watch(unitSystemProvider);
final repo = ref.read(routePlanRepositoryProvider);
if (!routeAsync.hasValue) {
return const Scaffold(
backgroundColor: Colors.transparent,
body: Center(child: CircularProgressIndicator()),
);
}
final route = routeAsync.value;
if (route == null) {
return Scaffold( /* unchanged "This route no longer exists." body */ );
}
```
This exactly mirrors the two-step pattern `waypointsAsync` already uses two lines
below it in the same method, just applied consistently to both providers.
- **Zoom fix**: replace the `initialZoom` line with FB-01's shared constant. FB-01 adds
`const double ambientZoom = 17.0;` to `lib/src/ui/components/ride_map.dart` — this
screen already imports named constants from that file (`show TileAttribution,
maxTileZoom, tileMaxNativeZoom, tileSubdomains, tileUrlTemplate, tileUserAgent`, near
the top of the file) — add `ambientZoom` to that same `show` clause and use it:
```dart
initialZoom: waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3,
```
**If FB-01 has not landed yet when this ticket is implemented**, add the same
constant directly to `ride_map.dart` yourself first (`const double ambientZoom =
17.0;` near `maxTileZoom`/`shortRideZoom`) so this ticket doesn't block on ordering —
whichever of FB-01/FB-04 lands second will find the constant already defined and
should just reuse it rather than redefining it (grep for `ambientZoom` before adding
it, to avoid a duplicate-constant compile error).
- **Investigation order, if the above turns out not to be the whole story** (do this
first, before assuming the fix above is sufficient — reproduce, then fix):
1. `flutter run` on the Android emulator (or a device), tap "+ New route" from
`RoutesListScreen` (`lib/src/ui/routes/routes_list_screen.dart`, `Key('new-route')`
button), watch closely for a flash of "This route no longer exists." text or a
fully blank screen right after navigation.
2. If reproduced and the `hasValue` fix above resolves it, done — write up the root
cause and fix in the Outcome section.
3. If it's *not* resolved (map still doesn't render even with the `hasValue` guard in
place), add a temporary `debugPrint('route=${routeAsync.runtimeType} '
'waypoints=${waypointsAsync.runtimeType}')` at the top of `build()` and watch the
console across a fresh navigation to see the actual state sequence — remove it
before committing.
4. Check whether the map mounts (i.e. `FlutterMap` is in the tree, visible in the
Flutter inspector / a screenshot) but tiles themselves are blank — if so, look at
`cachedTileProviderProvider` (`lib/src/app/providers.dart` ~lines 234-238): it
returns `null` while `tileCacheProvider`'s underlying `FutureProvider` hasn't
resolved yet, and `TileLayer(tileProvider: null)` falls back to flutter_map's own
default network fetcher — verify that fallback actually fetches (it should; if it
silently doesn't, that's the bug).
5. Diff this screen's tile setup token-for-token against `RideMap`'s
(`tileUrlTemplate`, `tileSubdomains`, `tileMaxNativeZoom`, `tileUserAgent` — all
already imported from the same `ride_map.dart` export, so a stale-URL-style
regression is unlikely, but `grep -n "tile.openstreetmap\|cartocdn"` across this
file to be certain nothing reintroduced UI-06's already-fixed bug).
## Implementation
1. Change `route` to `routeAsync` (full `AsyncValue`), add the `hasValue` guard, keep
the rest of the "no longer exists" branch's body unchanged.
2. Add `ambientZoom` to this file's `ride_map.dart` import `show` clause (adding the
constant to `ride_map.dart` first if FB-01 hasn't landed yet — see Design).
3. Replace `waypoints.length <= 1 ? 14 : maxTileZoom - 3` with
`waypoints.length <= 1 ? ambientZoom : maxTileZoom - 3`.
4. Reproduce on an emulator per the Investigation order above; if the root cause is
something other than the `hasValue` gap, document the real cause and its fix in the
Outcome section instead of (or in addition to) the above.
## Acceptance criteria
- [ ] Opening a brand-new route ("+ New route" from `RoutesListScreen`) always shows a
loading spinner briefly (if the stream hasn't emitted yet) and then the real map
canvas — never the "This route no longer exists." text for a route that
genuinely exists.
- [ ] Opening an existing, previously-created route with no waypoints shows the map
canvas at `ambientZoom` (street level), not zoom 14.
- [ ] A route that is actually deleted (or never existed — e.g. a stale/invalid id)
still correctly shows "This route no longer exists." — the fix must not weaken
this case, only stop it from firing on a merely-still-loading valid route.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
- Widget test: pump `RoutePlannerScreen` for a route id that has a pending (not-yet-
resolved) `routePlanProvider` stream — assert a `CircularProgressIndicator` shows,
never the "no longer exists" text, then once the stream emits the real row, assert
the map (`FlutterMap`) appears. (Check how existing tests in
`test/route_planner_screen_test.dart` seed/await the repository — likely via
`repo.createRoutePlan` then pumping a frame or two before the provider's stream
delivers, similar to the existing `pumpMap` helper in that file's own comments about
needing a handful of frames for the waypoints stream's first value.)
- Widget test: pump `RoutePlannerScreen` for an id that was never created (or was
deleted) — assert "This route no longer exists." still shows once the stream settles
(not immediately, if the provider briefly reports loading first).
- Widget test: a route with zero waypoints renders at `ambientZoom`, not `14` — read
the `FlutterMap`'s `options.initialZoom` via `tester.widget<FlutterMap>(...)` the same
way other zoom-related assertions in this test suite already inspect `MapOptions`
(check existing patterns in `test/ride_map_test.dart`/
`test/route_planner_screen_test.dart`).
- If the investigation reveals a different root cause than the `hasValue` gap, write a
regression test for the actual bug found, not just the hypothesis above.
## Risks
- If the `hasValue` fix doesn't fully explain what the user saw, this ticket's
Outcome section must say so plainly and document whatever the actual root cause
turned out to be — don't claim a fix that wasn't verified to address the real
symptom. Reproducing on a real emulator/device before declaring this done is
important precisely because the original bug report is "doesn't work at all," a
strong signal, and a subtle timing bug is an easy thing to fix on paper without
confirming it was actually the cause.
## Out of scope
Closed-loop routes (FB-05, sequenced after this ticket since it also edits this file
heavily). Turn-by-turn route following (V3-09, already explicitly deferred by UI-06).
Any change to `RoutesListScreen` itself.

View File

@@ -0,0 +1,411 @@
# FB-05 — Closed-loop routes in Route Planner
**Depends on** FB-04 (same file, heavily edited — sequence after it merges) · **Size** M · **Status** Not started
## Goal
Route planning currently only supports a one-way A→B→C path. Add the ability to close
the route into a loop that returns to its starting pin, updating the drawn polyline and
the distance stat to include the closing segment.
## Context
Direct user feedback (`docs/FEEDBACK.md`, "Route" section):
> ...there is no way to create a loop with the pins, it only creates a unidirectional
> path.
### Data model — no loop concept exists today
`lib/src/domain/models.dart`, `RoutePlan` (~lines 229-276):
```dart
class RoutePlan {
const RoutePlan({
this.id = 0,
required this.name,
required this.createdAt,
this.activity = Activity.motorcycle,
this.distanceM = 0.0,
this.estimatedMillis,
this.geometry,
});
final int id;
final String name;
final int createdAt;
final Activity activity;
final double distanceM;
final int? estimatedMillis;
final String? geometry;
RoutePlan copyWith({
int? id, String? name, int? createdAt, Activity? activity,
double? distanceM, int? estimatedMillis, String? geometry,
}) => RoutePlan(
id: id ?? this.id, name: name ?? this.name, createdAt: createdAt ?? this.createdAt,
activity: activity ?? this.activity, distanceM: distanceM ?? this.distanceM,
estimatedMillis: estimatedMillis ?? this.estimatedMillis, geometry: geometry ?? this.geometry,
);
}
```
`Waypoint` (~lines 278-309) has an `ordinal` for ordering, and waypoints are only ever
appended at the end (`RoutePlanRepository.addWaypoint`, below) — there is no "return to
start" field or concept anywhere in the model.
### Polyline and distance — strictly open, no wraparound
`lib/src/ui/routes/route_planner_screen.dart`, the `PolylineLayer` (~lines 182-201):
```dart
if (waypoints.length >= 2)
PolylineLayer(
polylines: [
Polyline(
points: [
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
],
strokeWidth: 4,
pattern: StrokePattern.dashed(segments: const [8, 6]),
color: colors.primary,
),
],
),
```
`lib/src/data/route_plan_repository.dart`, distance recomputation (full relevant
section):
```dart
Future<void> addWaypoint(int routeId, double latitude, double longitude) async {
final existing = await _db.waypointsForRoute(routeId);
await _db.insertWaypoint(
Waypoint(routeId: routeId, ordinal: existing.length, latitude: latitude, longitude: longitude),
);
await _recomputeDistance(routeId);
}
Future<void> _recomputeDistance(int routeId) async {
final waypoints = await _db.waypointsForRoute(routeId);
final distance = geo.pathLengthMeters([
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
]);
await _db.setRoutePlanDistance(routeId, distance);
}
```
`geo.pathLengthMeters` (`lib/src/geo/geo.dart` ~line 185) sums consecutive-pair
distances generically over whatever point list it's given — it needs no changes itself,
just a point list that includes the closing segment when appropriate.
`FloatingPill`'s Distance/Est. Time/Pins stats read straight off `route.distanceM`/
`route.estimatedMillis`/`waypoints.length` (`route_planner_screen.dart` ~lines 280-292)
— Distance updates for free once `_recomputeDistance` accounts for the closing segment;
Pins and Est. Time are unaffected by this ticket.
### Schema — current version and migration pattern to follow
`lib/src/data/database.dart`:
```dart
@DataClassName('RoutePlanRow')
class RoutePlans extends Table {
@override
String get tableName => 'route_plans';
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
IntColumn get createdAt => integer()();
TextColumn get activity => textEnum<domain.Activity>().withDefault(const Constant('motorcycle'))();
RealColumn get distanceM => real().withDefault(const Constant(0))();
IntColumn get estimatedMillis => integer().nullable()();
TextColumn get geometry => text().nullable()();
}
@DriftDatabase(tables: [Trips, Segments, TrackPoints, RoutePlans, Waypoints])
class AppDatabase extends _$AppDatabase {
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.addColumn(trips, trips.activity);
}
if (from < 3) {
await m.createTable(routePlans);
await m.createTable(waypoints);
}
},
...
```
The existing `if (from < 2) { await m.addColumn(trips, trips.activity); }` is the exact
pattern to follow for a new nullable/defaulted column.
`RoutePlanRepository` insert/conversion glue, in `database.dart`:
```dart
Future<int> insertRoutePlan(domain.RoutePlan route) => into(routePlans).insert(
RoutePlansCompanion.insert(
name: route.name, createdAt: route.createdAt, activity: Value(route.activity),
distanceM: Value(route.distanceM), estimatedMillis: Value(route.estimatedMillis),
geometry: Value(route.geometry),
),
);
Future<void> setRoutePlanDistance(int id, double distanceM) => (update(routePlans)
..where((r) => r.id.equals(id))).write(RoutePlansCompanion(distanceM: Value(distanceM)));
domain.RoutePlan _toRoutePlan(RoutePlanRow r) => domain.RoutePlan(
id: r.id, name: r.name, createdAt: r.createdAt, activity: r.activity,
distanceM: r.distanceM, estimatedMillis: r.estimatedMillis, geometry: r.geometry,
);
```
### Overflow menu — where the toggle belongs
`route_planner_screen.dart`, `_OverflowMenu` (~lines 493-530-ish) already hosts
Rename/Download/Delete as a `PopupMenuButton` inside a `GlassPanel`:
```dart
class _OverflowMenu extends StatelessWidget {
const _OverflowMenu({
required this.hasWaypoints, required this.onRename, required this.onDownload, required this.onDelete,
});
final bool hasWaypoints;
final VoidCallback onRename;
final VoidCallback? onDownload;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: PopupMenuButton<String>(
key: const Key('route-overflow-menu'),
icon: const Icon(Icons.more_vert),
onSelected: (value) => switch (value) {
'rename' => onRename(),
'download' => onDownload?.call(),
'delete' => onDelete(),
_ => null,
},
itemBuilder: (context) => [
const PopupMenuItem(key: Key('rename-route'), value: 'rename', child: Text('Rename')),
...
```
And it's constructed at the call site (~lines 298-310):
```dart
_OverflowMenu(
hasWaypoints: waypoints.isNotEmpty,
onRename: () => _rename(context, repo, route),
onDownload: waypoints.isEmpty ? null : () => _downloadOfflineTiles(context, waypoints),
onDelete: () async {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
},
),
```
## Design
- **Schema**: add `BoolColumn get isClosedLoop => boolean().withDefault(const
Constant(false))();` to `RoutePlans` in `database.dart`. Bump `schemaVersion` from
`3` to `4`; add `if (from < 4) { await m.addColumn(routePlans, routePlans.isClosedLoop); }`
to `onUpgrade`.
- **Domain model**: add `final bool isClosedLoop;` to `RoutePlan` (default `false` in
the constructor), thread it through `copyWith`.
- **DB glue**: add `isClosedLoop: Value(route.isClosedLoop)` to `insertRoutePlan`'s
`RoutePlansCompanion.insert(...)` call, `isClosedLoop: r.isClosedLoop` to
`_toRoutePlan`, and a new method mirroring `setRoutePlanDistance`:
```dart
Future<void> setRoutePlanClosedLoop(int id, bool value) => (update(routePlans)
..where((r) => r.id.equals(id))).write(RoutePlansCompanion(isClosedLoop: Value(value)));
```
- **Repository**: add to `RoutePlanRepository`:
```dart
Future<void> setClosedLoop(int routeId, bool value) async {
await _db.setRoutePlanClosedLoop(routeId, value);
await _recomputeDistance(routeId); // the closing segment changes the total
}
```
Update `_recomputeDistance` to fetch the route's own flag and append the first
waypoint's coordinates when closed, before summing:
```dart
Future<void> _recomputeDistance(int routeId) async {
final route = await _db.getRoutePlan(routeId);
final waypoints = await _db.waypointsForRoute(routeId);
final points = [for (final w in waypoints) geo.LatLon(w.latitude, w.longitude)];
if (route?.isClosedLoop == true && points.length >= 2) {
points.add(points.first);
}
final distance = geo.pathLengthMeters(points);
await _db.setRoutePlanDistance(routeId, distance);
}
```
(`_recomputeDistance` is already called from every waypoint-mutating method —
`addWaypoint`, `moveWaypoint`, `deleteWaypoint`, `reorderWaypoint` — so the closing
segment is kept correct automatically as pins are edited, with no other call site
changes needed.)
- **UI toggle**: add a 4th item to `_OverflowMenu`, enabled only when there are enough
waypoints for a loop to mean anything (`hasWaypoints` alone isn't enough — need at
least 2 to form any segment at all; reuse the existing `hasWaypoints` bool but also
thread through whether there are `>= 2` waypoints, or simplify by passing a new
`canCloseLoop: waypoints.length >= 2` param):
```dart
class _OverflowMenu extends StatelessWidget {
const _OverflowMenu({
required this.hasWaypoints,
required this.canCloseLoop,
required this.isClosedLoop,
required this.onRename,
required this.onDownload,
required this.onToggleLoop,
required this.onDelete,
});
...
final bool canCloseLoop;
final bool isClosedLoop;
final VoidCallback onToggleLoop;
@override
Widget build(BuildContext context) => GlassPanel(
...
child: PopupMenuButton<String>(
...
onSelected: (value) => switch (value) {
'rename' => onRename(),
'download' => onDownload?.call(),
'closeLoop' => onToggleLoop(),
'delete' => onDelete(),
_ => null,
},
itemBuilder: (context) => [
const PopupMenuItem(key: Key('rename-route'), value: 'rename', child: Text('Rename')),
...
PopupMenuItem(
key: const Key('toggle-closed-loop'),
value: 'closeLoop',
enabled: canCloseLoop,
child: Text(isClosedLoop ? 'Open the loop' : 'Close the loop'),
),
...
],
),
);
}
```
Wire it at the call site:
```dart
_OverflowMenu(
hasWaypoints: waypoints.isNotEmpty,
canCloseLoop: waypoints.length >= 2,
isClosedLoop: route.isClosedLoop,
onRename: () => _rename(context, repo, route),
onDownload: waypoints.isEmpty ? null : () => _downloadOfflineTiles(context, waypoints),
onToggleLoop: () => repo.setClosedLoop(widget.routeId, !route.isClosedLoop),
onDelete: () async { ... },
),
```
- **Polyline**: append the closing point when the route is closed and there are at
least 2 waypoints:
```dart
if (waypoints.length >= 2)
PolylineLayer(
polylines: [
Polyline(
points: [
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
if (route.isClosedLoop)
ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
],
strokeWidth: 4,
pattern: StrokePattern.dashed(segments: const [8, 6]),
color: colors.primary,
),
],
),
```
Same `Polyline` object, same styling — just one more point in the list, so the closing
segment renders with identical dashed styling to the rest of the route.
- **`FloatingPill`/`_initialFit`**: no changes needed — Distance updates automatically
via `_recomputeDistance`; Pins/Est. Time are correctly unaffected; `_initialFit`'s
bounds-fitting is based on waypoint positions regardless of whether the last segment
loops back (the closing point is always one of the existing waypoints' own
coordinates, already inside the fitted bounds).
## Implementation
1. Add `isClosedLoop` column to `RoutePlans` in `database.dart`, bump `schemaVersion`
to `4`, add the `onUpgrade` migration step.
2. Add `isClosedLoop` field to `RoutePlan` domain model + `copyWith`.
3. Add `isClosedLoop` to `insertRoutePlan`'s companion and `_toRoutePlan` in
`database.dart`; add `setRoutePlanClosedLoop`.
4. Add `RoutePlanRepository.setClosedLoop`; update `_recomputeDistance` to append the
closing point when `isClosedLoop`.
5. Extend `_OverflowMenu` with the new toggle item and params; wire it at the call
site in `route_planner_screen.dart`.
6. Extend the `PolylineLayer`'s point list with the conditional closing point.
## Acceptance criteria
- [ ] "Close the loop" appears in the overflow menu, disabled when there are fewer than
2 waypoints, enabled otherwise.
- [ ] Toggling it on redraws the polyline with a visible segment from the last waypoint
back to the first, same dashed styling as the rest of the route.
- [ ] Toggling it on increases the Distance stat by the length of the new closing
segment; toggling it back off returns Distance to the open-path total.
- [ ] The menu label reflects current state ("Close the loop" when open, "Open the
loop" when already closed).
- [ ] Adding/moving/deleting a waypoint on a closed-loop route keeps the closing segment
correct automatically (it's recomputed via the existing `_recomputeDistance` call
already present in every waypoint-mutating method).
- [ ] Deleting waypoints down to fewer than 2 while closed doesn't crash — the
`waypoints.length >= 2` guards in both the polyline and distance code make the
closing point simply disappear along with the rest of the route drawing, same as
today's behavior for an open path with 0-1 waypoints.
- [ ] A pre-existing (schema v3) database opens cleanly post-migration with
`isClosedLoop` defaulting to `false` on every existing route.
- [ ] `flutter analyze` clean, `flutter test` green, test count only goes up.
## Tests
- **Migration test** (`test/migration_test.dart`, following the exact pattern of the
existing `'a v2 database (V3-07) gains route_plans/waypoints and keeps its trips'`
test): seed a v3 database (v1 seed + `activity` column + `route_plans`/`waypoints`
tables created via raw SQL, `PRAGMA user_version = 3;`), open it with `AppDatabase`,
insert a route via `db.insertRoutePlan`, and assert `isClosedLoop` reads back
`false` by default, and that toggling it via `setRoutePlanClosedLoop` persists.
- **Repository tests** (`test/route_plan_repository_test.dart`, alongside the existing
`'adding waypoints appends in order and updates distance live'` etc.):
- `setClosedLoop(true)` on a route with 2+ waypoints increases `distanceM` by
exactly the closing segment's length (compute the expected delta directly with
`geo.pathLengthMeters`/a manual haversine call between the last and first
waypoint, matching how other distance tests in this file already assert exact
values).
- `setClosedLoop(false)` after `true` returns `distanceM` to the pre-toggle value.
- Adding a waypoint to an already-closed route keeps the distance correct
(recomputed including the new closing segment against the new last-added point,
not the old one).
- **Widget tests** (`test/route_planner_screen_test.dart`):
- The "Close the loop" menu item is disabled with 0-1 waypoints, enabled with 2+
(mirrors the existing "offline-tiles download menu item is disabled with no pins"
test's structure — open the overflow menu, inspect the `PopupMenuItem.enabled`
property via its key).
- Tapping it toggles `route.isClosedLoop` (assert via
`repo.routePlanById(id)?.isClosedLoop`, the same pattern the existing rename test
uses to verify writes through to the repository) and the label switches to "Open
the loop".
- With the loop closed, `find.byType(Polyline)` (or inspecting the `PolylineLayer`'s
`polylines` list directly, matching whatever existing pattern this test file uses
to inspect polyline data) has one more point than the waypoint count.
## Risks
- None significant — this is additive (new column, new optional toggle) and every
touched call site (`_recomputeDistance`) already runs on every mutation, so there's
no new code path that could silently skip recomputation.
## Out of scope
Turn-by-turn following of a closed loop (V3-09, already deferred). Any UI indication of
loop direction/rotation. Road-snapped routing for the closing segment (V3-08) — it stays
a straight line like every other segment in this ticket's scope.

41
docs/feedback/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Post-launch feedback tickets
Same shape as `docs/ui-redesign/`: one file per ticket, Goal · Context · Design ·
Implementation · Acceptance criteria · Tests · Risks · Out of scope, written before
implementing, with an Outcome section appended after.
Source: `docs/FEEDBACK.md` — hands-on feedback after using the redesigned app. Turned
into 5 tickets, each independently completable (self-contained enough for a fresh
subagent with no prior context to implement correctly).
## The tickets
| # | Ticket | Size | Depends on | Status |
|---|---|---|---|---|
| [FB-01](FB-01-live-street-level-map.md) | Live, street-level map everywhere | L | — | Not started |
| [FB-02](FB-02-hide-idle-speed-panel.md) | Hide the idle Speed panel until recording starts | S | — | Not started |
| [FB-03](FB-03-grid-snapped-hud-widgets.md) | Grid-snapped HUD widgets with auto-fit text | L | — | Not started |
| [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) | Not started |
| [FB-05](FB-05-closed-loop-routes.md) | Closed-loop routes in Route Planner | M | FB-04 (same file) | Not started |
## Dependencies / dispatch order
```
Wave 1 (parallel): FB-01, FB-02, FB-03
Wave 2 (after Wave 1 lands): FB-04 — reuses FB-01's ambientZoom constant
Wave 3 (after Wave 2 lands): FB-05 — heavily edits the same file FB-04 just touched
```
FB-02 and FB-03 both touch `lib/src/ui/record/record_screen.dart`, but disjoint
regions (the idle-state `Column` vs. `_HudMetricValue`) — low conflict risk.
## Working method, per ticket
1. Implement against the ticket's own Implementation section.
2. `flutter analyze` clean, `flutter test` green — count must not regress (374 passing
before this batch starts).
3. Outcome section appended to the ticket file: what shipped, deviations, test counts.
4. Commit, status flipped to Done in both the ticket file and this table.
Android-emulator verification is best-effort only for this batch — lean on widget
tests as the primary bar; don't get stuck fighting emulator flakiness.

View File

@@ -57,6 +57,30 @@ final locationSourceProvider = Provider<LocationSource>((ref) {
return source;
});
/// The device's current position when nothing is being recorded — drives the shared
/// background map's "look like Google Maps while idle" behavior. Deliberately NOT
/// gated through `recordingEngineProvider`/`RecordingEngine.start()` — this must work
/// whether or not a ride is ever recorded. Never calls `LocationSource.stop()`: the
/// underlying `locationSourceProvider` instance is shared with the recording engine,
/// and `stop()` is not reference-counted (see FB-01's ticket for why).
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);
// No `stop()` call, ever, on dispose — see the doc comment above. Only this
// provider's own subscription to the broadcast `fixes` stream ends; the shared
// platform subscription is left exactly as it was.
});
/// Loaded once at startup; null until then so nothing blocks the first frame.
final configProvider = StateProvider<Config?>((ref) => null);

View File

@@ -14,6 +14,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart' as ll;
import '../app/providers.dart';
import '../domain/models.dart';
@@ -70,6 +71,13 @@ class ShellScaffold extends ConsumerWidget {
final segments = trip == null
? const <Segment>[]
: ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const <Segment>[];
// FB-01: only watched while idle, so there's never a duplicate GPS consumer during
// an active recording -- the moment a trip starts, this stops being watched at all.
final ambientFix =
trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null;
final ambientPosition = ambientFix == null
? null
: ll.LatLng(ambientFix.latitude, ambientFix.longitude);
return Scaffold(
// Falls back to the ordinary theme background when the map is disabled -- the
@@ -90,6 +98,7 @@ class ShellScaffold extends ConsumerWidget {
key: const Key('shell-background-map'),
points: points,
segments: segments,
ambientPosition: ambientPosition,
follow: isMapTab,
fill: true,
showEmptyLabel: false,

View File

@@ -47,6 +47,12 @@ const double maxTileZoom = 19.0;
/// What a very short ride falls back to, so streets stay visible.
const double shortRideZoom = 17.0;
/// Street-level zoom for the idle, no-recorded-points ambient position (FB-01). A
/// distinct name from [shortRideZoom] even though the value happens to match -- one
/// means "a very short recorded ride," the other means "no ride at all, just ambient
/// GPS."
const double ambientZoom = 17.0;
class RideMap extends StatefulWidget {
const RideMap({
super.key,
@@ -60,12 +66,19 @@ class RideMap extends StatefulWidget {
this.skeletonMode = false,
this.showLocationMarker = false,
this.showAttribution = true,
this.ambientPosition,
});
final List<TrackPoint> points;
final List<Segment> segments;
final double height;
/// FB-01: the device's current GPS position when there are no recorded [points] to
/// chase -- drives the shared background map's "look like Google Maps while idle"
/// behaviour. Only meaningful when [points] is empty; a recorded path always takes
/// priority over ambient position.
final ll.LatLng? ambientPosition;
/// UI-02: true when `MapConnectivityState` has decided neither the offline cache nor
/// the network can currently produce tiles. Swaps `TileLayer` for `SkeletonMapLayer`
/// -- markers/polylines are unaffected, since those come from local data, not tiles.
@@ -128,14 +141,24 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
@override
void didUpdateWidget(RideMap old) {
super.didUpdateWidget(old);
if (!_following || widget.points.isEmpty || _backgrounded) return;
final last = widget.points.last;
// A manual camera move already exists once the controller has been used; jumping
// straight to the latest fix keeps the map from ever being one flush behind.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_following) return;
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
});
if (!_following || _backgrounded) return;
if (widget.points.isNotEmpty) {
final last = widget.points.last;
// A manual camera move already exists once the controller has been used; jumping
// straight to the latest fix keeps the map from ever being one flush behind.
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) {
// FB-01: no recorded path yet -- chase the ambient GPS fix instead, the same way
// a recording is chased above.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_following) return;
_controller.move(widget.ambientPosition!, _controller.camera.zoom);
});
}
}
@override
@@ -229,10 +252,10 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
maxZoom: maxTileZoom,
),
initialCenter: bounds == null
? const ll.LatLng(0, 0)
? (widget.ambientPosition ?? const ll.LatLng(0, 0))
: ll.LatLng(bounds.centerLat, bounds.centerLon),
initialZoom: bounds == null
? 2
? (widget.ambientPosition == null ? 2 : ambientZoom)
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
maxZoom: maxTileZoom,
interactionOptions: hasPoints
@@ -272,15 +295,17 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
tileProvider: widget.tileProvider,
),
PolylineLayer(polylines: polylines),
if (widget.showLocationMarker && hasPoints)
if (widget.showLocationMarker && (hasPoints || widget.ambientPosition != null))
MarkerLayer(
markers: [
Marker(
key: const Key('location-marker'),
point: ll.LatLng(
widget.points.last.latitude,
widget.points.last.longitude,
),
point: hasPoints
? ll.LatLng(
widget.points.last.latitude,
widget.points.last.longitude,
)
: widget.ambientPosition!,
width: 40,
height: 40,
child: const PulsingLocationMarker(),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart' as ll;
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/components/ride_map.dart';
import 'package:rippr/src/ui/components/skeleton_map_layer.dart';
@@ -181,4 +182,150 @@ void main() {
'just because the tile fetch is failing');
});
});
group('ambient position (FB-01)', () {
testWidgets('with no recorded points, an ambient position centers the map at '
'street level', (tester) async {
const fix = ll.LatLng(51.05, -114.05);
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));
expect(map.options.initialCenter, fix);
expect(map.options.initialZoom, ambientZoom);
});
testWidgets('with neither recorded points nor an ambient fix, the map still '
'falls back to (0, 0) at zoom 2 (no regression, no crash)', (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.initialCenter, const ll.LatLng(0, 0));
expect(map.options.initialZoom, 2);
});
testWidgets('while following, a new ambient fix re-centers the map, exactly '
'like the recorded-path chase', (tester) async {
const fix1 = ll.LatLng(51.0, -114.0);
const fix2 = ll.LatLng(51.01, -114.01);
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();
await tester.pumpWidget(build(fix2));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(fix2.latitude, 1e-9));
expect(center.longitude, closeTo(fix2.longitude, 1e-9));
});
testWidgets('a manual pan cancels ambient following the same way it cancels '
'recording-follow', (tester) async {
const fix1 = ll.LatLng(51.0, -114.0);
const fix2 = ll.LatLng(51.01, -114.01);
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();
// Simulate a real user gesture the same way flutter_map itself would report
// one to `onPositionChanged` -- calling the callback directly with
// `hasGesture: true` exercises the exact guard in `_RideMapState` without
// needing a real pointer gesture to get past `InteractionOptions`.
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);
await tester.pump();
await tester.pumpWidget(build(fix2));
await tester.pump();
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(fix1.latitude, 1e-9),
reason: 'following was cancelled by the manual pan; a later ambient fix '
'must not move the camera');
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
});
testWidgets('the pulsing location marker falls back to the ambient position '
'when there are no recorded points', (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,
showLocationMarker: true,
ambientPosition: fix,
),
),
));
await tester.pump();
expect(find.byKey(const Key('location-marker')), findsOneWidget);
});
testWidgets('no marker is shown when there is neither a recorded point nor an '
'ambient position', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
showLocationMarker: true,
),
),
));
await tester.pump();
expect(find.byKey(const Key('location-marker')), findsNothing);
});
});
}

View File

@@ -461,6 +461,85 @@ void main() {
});
});
group('ambient position (FB-01)', () {
screenTest('with no active trip, an ambient GPS fix drives the shared '
'background map', (tester) async {
await tester.pumpWidget(host(
ShellScaffold(
currentIndex: 0,
onDestinationSelected: (_) {},
child: const SizedBox.shrink(),
),
));
// A frame to build the tree, then time for `ambientPositionProvider`'s
// `await source.start()` to resolve and subscribe to `source.fixes`.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// No fix yet: still today's neutral fallback, no marker.
expect(find.byKey(const Key('location-marker')), findsNothing);
source.emitAt(timestamp: 1000, latitude: 51.2, longitude: -114.2);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byKey(const Key('location-marker')), findsOneWidget,
reason: 'an ambient fix drives the pulsing marker even with no trip '
'ever recorded');
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(51.2, 1e-9));
expect(center.longitude, closeTo(-114.2, 1e-9));
// A second fix keeps the map following, exactly like the recorded-path chase.
source.emitAt(timestamp: 2000, latitude: 51.21, longitude: -114.21);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final moved = tester.widget<FlutterMap>(find.byType(FlutterMap));
final movedCenter = moved.mapController!.camera.center;
expect(movedCenter.latitude, closeTo(51.21, 1e-9));
expect(movedCenter.longitude, closeTo(-114.21, 1e-9));
});
screenTest('once a trip starts recording, the shell stops watching ambient '
'GPS -- no duplicate location consumer during a ride', (tester) async {
// Idle first: the ambient provider engages and calls start() at least once
// (activeTripProvider's own first, async emission of `null` briefly reads
// as "idle" too, which is expected -- same shape as `points`/`segments`
// falling back to empty lists until their streams first emit).
await tester.pumpWidget(host(
ShellScaffold(
currentIndex: 0,
onDestinationSelected: (_) {},
child: const SizedBox.shrink(),
),
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
source.emitAt(timestamp: 1000, latitude: 51.0, longitude: -114.0);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byKey(const Key('location-marker')), findsOneWidget,
reason: 'sanity check: ambient mode is actually engaged before the '
'trip starts');
final callsWhileIdle = source.startCalls;
expect(callsWhileIdle, greaterThanOrEqualTo(1));
// Now a trip starts recording. Nothing in this test ever taps Start, so the
// only thing that could call `LocationSource.start()` again is
// `ambientPositionProvider` -- and it must not, because `ShellScaffold`
// stops watching it entirely the moment `activeTripProvider` is non-null.
await repo.startTrip(2000);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(source.startCalls, callsWhileIdle,
reason: 'once a trip is active, ambientPositionProvider must no '
'longer be watched at all');
});
});
group('trips list', () {
screenTest('empty state explains what to do', (tester) async {
await tester.pumpWidget(host(const TripsScreen()));
@@ -829,4 +908,59 @@ void main() {
expect(await repo.segmentsForTrip(h.tripId), hasLength(1));
});
});
group('ambientPositionProvider (FB-01)', () {
// Provider-level, not widget-level: exercises the provider directly against a
// `ProviderContainer` so its lifecycle (including disposal) can be driven
// precisely, per the ticket's own suggested test shape.
test('never calls LocationSource.stop() -- the underlying source is shared '
'with an active recording and stop() is not reference-counted', () async {
final fakeSource = FakeLocationSource();
final container = ProviderContainer(
overrides: [locationSourceProvider.overrideWithValue(fakeSource)],
);
addTearDown(container.dispose);
final sub = container.listen(ambientPositionProvider, (_, _) {});
// Let the provider's `await source.start()` resolve and subscribe.
await Future<void>.delayed(Duration.zero);
expect(fakeSource.startCalls, 1);
sub.close();
container.dispose();
expect(fakeSource.stopCalls, 0,
reason: 'ambientPositionProvider must only ever call start(), never '
'stop(), on the shared LocationSource');
await fakeSource.dispose();
});
test('turning mapEnabledProvider off never starts location and yields null',
() async {
final fakeSource = FakeLocationSource();
final container = ProviderContainer(
overrides: [
locationSourceProvider.overrideWithValue(fakeSource),
mapEnabledProvider.overrideWith((ref) => false),
],
);
addTearDown(container.dispose);
final values = <AsyncValue<LocationFix?>>[];
final sub = container.listen(
ambientPositionProvider,
(_, next) => values.add(next),
fireImmediately: true,
);
await Future<void>.delayed(Duration.zero);
expect(fakeSource.startCalls, 0,
reason: 'no tile or location request should fire while the map is '
'disabled, matching the existing map-toggle guarantee');
expect(values.last.valueOrNull, isNull);
sub.close();
await fakeSource.dispose();
});
});
}