diff --git a/Nuvolari/app/lib/features/map/map_overlay_controls.dart b/Nuvolari/app/lib/features/map/map_overlay_controls.dart index 960d1a7..113cc66 100644 --- a/Nuvolari/app/lib/features/map/map_overlay_controls.dart +++ b/Nuvolari/app/lib/features/map/map_overlay_controls.dart @@ -44,6 +44,7 @@ class TargetSelector extends ConsumerWidget { const TargetSelector({ required this.region, required this.onFollowRequested, + required this.onRegionRequested, required this.onSearch, required this.onManagePlaces, super.key, @@ -56,6 +57,12 @@ class TargetSelector extends ConsumerWidget { /// owns the map handles it and only then changes the target. final VoidCallback onFollowRequested; + /// Framing the region is a camera move, and camera moves belong to the screen + /// that owns the map. Selecting the target alone cannot do it: the map lands + /// in the same state when the user simply drags away from what it was + /// following, and that must not throw their pan away. + final VoidCallback onRegionRequested; + final VoidCallback onSearch; final VoidCallback onManagePlaces; @@ -69,12 +76,16 @@ class TargetSelector extends ConsumerWidget { final label = switch (target) { FollowUser() => l10n.targetFollowMe, PlaceTarget(:final place) => place.name, + PointTarget(:final label) => label, FreeTarget() => l10n.targetWholeRegion(region.displayName), }; + // A searched point gets the outlined pin: it is on the map like a saved + // place, but nothing has been kept, and the chip should not claim otherwise. final icon = switch (target) { FollowUser() => Icons.my_location, PlaceTarget() => Icons.place, + PointTarget() => Icons.place_outlined, FreeTarget() => Icons.map_outlined, }; @@ -90,9 +101,7 @@ class TargetSelector extends ConsumerWidget { case _ChooseFollow(): onFollowRequested(); case _ChooseRegion(): - await ref - .read(mapTargetProvider.notifier) - .select(const FreeTarget()); + onRegionRequested(); case _ChoosePlace(:final place): await ref .read(mapTargetProvider.notifier) @@ -192,6 +201,7 @@ class MapActionButtons extends ConsumerWidget { final recentreIcon = switch (target) { FollowUser() => Icons.my_location, PlaceTarget() => Icons.place, + PointTarget() => Icons.place_outlined, FreeTarget() => Icons.zoom_out_map, }; diff --git a/Nuvolari/app/lib/features/map/place_marker.dart b/Nuvolari/app/lib/features/map/place_marker.dart new file mode 100644 index 0000000..2da6f47 --- /dev/null +++ b/Nuvolari/app/lib/features/map/place_marker.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../../core/region/geo.dart'; + +/// Marks the point the map is pointed at. +/// +/// The blue dot answers "where am I". This answers "where is the place I +/// chose": without it, picking a town moves the camera and then leaves the +/// reader to guess which of the settlements now on screen was the one they +/// asked for. A town has no precise position of its own, so what gets marked is +/// its centre. +/// +/// Drawn as a style layer rather than as a widget stacked over the map. The +/// native renderer keeps a layer pinned to its coordinates through every pan, +/// zoom and camera animation; a widget would have to be repositioned from Dart +/// on every frame, one asynchronous coordinate conversion at a time, and would +/// visibly swim behind the map while it moved. +class PlaceMarker { + PlaceMarker({required this.map, required this.color}); + + static const String sourceId = 'nuvolari-place-marker'; + + /// The lower of the two layers. + /// + /// Public because layer order is a property of the whole map, not of this + /// class: the radar frames are added *below* this id, which is what stops a + /// band of rain from being painted over the marker. + static const String haloLayerId = 'nuvolari-place-marker-halo'; + static const String dotLayerId = 'nuvolari-place-marker-dot'; + + final MapLibreMapController map; + + /// Fill colour of the dot. The white ring underneath carries the contrast, so + /// this only has to identify the app. + final Color color; + + bool _attached = false; + GeoPoint? _point; + + bool get isAttached => _attached; + + /// The point currently marked, or null when nothing is. + GeoPoint? get point => _point; + + /// Serialises platform-channel work, so a target change that lands while the + /// previous one is still in flight cannot apply out of order. + Future _pending = Future.value(); + + /// Creates the source and layers, marking nothing yet. + /// + /// Called before the radar overlay attaches so that the marker's layers are + /// already in the style for the frames to be inserted underneath. + Future attach() => _serialise(() async { + if (_attached) return; + + await map.addGeoJsonSource(sourceId, _featureCollection(null)); + + // A white disc under a coloured dot. The radar palette runs through blues + // and greens, and a marker in any single colour disappears into some part + // of it; a ring of the background colour reads against all of them. + await map.addCircleLayer( + sourceId, + haloLayerId, + const CircleLayerProperties( + circleRadius: 12.0, + circleColor: '#FFFFFF', + circleOpacity: 0.95, + ), + enableInteraction: false, + ); + await map.addCircleLayer( + sourceId, + dotLayerId, + CircleLayerProperties(circleRadius: 6.0, circleColor: _hex(color)), + enableInteraction: false, + ); + + _attached = true; + _point = null; + }); + + /// Moves the marker to [point], or clears it when [point] is null. + Future show(GeoPoint? point) => _serialise(() async { + if (!_attached) return; + if (point == _point) return; + + // Emptying the feature collection hides the marker without removing the + // layers, which keeps them above the radar for the next time it is shown. + await map.setGeoJsonSource(sourceId, _featureCollection(point)); + _point = point; + }); + + /// Removes the layers and the source. + Future detach() => _serialise(() async { + if (!_attached) return; + + // Layers reference the source, so they go first. Each removal tolerates + // failing: the style may already have dropped them on a reload, and the + // goal is to end up with nothing rather than to prove what was there. + for (final layer in [dotLayerId, haloLayerId]) { + try { + await map.removeLayer(layer); + } on Object { + continue; + } + } + try { + await map.removeSource(sourceId); + } on Object { + // Nothing left to do; the state below is reset either way. + } + + _attached = false; + _point = null; + }); + + Map _featureCollection(GeoPoint? point) => { + 'type': 'FeatureCollection', + 'features': >[ + if (point != null) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [point.longitude, point.latitude], + }, + 'properties': {}, + }, + ], + }; + + Future _serialise(Future Function() action) { + final next = _pending.then((_) => action()); + // Swallowed for the chain's sake only: one platform-channel failure must + // not deadlock every later update queued behind it. + _pending = next.catchError((Object _) {}); + return next; + } +} + +/// The `#RRGGBB` string the style expects. +String _hex(Color color) { + final rgb = color.toARGB32() & 0xFFFFFF; + return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; +} diff --git a/Nuvolari/app/lib/features/map/radar_map_screen.dart b/Nuvolari/app/lib/features/map/radar_map_screen.dart index c17a454..16c1bdd 100644 --- a/Nuvolari/app/lib/features/map/radar_map_screen.dart +++ b/Nuvolari/app/lib/features/map/radar_map_screen.dart @@ -24,6 +24,7 @@ import '../timeline/radar_timeline.dart'; import '../timeline/timeline_bar.dart'; import 'map_overlay_controls.dart'; import 'map_style.dart'; +import 'place_marker.dart'; import 'radar_overlay.dart'; /// The radar map: base map, animated precipitation overlay, timeline. @@ -101,6 +102,7 @@ class _RegionMapState extends ConsumerState<_RegionMap> { MapLibreMapController? _controller; RadarOverlay? _overlay; + PlaceMarker? _marker; AppLifecycleListener? _lifecycle; /// Whether the map may draw the blue dot. @@ -126,6 +128,7 @@ class _RegionMapState extends ConsumerState<_RegionMap> { void dispose() { _lifecycle?.dispose(); unawaited(_overlay?.detach()); + unawaited(_marker?.detach()); super.dispose(); } @@ -166,16 +169,26 @@ class _RegionMapState extends ConsumerState<_RegionMap> { ); } - /// Puts the camera where the current target says it should be. + /// Puts the camera and the marker where the current target says they belong. + /// + /// Deliberately leaves the camera alone for [FreeTarget]. That state is also + /// what following degrades to the instant the user drags the map, and + /// re-framing the whole region there would throw away the very pan they just + /// made. Framing the region is an action - [_selectRegion], the opening view, + /// the recentre button - never a reaction to the state changing. Future _applyTarget(MapTarget target) async { + await _marker?.show(target.markedPoint); + switch (target) { case FollowUser(): // The native tracking mode does the following; nothing to animate. break; case PlaceTarget(:final place): await _centreOn(place.point, zoom: placeZoom); + case PointTarget(:final point): + await _centreOn(point, zoom: placeZoom); case FreeTarget(): - await _frameRegion(); + break; } } @@ -203,8 +216,21 @@ class _RegionMapState extends ConsumerState<_RegionMap> { } setState(() => _locationEnabled = true); + + // Move the camera first, switch tracking on second. MapLibre reports a + // camera animation the app started exactly the way it reports the user + // grabbing the map - as tracking dismissed - so engaging follow and then + // animating to the position drops straight back out of following. Doing it + // in this order leaves nothing to dismiss. + await _centreOn(point, zoom: placeZoom); + if (!mounted) return; await ref.read(mapTargetProvider.notifier).select(const FollowUser()); - if (mounted) await _centreOn(point, zoom: placeZoom); + } + + /// Shows the whole region, as a choice rather than as a side effect. + Future _selectRegion() async { + await ref.read(mapTargetProvider.notifier).select(const FreeTarget()); + if (mounted) await _frameRegion(); } Future _openSearch() async { @@ -214,10 +240,14 @@ class _RegionMapState extends ConsumerState<_RegionMap> { if (result == null || !mounted) return; // A searched point is somewhere the user is looking, not somewhere they - // have committed to, so it moves the camera without becoming the target. - // Saving it is one tap away if they want it to stick. - await ref.read(mapTargetProvider.notifier).select(const FreeTarget()); - await _centreOn(result.point, zoom: placeZoom); + // have committed to: it gets marked and centred, but what the app reopens + // on is left alone. Saving it is one tap away if they want it to stick. + ref + .read(mapTargetProvider.notifier) + .previewPoint(point: result.point, label: result.label); + // Applied here rather than left to the listener, which does not fire when + // the same point is searched twice running. + await _applyTarget(ref.read(mapTargetProvider)); if (!mounted) return; final l10n = AppLocalizations.of(context); @@ -282,14 +312,33 @@ class _RegionMapState extends ConsumerState<_RegionMap> { Future _onMapCreated(MapLibreMapController controller) async { _controller = controller; - await _applyTarget(ref.read(mapTargetProvider)); + + final target = ref.read(mapTargetProvider); + await _applyTarget(target); + // The opening view when there is no place to open on. Nothing else frames + // the region now that a state change no longer does. + if (target is FreeTarget) await _frameRegion(); } Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; - _overlay = RadarOverlay(map: controller, bounds: widget.region.bounds); + // The marker's layers are created first and the radar is inserted below + // them, so the frames can never cover the marker however late they arrive. + final marker = PlaceMarker( + map: controller, + color: Theme.of(context).colorScheme.primary, + ); + _marker = marker; + await marker.attach(); + await marker.show(ref.read(mapTargetProvider).markedPoint); + + _overlay = RadarOverlay( + map: controller, + bounds: widget.region.bounds, + belowLayerId: PlaceMarker.haloLayerId, + ); await _showFrame(ref.read(radarTimelineProvider).currentFrame); } @@ -381,6 +430,7 @@ class _RegionMapState extends ConsumerState<_RegionMap> { child: TargetSelector( region: widget.region, onFollowRequested: () => unawaited(_requestFollow()), + onRegionRequested: () => unawaited(_selectRegion()), onSearch: () => unawaited(_openSearch()), onManagePlaces: () => unawaited(_openPlaces()), ), diff --git a/Nuvolari/app/lib/features/map/radar_overlay.dart b/Nuvolari/app/lib/features/map/radar_overlay.dart index 391fb62..68bdd86 100644 --- a/Nuvolari/app/lib/features/map/radar_overlay.dart +++ b/Nuvolari/app/lib/features/map/radar_overlay.dart @@ -14,13 +14,16 @@ import '../../core/region/geo.dart'; /// GPU memory at once, and twenty 512×512 RGBA frames is about 80 MB. Two /// buffers cost the same whether the timeline holds six frames or sixty. class RadarOverlay { - RadarOverlay({required this.map, required GeoBounds bounds}) - : _quad = LatLngQuad( - topLeft: LatLng(bounds.north, bounds.west), - topRight: LatLng(bounds.north, bounds.east), - bottomRight: LatLng(bounds.south, bounds.east), - bottomLeft: LatLng(bounds.south, bounds.west), - ); + RadarOverlay({ + required this.map, + required GeoBounds bounds, + this.belowLayerId, + }) : _quad = LatLngQuad( + topLeft: LatLng(bounds.north, bounds.west), + topRight: LatLng(bounds.north, bounds.east), + bottomRight: LatLng(bounds.south, bounds.east), + bottomLeft: LatLng(bounds.south, bounds.west), + ); static const String _sourceA = 'nuvolari-radar-a'; static const String _sourceB = 'nuvolari-radar-b'; @@ -29,6 +32,12 @@ class RadarOverlay { final MapLibreMapController map; + /// Layer to insert the frames beneath, or null to put them on top. + /// + /// The place marker sits above the weather: a band of rain over the town the + /// user just chose must not hide the thing that says which town it was. + final String? belowLayerId; + /// Corners of the published frames. The worker renders in EPSG:3857 cropped /// to exactly this box, so the quad is exact and nothing is warped client /// side. @@ -57,8 +66,17 @@ class RadarOverlay { await map.addImageSource(_sourceA, bytes, _quad); await map.addImageSource(_sourceB, bytes, _quad); - await map.addImageLayer(_layerA, _sourceA); - await map.addImageLayer(_layerB, _sourceB); + + final below = belowLayerId; + if (below == null) { + await map.addImageLayer(_layerA, _sourceA); + await map.addImageLayer(_layerB, _sourceB); + } else { + // Both go under the same layer, so which of the two ends up on top of the + // other does not matter: only one is ever visible. + await map.addImageLayerBelow(_layerA, _sourceA, below); + await map.addImageLayerBelow(_layerB, _sourceB, below); + } await map.setLayerVisibility(_layerB, false); _attached = true; diff --git a/Nuvolari/app/lib/features/places/map_target.dart b/Nuvolari/app/lib/features/places/map_target.dart index e714584..e50cff2 100644 --- a/Nuvolari/app/lib/features/places/map_target.dart +++ b/Nuvolari/app/lib/features/places/map_target.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/region/geo.dart'; import 'saved_place.dart'; import 'saved_places.dart'; @@ -13,6 +14,12 @@ sealed class MapTarget { /// The value persisted as the preferred target, or null if it is not a /// sensible thing to reopen on. String? get storageValue; + + /// The point the map draws a marker on for this target, or null for none. + /// + /// On the interface rather than in the map widget so that adding a target + /// without deciding what it looks like on the map stops compiling. + GeoPoint? get markedPoint; } /// Follow the device: the blue dot stays centred until the user pans away. @@ -22,6 +29,11 @@ class FollowUser extends MapTarget { @override String? get storageValue => 'follow'; + /// None: the blue dot marks the real position, and a pin on top of it would + /// say the same thing twice, less precisely. + @override + GeoPoint? get markedPoint => null; + @override bool operator ==(Object other) => other is FollowUser; @@ -38,6 +50,9 @@ class PlaceTarget extends MapTarget { @override String? get storageValue => 'place:${place.id}'; + @override + GeoPoint? get markedPoint => place.point; + @override bool operator ==(Object other) => other is PlaceTarget && other.place.id == place.id; @@ -46,6 +61,34 @@ class PlaceTarget extends MapTarget { int get hashCode => place.id.hashCode; } +/// A point the user has gone to look at without keeping it. +/// +/// What a search produces. The map centres on it and marks it, and the recentre +/// button comes back to it, but it is never persisted as what the app reopens +/// on: looking something up is not the same commitment as saving a place, and +/// the place the user chose to open on should survive a search. +class PointTarget extends MapTarget { + const PointTarget({required this.point, required this.label}); + + final GeoPoint point; + + /// What to call it on screen, and the name it takes if the user saves it. + final String label; + + @override + String? get storageValue => null; + + @override + GeoPoint? get markedPoint => point; + + @override + bool operator ==(Object other) => + other is PointTarget && other.point == point && other.label == label; + + @override + int get hashCode => Object.hash(point, label); +} + /// The whole region, and wherever the user has panned to. /// /// Also what following degrades to the moment the user drags the map: the @@ -57,6 +100,9 @@ class FreeTarget extends MapTarget { @override String? get storageValue => null; + @override + GeoPoint? get markedPoint => null; + @override bool operator ==(Object other) => other is FreeTarget; @@ -157,11 +203,23 @@ class MapTargetController extends Notifier { } /// Selects [target] and remembers it as what to reopen on. + /// + /// Not for [PointTarget]: a searched point is transient, and persisting it + /// would clear the place the user chose to open on. Use [previewPoint]. Future select(MapTarget target) async { + assert( + target is! PointTarget, + 'a searched point is transient - use previewPoint', + ); state = target; await ref.read(preferredTargetStoreProvider).save(target.storageValue); } + /// Points the map at [point] without changing what it reopens on. + void previewPoint({required GeoPoint point, required String label}) { + state = PointTarget(point: point, label: label); + } + /// Drops out of following without changing what the app reopens on. /// /// Called when the user drags the map while it is chasing them: they want to diff --git a/Nuvolari/app/test/features/places/map_target_test.dart b/Nuvolari/app/test/features/places/map_target_test.dart new file mode 100644 index 0000000..a552c2e --- /dev/null +++ b/Nuvolari/app/test/features/places/map_target_test.dart @@ -0,0 +1,176 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/core/region/geo.dart'; +import 'package:nuvolari/core/region/region_config.dart'; +import 'package:nuvolari/core/region/region_repository.dart'; +import 'package:nuvolari/features/places/map_target.dart'; +import 'package:nuvolari/features/places/places_repository.dart'; +import 'package:nuvolari/features/places/saved_place.dart'; +import 'package:nuvolari/features/places/saved_places.dart'; + +const torino = GeoPoint(7.686, 45.070); +const cuneo = GeoPoint(7.549, 44.393); + +SavedPlace place(String id, String name, GeoPoint point) => + SavedPlace(id: id, name: name, point: point, createdAt: DateTime.utc(2026)); + +({ProviderContainer container, InMemoryPreferredTargetStore store}) harness( + RegionConfig region, { + List? places, + String? preferred, +}) { + final store = InMemoryPreferredTargetStore(preferred); + final container = ProviderContainer( + overrides: [ + regionConfigProvider.overrideWith((ref) async => region), + placesStoreProvider.overrideWithValue(InMemoryPlacesStore(places)), + preferredTargetStoreProvider.overrideWithValue(store), + ], + ); + addTearDown(container.dispose); + return (container: container, store: store); +} + +void main() { + late RegionConfig region; + + setUpAll(() { + region = RegionConfig.parse( + File('assets/regions/piemonte.json').readAsStringSync(), + ); + }); + + group('MapTargetController', () { + test('selecting a place remembers it as what to reopen on', () async { + final casa = place('a', 'Casa', torino); + final h = harness(region, places: [casa]); + await h.container.read(savedPlacesProvider.future); + + await h.container + .read(mapTargetProvider.notifier) + .select(PlaceTarget(casa)); + + expect(h.container.read(mapTargetProvider), PlaceTarget(casa)); + expect(await h.store.load(), 'place:a'); + }); + + test('choosing the whole region clears the stored preference', () async { + final h = harness(region, preferred: 'follow'); + await h.container.read(savedPlacesProvider.future); + + await h.container + .read(mapTargetProvider.notifier) + .select(const FreeTarget()); + + expect(await h.store.load(), isNull); + }); + + // Searching is looking, not deciding. Before PointTarget existed a search + // went through select(FreeTarget), which wrote null over the preference and + // silently cost the user the place their app opened on. + test('previewing a searched point leaves the preference alone', () async { + final casa = place('a', 'Casa', torino); + final h = harness(region, places: [casa], preferred: 'place:a'); + await h.container.read(savedPlacesProvider.future); + + h.container + .read(mapTargetProvider.notifier) + .previewPoint(point: cuneo, label: 'Cuneo'); + + expect( + h.container.read(mapTargetProvider), + const PointTarget(point: cuneo, label: 'Cuneo'), + ); + expect( + await h.store.load(), + 'place:a', + reason: 'a search must not change what the app reopens on', + ); + }); + + test('a previewed point is never persisted, even if asked', () async { + final h = harness(region); + await h.container.read(savedPlacesProvider.future); + + expect( + () => h.container + .read(mapTargetProvider.notifier) + .select(const PointTarget(point: cuneo, label: 'Cuneo')), + throwsA(isA()), + ); + }); + + // What the map marks is the whole point of the target for anything that is + // not the user themselves: a town has no precise position, so its centre is + // the honest answer. + test('says what the map should mark', () { + final casa = place('a', 'Casa', torino); + + expect(PlaceTarget(casa).markedPoint, torino); + expect( + const PointTarget(point: cuneo, label: 'Cuneo').markedPoint, + cuneo, + ); + expect(const FreeTarget().markedPoint, isNull); + expect( + const FollowUser().markedPoint, + isNull, + reason: 'the blue dot marks the user, not a pin', + ); + }); + + test('points differ by coordinates and by label', () { + const at = PointTarget(point: cuneo, label: 'Cuneo'); + + expect(at, const PointTarget(point: cuneo, label: 'Cuneo')); + expect(at, isNot(const PointTarget(point: torino, label: 'Cuneo'))); + expect(at, isNot(const PointTarget(point: cuneo, label: 'Cuneo Centro'))); + }); + + test('restores the preferred place once places have loaded', () async { + final casa = place('a', 'Casa', torino); + final h = harness(region, places: [casa], preferred: 'place:a'); + await h.container.read(savedPlacesProvider.future); + + // Reading it is what builds it, and the restore starts from there: it + // reads the store, so it settles a microtask later, not immediately. + expect(h.container.read(mapTargetProvider), const FreeTarget()); + await Future.delayed(Duration.zero); + + expect(h.container.read(mapTargetProvider), PlaceTarget(casa)); + }); + + test('forgets a preferred place that no longer exists', () async { + final h = harness(region, places: [], preferred: 'place:gone'); + await h.container.read(savedPlacesProvider.future); + h.container.read(mapTargetProvider); + await Future.delayed(Duration.zero); + + expect(h.container.read(mapTargetProvider), const FreeTarget()); + expect(await h.store.load(), isNull); + }); + + test( + 'panning while following stops the chase but keeps the choice', + () async { + final h = harness(region); + await h.container.read(savedPlacesProvider.future); + await h.container + .read(mapTargetProvider.notifier) + .select(const FollowUser()); + + h.container.read(mapTargetProvider.notifier).releaseFollow(); + + expect(h.container.read(mapTargetProvider), const FreeTarget()); + expect( + await h.store.load(), + 'follow', + reason: + 'looking elsewhere now is not changing your mind for next time', + ); + }, + ); + }); +} diff --git a/Nuvolari/docs/architecture.md b/Nuvolari/docs/architecture.md index f46a67b..aad2e62 100644 --- a/Nuvolari/docs/architecture.md +++ b/Nuvolari/docs/architecture.md @@ -57,6 +57,40 @@ lib/ Dependencies point inwards: `features` depends on `data`, `data` depends on `core`, `core` depends on nothing in the app. A feature never imports another feature. +## What the map draws, and in what order + +Three things sit on the base map, and the order matters: + +``` +place marker (GeoJSON source + two circle layers) <- always on top +radar frames (two image layers, double buffered) +base map (OpenFreeMap vector tiles) +``` + +`PlaceMarker` creates its layers when the style finishes loading, and `RadarOverlay` +inserts its frames *below* them with `addImageLayerBelow`. Order is not left to whichever +attaches first: the radar attaches when the first frame arrives, which can be before or +after the user picks a place, and a band of rain painted over the marker would hide the +one thing on screen that says which town was chosen. + +The marker is a style layer rather than a widget in the `Stack` over the map. The native +renderer keeps a layer pinned to its coordinates through every pan and zoom; a widget +would need repositioning from Dart on each camera frame, one asynchronous coordinate +conversion at a time, and would swim behind the map while it moved. + +### Following the device + +Two behaviours of MapLibre shape how `FollowUser` works: + +- **A camera animation the app starts is reported as tracking dismissed**, exactly like + the user grabbing the map. So follow is engaged by moving the camera *first* and + switching tracking on *second*. Done the other way round, the animation that brings the + camera to the user cancels the following it was meant to start. +- **Dropping out of following lands in `FreeTarget`**, which is also the state the user + reaches by choosing "the whole region". So `FreeTarget` must never move the camera as a + reaction to the state changing — that would throw away the pan the user just made. + Framing the region is an action: the opening view, the menu entry, the recentre button. + ## The adapter seam ```dart