Files
Europa/Nuvolari/app/lib/features/map/radar_map_screen.dart
T
Alby96andClaude Opus 5 a686f7625e Mark the place the map is pointed at, and make following work
Choosing a town moved the camera and then left the reader to work out which of
the settlements now on screen was the one they asked for. A town has no precise
position, so the map now marks its centre: a white disc under a coloured dot,
drawn as a style layer so the native renderer keeps it pinned through every pan
and zoom. The radar frames are inserted below it, because a band of rain must
not paint over the one thing that says which town this is.

A searched municipality becomes a PointTarget rather than being thrown away as
"the whole region". It is marked, the recentre button returns to it, and the
chip names it - but it is never persisted, so looking something up no longer
costs the user the place their app opens on. That was a real defect: searching
went through select(FreeTarget), which wrote null over the stored preference.

Following the device was broken outright. MapLibre reports a camera animation
the app started exactly as it reports the user grabbing the map, so engaging
follow and then animating to the position cancelled the follow it had just
started; the resulting FreeTarget then re-framed the whole region. Follow now
moves the camera first and switches tracking on second, and FreeTarget no longer
moves the camera as a reaction to the state changing - framing the region is an
action, so panning away while following keeps the view the user panned to.

Verified on the emulator with a fix in Turin: a searched town is marked and
saveable, the app reopens on it with the marker in place, following centres on
the blue dot, and panning stops the chase without yanking the map away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:17:21 +02:00

