Drops the app bar, moves settings onto the map with the other controls, and takes the attribution bar off the main screen. The app bar held only the app name — which the launcher already shows — and one action, so removing it hands its height to the map rather than leaving an empty strip. The settings button joins search and recentre in the column over the map, where the rest of the controls that act on the map already live. The attribution needed checking before it could move, because it is a licence obligation rather than a layout choice. The OSMF guidelines settle it: for a browsable map the credit does not have to be permanently visible, provided "the user must still be able to find the licence information if they look for it, for example from an '(i)' button in the corner of the map or an 'About' option in a menu". Map to settings to "Fonti e licenze" is exactly that, and MapLibre's own (i) control now sits in the map's top-right corner as a second route — it had been landing underneath the new button column. Two things had to follow the bar rather than disappear with it. The radar manifest carries its own credit for the frames on screen, set by whoever published them and absent from the region config, so the Sources screen now shows it alongside the configured attributions. And the settings entry that leads there carries a subtitle saying what it holds, because "Fonti e licenze" alone does not tell a reader that this is where the licence information went. settings_screen_test asserts that entry exists and opens the screen. Removing it would be a licence breach, so it should fail the build rather than ship quietly. Verified on the emulator: no title bar, the map runs to the top with the demo notice above it, the three controls stack bottom-right without colliding with the (i), and settings shows the sources entry with its subtitle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473 lines
15 KiB
Dart
473 lines
15 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 '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<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 where the current target says it should be.
|
|
Future<void> _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<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);
|
|
await ref.read(mapTargetProvider.notifier).select(const FollowUser());
|
|
if (mounted) await _centreOn(point, zoom: placeZoom);
|
|
}
|
|
|
|
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, 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<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;
|
|
await _applyTarget(ref.read(mapTargetProvider));
|
|
}
|
|
|
|
Future<void> _onStyleLoaded() async {
|
|
final controller = _controller;
|
|
if (controller == null) return;
|
|
|
|
_overlay = RadarOverlay(map: controller, bounds: widget.region.bounds);
|
|
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()),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|