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/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 '../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 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)), 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()); 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; 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, 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, ), ], ), ], ), ), ); } }