523 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import '../../core/config/env.dart';
import '../../core/region/geo.dart';
import '../../core/region/region_config.dart';
import '../../core/region/region_repository.dart';
import '../../data/radar/radar_manifest.dart';
import '../../data/radar/radar_source.dart';
import '../../l10n/app_localizations.dart';
import '../places/location_flow.dart';
import '../places/location_service.dart';
import '../places/map_target.dart';
import '../places/places_screen.dart';
import '../places/saved_place.dart';
import '../places/saved_places.dart';
import '../places/search_screen.dart';
import '../settings/settings_screen.dart';
import '../timeline/data_age_banner.dart';
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.
class RadarMapScreen extends ConsumerWidget {
const RadarMapScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final region = ref.watch(regionConfigProvider);
// No app bar. Its title was just the app name, which the launcher already
// shows, and its one action now lives over the map with the other controls.
// The map runs full-bleed to the top; the overlaid controls carry their own
// SafeArea.
return Scaffold(
body: switch (region) {
AsyncData(:final value) => _MapBody(region: value),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${l10n.dataUnavailable}\n$error'),
),
),
_ => Center(child: Text(l10n.loading)),
},
);
}
}
class _MapBody extends ConsumerWidget {
const _MapBody({required this.region});
final RegionConfig region;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final style = MapStyle.forRegion(region);
return Column(
children: [
if (Env.isDemoMode)
SafeArea(
bottom: false,
child: MaterialBanner(
content: Text(l10n.demoModeBanner),
actions: const [SizedBox.shrink()],
),
),
Expanded(
child: _RegionMap(region: region, style: style),
),
const DataAgeBanner(),
const TimelineBar(),
],
);
}
}
class _RegionMap extends ConsumerStatefulWidget {
const _RegionMap({required this.region, required this.style});
final RegionConfig region;
final MapStyle style;
@override
ConsumerState<_RegionMap> createState() => _RegionMapState();
}
class _RegionMapState extends ConsumerState<_RegionMap> {
/// Close enough to read a town and its surroundings, wide enough to see
/// weather arriving from the next valley.
static const double placeZoom = 9.5;
MapLibreMapController? _controller;
RadarOverlay? _overlay;
PlaceMarker? _marker;
AppLifecycleListener? _lifecycle;
/// Whether the map may draw the blue dot.
///
/// Turning it on without permission makes the native layer complain, so it
/// waits until permission is actually held.
bool _locationEnabled = false;
/// Guards against a frame that finished loading after the playhead moved on.
int _requestId = 0;
@override
void initState() {
super.initState();
_lifecycle = AppLifecycleListener(
onPause: () =>
ref.read(radarTimelineProvider.notifier).onAppBackgrounded(),
);
unawaited(_syncLocationAvailability());
}
@override
void dispose() {
_lifecycle?.dispose();
unawaited(_overlay?.detach());
unawaited(_marker?.detach());
super.dispose();
}
Future<void> _syncLocationAvailability() async {
final availability = await ref.read(locationServiceProvider).availability();
if (!mounted) return;
setState(
() => _locationEnabled = availability == LocationAvailability.granted,
);
}
// --- camera ---------------------------------------------------------------
Future<void> _frameRegion() async {
final bounds = widget.region.bounds;
await _controller?.moveCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(
southwest: LatLng(bounds.south, bounds.west),
northeast: LatLng(bounds.north, bounds.east),
),
left: 16,
top: 16,
right: 16,
bottom: 16,
),
);
}
Future<void> _centreOn(GeoPoint point, {double? zoom}) async {
await _controller?.animateCamera(
zoom == null
? CameraUpdate.newLatLng(LatLng(point.latitude, point.longitude))
: CameraUpdate.newLatLngZoom(
LatLng(point.latitude, point.longitude),
zoom,
),
);
}
/// 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<void> _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():
break;
}
}
// --- actions --------------------------------------------------------------
/// Asks for permission, then starts following.
Future<void> _requestFollow() async {
final point = await requestPosition(context, ref);
if (!mounted) return;
if (point == null) {
// requestPosition has already explained why it could not.
await _syncLocationAvailability();
return;
}
if (!widget.region.bounds.contains(point)) {
// Worth following anyway — someone may be travelling into the region —
// but they should know why the map looks empty.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).locationOutsideRegion),
),
);
}
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());
}
/// Shows the whole region, as a choice rather than as a side effect.
Future<void> _selectRegion() async {
await ref.read(mapTargetProvider.notifier).select(const FreeTarget());
if (mounted) await _frameRegion();
}
Future<void> _openSearch() async {
final result = await Navigator.of(context).push<SearchResult>(
MaterialPageRoute<SearchResult>(builder: (_) => const SearchScreen()),
);
if (result == null || !mounted) return;
// A searched point is somewhere the user is looking, not somewhere they
// 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);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.label),
action: SnackBarAction(
label: l10n.mapSaveThisPoint,
onPressed: () => unawaited(_savePoint(result)),
),
),
);
}
Future<void> _savePoint(SearchResult result) async {
try {
final place = await ref
.read(savedPlacesProvider.notifier)
.add(name: result.label, point: result.point);
if (!mounted) return;
await ref.read(mapTargetProvider.notifier).select(PlaceTarget(place));
} on SavedPlaceException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(messageFor(AppLocalizations.of(context), error)),
),
);
}
}
Future<void> _openPlaces() async {
final target = _controller?.cameraPosition?.target;
final centre = target == null
? widget.region.map.center
: GeoPoint(target.longitude, target.latitude);
final chosen = await Navigator.of(context).push<SavedPlace>(
MaterialPageRoute<SavedPlace>(
builder: (_) => PlacesScreen(mapCentre: centre),
),
);
if (chosen == null || !mounted) return;
await ref.read(mapTargetProvider.notifier).select(PlaceTarget(chosen));
}
/// Puts the camera back on whatever is being followed.
Future<void> _recentre() async {
final target = ref.read(mapTargetProvider);
if (target is FollowUser) {
// Re-asserting the tracking mode is what pulls the camera back to the
// blue dot after the user has panned away from it.
await _controller?.updateMyLocationTrackingMode(
MyLocationTrackingMode.tracking,
);
return;
}
await _applyTarget(target);
}
// --- map lifecycle --------------------------------------------------------
Future<void> _onMapCreated(MapLibreMapController controller) async {
_controller = controller;
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<void> _onStyleLoaded() async {
final controller = _controller;
if (controller == null) return;
// 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);
}
Future<void> _showFrame(RadarFrame? frame) async {
final overlay = _overlay;
if (overlay == null || frame == null) return;
if (overlay.visiblePath == frame.path) return;
final request = ++_requestId;
try {
final bytes = await ref
.read(radarTimelineProvider.notifier)
.bytesFor(frame);
if (!mounted || request != _requestId) return;
await overlay.show(frame.path, bytes);
} on RadarUnavailableException {
// Hold the previous frame. A gap mid-timeline is normal while the worker
// publishes, and blanking the map is a worse answer.
}
}
@override
Widget build(BuildContext context) {
ref.listen(
radarTimelineProvider.select((state) => state.currentFrame),
(_, frame) => unawaited(_showFrame(frame)),
);
ref.listen<MapTarget>(
mapTargetProvider,
(_, target) => unawaited(_applyTarget(target)),
);
final target = ref.watch(mapTargetProvider);
final manifest = ref.watch(
radarTimelineProvider.select((state) => state.manifest),
);
final centre = widget.region.map.center;
final zoom = widget.region.map.zoom;
final bounds = widget.region.bounds;
return Stack(
children: [
Positioned.fill(
child: MapLibreMap(
styleString: widget.style.styleString,
onMapCreated: _onMapCreated,
onStyleLoadedCallback: () => unawaited(_onStyleLoaded()),
// Without this the controller's cameraPosition stays null, and
// "save the map centre" silently saves the region default instead
// of what the user is actually looking at.
trackCameraPosition: true,
initialCameraPosition: CameraPosition(
target: LatLng(centre.latitude, centre.longitude),
zoom: zoom.initial,
),
minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max),
cameraTargetBounds: CameraTargetBounds(
LatLngBounds(
southwest: LatLng(bounds.south, bounds.west),
northeast: LatLng(bounds.north, bounds.east),
),
),
// Top right: the bottom-right corner now holds our own control
// column, and the plugin's button was landing underneath it.
attributionButtonPosition: AttributionButtonPosition.topRight,
compassEnabled: false,
rotateGesturesEnabled: false,
tiltGesturesEnabled: false,
myLocationEnabled: _locationEnabled,
myLocationTrackingMode: target is FollowUser
? MyLocationTrackingMode.tracking
: MyLocationTrackingMode.none,
// Dragging the map while it is chasing the user means "let me look
// over here". The camera stops following, but what the app reopens
// on is left alone: that is a different decision.
onCameraTrackingDismissed: () =>
ref.read(mapTargetProvider.notifier).releaseFollow(),
),
),
Positioned(
left: 12,
right: 12,
top: 12,
child: SafeArea(
bottom: false,
child: Align(
alignment: Alignment.topCenter,
child: TargetSelector(
region: widget.region,
onFollowRequested: () => unawaited(_requestFollow()),
onRegionRequested: () => unawaited(_selectRegion()),
onSearch: () => unawaited(_openSearch()),
onManagePlaces: () => unawaited(_openPlaces()),
),
),
),
),
if (manifest != null)
Positioned(
left: 12,
bottom: 12,
child: _RadarLegend(legend: manifest.legend),
),
Positioned(
right: 12,
bottom: 12,
child: MapActionButtons(
onSettings: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
),
onSearch: () => unawaited(_openSearch()),
onRecentre: () => unawaited(_recentre()),
),
),
],
);
}
}
/// The colour ramp for the frames currently on screen.
///
/// Built from the manifest rather than from a constant: the worker chose these
/// colours when it rendered the PNGs, so anything hardcoded here could drift
/// and mislabel the intensities the reader is looking at.
class _RadarLegend extends StatelessWidget {
const _RadarLegend({required this.legend});
final RadarLegend legend;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.radarLegendUnit(legend.unit),
style: theme.textTheme.labelSmall,
),
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final stop in legend.stops)
Container(width: 14, height: 12, color: stop.color),
],
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${legend.stops.first.value.toInt()}',
style: theme.textTheme.labelSmall,
),
SizedBox(width: 14.0 * legend.stops.length - 28),
Text(
'${legend.stops.last.value.toInt()}',
style: theme.textTheme.labelSmall,
),
],
),
],
),
),
);
}
}