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 '../../l10n/app_localizations.dart'; import 'attribution_bar.dart'; import 'map_style.dart'; /// The radar map. /// /// Milestone 2 renders the base map, frames it on the region and keeps the /// attribution visible. The radar image layers and the timeline arrive in /// milestone 3. 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 StatelessWidget { const _MapWithAttribution({required this.region}); final RegionConfig region; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final style = MapStyle.forRegion(region); return Column( children: [ if (Env.isDemoMode) MaterialBanner( content: Text(l10n.demoModeBanner), actions: const [SizedBox.shrink()], ), Expanded( child: _RegionMap(region: region, style: style), ), // 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, ), ), ], ); } } class _RegionMap extends StatelessWidget { const _RegionMap({required this.region, required this.style}); final RegionConfig region; final MapStyle style; /// 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 coastline, border or outline to orient by. /// Fitting the bounds works on every screen size. Future _frameRegion(MapLibreMapController controller) async { final bounds = 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, ), ); } @override Widget build(BuildContext context) { final center = region.map.center; final zoom = region.map.zoom; final bounds = region.bounds; return MapLibreMap( styleString: style.styleString, onMapCreated: _frameRegion, 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, ); } }