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 '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; 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()); super.dispose(); } Future _syncLocationAvailability() async { final availability = await ref.read(locationServiceProvider).availability(); if (!mounted) return; setState( () => _locationEnabled = availability == LocationAvailability.granted, ); } // --- camera --------------------------------------------------------------- Future _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 _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 where the current target says it should be. Future _applyTarget(MapTarget target) async { 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 FreeTarget(): await _frameRegion(); } } // --- actions -------------------------------------------------------------- /// Asks for permission, then starts following. Future _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); await ref.read(mapTargetProvider.notifier).select(const FollowUser()); if (mounted) await _centreOn(point, zoom: placeZoom); } Future _openSearch() async { final result = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SearchScreen()), ); 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); 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 _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 _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( MaterialPageRoute( 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 _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 _onMapCreated(MapLibreMapController controller) async { _controller = controller; await _applyTarget(ref.read(mapTargetProvider)); } Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; _overlay = RadarOverlay(map: controller, bounds: widget.region.bounds); await _showFrame(ref.read(radarTimelineProvider).currentFrame); } Future _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( 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()), 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(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, ), ], ), ], ), ), ); } }