Write FB-01..FB-05 feedback tickets
Turns docs/FEEDBACK.md into five self-contained tickets, each grounded in exact current code and a concrete implementation approach, so they can be handed to independent subagents with no prior context.
This commit is contained in:
335
docs/feedback/FB-01-live-street-level-map.md
Normal file
335
docs/feedback/FB-01-live-street-level-map.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# FB-01 — Live, street-level map everywhere
|
||||
|
||||
**Depends on** — · **Size** L · **Status** Not started
|
||||
|
||||
## 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.
|
||||
193
docs/feedback/FB-02-hide-idle-speed-panel.md
Normal file
193
docs/feedback/FB-02-hide-idle-speed-panel.md
Normal 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.
|
||||
603
docs/feedback/FB-03-grid-snapped-hud-widgets.md
Normal file
603
docs/feedback/FB-03-grid-snapped-hud-widgets.md
Normal 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.
|
||||
216
docs/feedback/FB-04-route-planner-map-render-fix.md
Normal file
216
docs/feedback/FB-04-route-planner-map-render-fix.md
Normal 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.
|
||||
411
docs/feedback/FB-05-closed-loop-routes.md
Normal file
411
docs/feedback/FB-05-closed-loop-routes.md
Normal 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
41
docs/feedback/README.md
Normal 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.
|
||||
Reference in New Issue
Block a user