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