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/places_screen.dart'; import '../places/saved_place.dart'; import '../places/saved_places.dart'; import '../settings/settings_screen.dart'; import '../timeline/data_age_banner.dart'; import '../timeline/radar_timeline.dart'; import '../timeline/timeline_bar.dart'; import 'attribution_bar.dart'; import 'map_style.dart'; import 'radar_overlay.dart'; /// The live map controller, or null before the map is created. /// /// Held in a provider because the app bar sits above the map in the widget tree /// and still needs to move the camera. Cleared on dispose so a stale controller /// is never used after the map is gone. class MapControllerHolder extends Notifier { @override MapLibreMapController? build() => null; void attach(MapLibreMapController controller) => state = controller; void detach() => state = null; } final mapControllerProvider = NotifierProvider( MapControllerHolder.new, ); /// Moves the map onto [point], keeping the current zoom. Future _centreOn(WidgetRef ref, GeoPoint point) async { final controller = ref.read(mapControllerProvider); if (controller == null) return; await controller.animateCamera( CameraUpdate.newLatLng(LatLng(point.latitude, point.longitude)), ); } /// Centres the map on the device position. class _LocateAction extends ConsumerWidget { const _LocateAction(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); return IconButton( tooltip: l10n.locateMe, icon: const Icon(Icons.my_location), onPressed: () async { final point = await requestPosition(context, ref); if (point == null || !context.mounted) return; // The app only has data for this region. Saying so beats silently // refusing to move, and beats moving somewhere with an empty map. final region = await ref.read(regionConfigProvider.future); if (!context.mounted) return; if (!region.bounds.contains(point)) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.locationOutsideRegion))); return; } ref.read(activePlaceProvider.notifier).select(null); await _centreOn(ref, point); }, ); } } /// Opens the saved places list and centres on whatever comes back. class _PlacesAction extends ConsumerWidget { const _PlacesAction(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); return IconButton( tooltip: l10n.placesOpen, icon: const Icon(Icons.place_outlined), onPressed: () async { final controller = ref.read(mapControllerProvider); final region = await ref.read(regionConfigProvider.future); if (!context.mounted) return; final target = controller?.cameraPosition?.target; final centre = target == null ? region.map.center : GeoPoint(target.longitude, target.latitude); final chosen = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlacesScreen(mapCentre: centre), ), ); if (chosen == null) return; ref.read(activePlaceProvider.notifier).select(chosen); await _centreOn(ref, chosen.point); }, ); } } class _SettingsAction extends StatelessWidget { const _SettingsAction(); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return IconButton( tooltip: l10n.settingsOpen, icon: const Icon(Icons.settings_outlined), onPressed: () => Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const SettingsScreen())), ); } } /// 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); return Scaffold( appBar: AppBar( title: Text(l10n.appTitle), actions: const [_LocateAction(), _PlacesAction(), _SettingsAction()], ), body: switch (region) { AsyncData(:final value) => _MapWithAttribution(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 _MapWithAttribution extends ConsumerWidget { const _MapWithAttribution({required this.region}); final RegionConfig region; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final style = MapStyle.forRegion(region); final manifest = ref.watch( radarTimelineProvider.select((state) => state.manifest), ); return Column( children: [ if (Env.isDemoMode) MaterialBanner( content: Text(l10n.demoModeBanner), actions: const [SizedBox.shrink()], ), Expanded( child: Stack( children: [ Positioned.fill( child: _RegionMap(region: region, style: style), ), if (manifest != null) Positioned( left: 12, bottom: 12, child: _RadarLegend(legend: manifest.legend), ), ], ), ), const DataAgeBanner(), const TimelineBar(), // Outside the map rather than floating over it, so the credit can never // be occluded by a map control. SafeArea keeps it clear of the system // gesture bar as well — a credit sitting behind the navigation pill is // a credit that is not being displayed. SafeArea( top: false, child: AttributionBar( region: region, activeSourceIds: style.attributionIds, showBaseMapNotice: style.kind == BaseMapKind.offlineFallback, // The manifest carries the credit for the frames it indexes, which // is the whole point of it travelling with the data: whoever // published these frames says here who to credit for them. additionalCredits: [ if (manifest != null) manifest.attribution, ], ), ), ], ); } } 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> { MapLibreMapController? _controller; RadarOverlay? _overlay; AppLifecycleListener? _lifecycle; /// Guards against a frame that finished loading after the playhead moved on, /// which would briefly show the wrong image. int _requestId = 0; @override void initState() { super.initState(); // Playback runs a timer and pushes a texture several times a second. // Behind another app there is no one watching, so it is pure battery. _lifecycle = AppLifecycleListener( onPause: () => ref.read(radarTimelineProvider.notifier).onAppBackgrounded(), ); } @override void dispose() { _lifecycle?.dispose(); unawaited(_overlay?.detach()); // The controller dies with the platform view; leaving it in the provider // would let the app bar drive a dead map. Future.microtask( () => ref.read(mapControllerProvider.notifier).detach(), ).ignore(); super.dispose(); } /// Frames the whole region once the map is ready. /// /// The configured initial zoom is only a starting point: a single zoom number /// cannot fit the region on both a small phone and a tablet, and on a 411dp /// screen zoom 7.2 puts the viewport entirely inside the region, so the user /// sees a flat expanse with no border or outline to orient by. Fitting the /// bounds works on every screen size. Future _onMapCreated(MapLibreMapController controller) async { _controller = controller; ref.read(mapControllerProvider.notifier).attach(controller); 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, ), ); } /// Layers can only be added once the style exists, and the style is reloaded /// whenever it changes, so the overlay is rebuilt here rather than in /// [_onMapCreated]. 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); // Someone scrubbed while this was loading; that newer frame wins. 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 would be a worse answer than showing // the neighbouring minute. } } @override Widget build(BuildContext context) { ref.listen( radarTimelineProvider.select((state) => state.currentFrame), (_, frame) => unawaited(_showFrame(frame)), ); final center = widget.region.map.center; final zoom = widget.region.map.zoom; final bounds = widget.region.bounds; return MapLibreMap( styleString: widget.style.styleString, onMapCreated: _onMapCreated, // 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, onStyleLoadedCallback: () => unawaited(_onStyleLoaded()), initialCameraPosition: CameraPosition( target: LatLng(center.latitude, center.longitude), zoom: zoom.initial, ), minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max), // Panning is confined to the region: this app has data for Piedmont and // nowhere else, so letting the user drift away would only ever show an // empty map. cameraTargetBounds: CameraTargetBounds( LatLngBounds( southwest: LatLng(bounds.south, bounds.west), northeast: LatLng(bounds.north, bounds.east), ), ), // Kept enabled on top of our own attribution bar: some tile providers // require the plugin's own attribution control, and a duplicated credit // is harmless where a missing one is a licence breach. attributionButtonPosition: AttributionButtonPosition.bottomRight, compassEnabled: false, rotateGesturesEnabled: false, tiltGesturesEnabled: false, myLocationEnabled: false, ); } } /// 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, ), ], ), ], ), ), ); } }