Add municipality and coordinate search, and a map target selector
Puts the controls that change what the map is looking at over the map itself, where they act, and gives them something to search. Search is one field, not two. A string either parses as coordinates or it does not, and the answer is obvious from the text, so making the user declare up front which kind of thing they are looking for would be asking them to do the program's job. Coordinates accept decimal and degrees-minutes-seconds, comma or space separated, with hemisphere letters — including the Italian O for ovest, because someone reading an Italian map will type it and silently reading it as east would put them the wrong side of Greenwich. The 1180 Piedmont municipalities are bundled rather than geocoded online. The app is region-scoped, so a list of one region's towns is small enough to ship (100 KB) and beats a geocoder on every axis that matters: instant, offline, no API key, no rate limit, and it cannot return a result somewhere the app has no radar for. Matching folds accents, so "aglie" finds "Agliè", and prefix matches outrank substring ones — typing "tor" should surface Torino, not the first alphabetical name that happens to contain those letters. tool/generate_places.py derives the list from Istat boundary shapefiles (CC BY 4.0). Two properties of that file cost time and are now written down: the geometry is UTM 32N rather than degrees, and the DBF is UTF-8 despite one bilingual Friulian record that makes strict cp1252 fail. A terminal renders utf-8 and latin-1 output identically, so the encoding cannot be settled by looking at printed text — it took dumping codepoints. A guard in the generator and a test against the shipped asset both check for mojibake now, and the guard caught a real mistake the moment it was written. The target selector switches between following the device, a saved place, and the whole region, and the selection doubles as what the app reopens on: "the place I marked" and "what I see when I open the app" are one idea to the person using it. Dragging the map while it is following stops the camera chasing them but leaves that preference alone, because looking somewhere else now is not the same as changing their mind about next launch. The position marker and the following are MapLibre's own, driven by onCameraTrackingDismissed, so there is no second location stream to keep in step with the map. A test asserts that all 1180 municipalities fall inside the region bounds, which is what actually validates the UTM-to-degrees conversion end to end. Verified on the emulator: the dropdown lists follow, region and both saved places; "aglie" finds Agliè; "44.3841 7.5426" offers the coordinate jump and lands on Cuneo at town-reading zoom; selecting follow moves the map to the device position with the blue dot on it and changes the recentre button to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/region/region_config.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../places/map_target.dart';
|
||||
import '../places/saved_place.dart';
|
||||
import '../places/saved_places.dart';
|
||||
|
||||
/// Identifies an entry in the target menu.
|
||||
///
|
||||
/// A sealed value rather than a raw string so a typo cannot silently become a
|
||||
/// menu entry that does nothing.
|
||||
sealed class _TargetChoice {
|
||||
const _TargetChoice();
|
||||
}
|
||||
|
||||
class _ChooseFollow extends _TargetChoice {
|
||||
const _ChooseFollow();
|
||||
}
|
||||
|
||||
class _ChooseRegion extends _TargetChoice {
|
||||
const _ChooseRegion();
|
||||
}
|
||||
|
||||
class _ChoosePlace extends _TargetChoice {
|
||||
const _ChoosePlace(this.place);
|
||||
final SavedPlace place;
|
||||
}
|
||||
|
||||
class _ChooseSearch extends _TargetChoice {
|
||||
const _ChooseSearch();
|
||||
}
|
||||
|
||||
class _ChooseManage extends _TargetChoice {
|
||||
const _ChooseManage();
|
||||
}
|
||||
|
||||
/// The target selector that sits over the top of the map.
|
||||
///
|
||||
/// Over the map rather than in the app bar because it is a map control: it
|
||||
/// changes what the map is looking at, and belongs next to the thing it acts on.
|
||||
class TargetSelector extends ConsumerWidget {
|
||||
const TargetSelector({
|
||||
required this.region,
|
||||
required this.onFollowRequested,
|
||||
required this.onSearch,
|
||||
required this.onManagePlaces,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final RegionConfig region;
|
||||
|
||||
/// Following needs location permission, and asking for it — with the
|
||||
/// disclosure that has to come first — is not a menu's job. The screen that
|
||||
/// owns the map handles it and only then changes the target.
|
||||
final VoidCallback onFollowRequested;
|
||||
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onManagePlaces;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final target = ref.watch(mapTargetProvider);
|
||||
final places = ref.watch(savedPlacesProvider).value ?? const <SavedPlace>[];
|
||||
|
||||
final label = switch (target) {
|
||||
FollowUser() => l10n.targetFollowMe,
|
||||
PlaceTarget(:final place) => place.name,
|
||||
FreeTarget() => l10n.targetWholeRegion(region.displayName),
|
||||
};
|
||||
|
||||
final icon = switch (target) {
|
||||
FollowUser() => Icons.my_location,
|
||||
PlaceTarget() => Icons.place,
|
||||
FreeTarget() => Icons.map_outlined,
|
||||
};
|
||||
|
||||
return Material(
|
||||
elevation: 3,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: theme.colorScheme.surface,
|
||||
child: PopupMenuButton<_TargetChoice>(
|
||||
tooltip: l10n.targetChoose,
|
||||
position: PopupMenuPosition.under,
|
||||
onSelected: (choice) async {
|
||||
switch (choice) {
|
||||
case _ChooseFollow():
|
||||
onFollowRequested();
|
||||
case _ChooseRegion():
|
||||
await ref
|
||||
.read(mapTargetProvider.notifier)
|
||||
.select(const FreeTarget());
|
||||
case _ChoosePlace(:final place):
|
||||
await ref
|
||||
.read(mapTargetProvider.notifier)
|
||||
.select(PlaceTarget(place));
|
||||
case _ChooseSearch():
|
||||
onSearch();
|
||||
case _ChooseManage():
|
||||
onManagePlaces();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => <PopupMenuEntry<_TargetChoice>>[
|
||||
CheckedPopupMenuItem<_TargetChoice>(
|
||||
value: const _ChooseFollow(),
|
||||
checked: target is FollowUser,
|
||||
child: Text(l10n.targetFollowMe),
|
||||
),
|
||||
CheckedPopupMenuItem<_TargetChoice>(
|
||||
value: const _ChooseRegion(),
|
||||
checked: target is FreeTarget,
|
||||
child: Text(l10n.targetWholeRegion(region.displayName)),
|
||||
),
|
||||
if (places.isNotEmpty) const PopupMenuDivider(),
|
||||
for (final place in places)
|
||||
CheckedPopupMenuItem<_TargetChoice>(
|
||||
value: _ChoosePlace(place),
|
||||
checked: target is PlaceTarget && target.place.id == place.id,
|
||||
child: Text(place.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<_TargetChoice>(
|
||||
value: const _ChooseSearch(),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.search),
|
||||
title: Text(l10n.searchTitle),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<_TargetChoice>(
|
||||
value: const _ChooseManage(),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.place_outlined),
|
||||
title: Text(l10n.placesTitle),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.arrow_drop_down, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search and recentre, stacked over the right edge of the map.
|
||||
class MapActionButtons extends ConsumerWidget {
|
||||
const MapActionButtons({
|
||||
required this.onSearch,
|
||||
required this.onRecentre,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onRecentre;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final target = ref.watch(mapTargetProvider);
|
||||
|
||||
// The recentre button says what it will do. Following but panned away is
|
||||
// the case that matters: the icon has to promise the blue dot, not a place.
|
||||
final recentreIcon = switch (target) {
|
||||
FollowUser() => Icons.my_location,
|
||||
PlaceTarget() => Icons.place,
|
||||
FreeTarget() => Icons.zoom_out_map,
|
||||
};
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'nuvolari-search',
|
||||
tooltip: l10n.searchOpen,
|
||||
onPressed: onSearch,
|
||||
child: const Icon(Icons.search),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'nuvolari-recentre',
|
||||
tooltip: l10n.mapCentreOnTarget,
|
||||
onPressed: onRecentre,
|
||||
child: Icon(recentreIcon),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,130 +12,21 @@ 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 'attribution_bar.dart';
|
||||
import 'map_overlay_controls.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<MapLibreMapController?> {
|
||||
@override
|
||||
MapLibreMapController? build() => null;
|
||||
|
||||
void attach(MapLibreMapController controller) => state = controller;
|
||||
|
||||
void detach() => state = null;
|
||||
}
|
||||
|
||||
final mapControllerProvider =
|
||||
NotifierProvider<MapControllerHolder, MapLibreMapController?>(
|
||||
MapControllerHolder.new,
|
||||
);
|
||||
|
||||
/// Moves the map onto [point], keeping the current zoom.
|
||||
Future<void> _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<SavedPlace>(
|
||||
MaterialPageRoute<SavedPlace>(
|
||||
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<void>(builder: (_) => const SettingsScreen())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The radar map: base map, animated precipitation overlay, timeline.
|
||||
class RadarMapScreen extends ConsumerWidget {
|
||||
const RadarMapScreen({super.key});
|
||||
@@ -148,7 +39,17 @@ class RadarMapScreen extends ConsumerWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.appTitle),
|
||||
actions: const [_LocateAction(), _PlacesAction(), _SettingsAction()],
|
||||
// Only settings here. Everything that changes what the map is looking
|
||||
// at lives over the map itself, next to the thing it acts on.
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.settingsOpen,
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: switch (region) {
|
||||
AsyncData(:final value) => _MapWithAttribution(region: value),
|
||||
@@ -185,19 +86,7 @@ class _MapWithAttribution extends ConsumerWidget {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _RegionMap(region: region, style: style),
|
||||
),
|
||||
const DataAgeBanner(),
|
||||
const TimelineBar(),
|
||||
@@ -235,50 +124,53 @@ class _RegionMap extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// Guards against a frame that finished loading after the playhead moved on,
|
||||
/// which would briefly show the wrong image.
|
||||
/// 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();
|
||||
// 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(),
|
||||
);
|
||||
unawaited(_syncLocationAvailability());
|
||||
}
|
||||
|
||||
@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<void>.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<void> _onMapCreated(MapLibreMapController controller) async {
|
||||
_controller = controller;
|
||||
ref.read(mapControllerProvider.notifier).attach(controller);
|
||||
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(
|
||||
await _controller?.moveCamera(
|
||||
CameraUpdate.newLatLngBounds(
|
||||
LatLngBounds(
|
||||
southwest: LatLng(bounds.south, bounds.west),
|
||||
@@ -292,9 +184,136 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<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;
|
||||
@@ -313,13 +332,11 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
|
||||
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.
|
||||
// publishes, and blanking the map is a worse answer.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,41 +346,91 @@ class _RegionMapState extends ConsumerState<_RegionMap> {
|
||||
radarTimelineProvider.select((state) => state.currentFrame),
|
||||
(_, frame) => unawaited(_showFrame(frame)),
|
||||
);
|
||||
ref.listen<MapTarget>(
|
||||
mapTargetProvider,
|
||||
(_, target) => unawaited(_applyTarget(target)),
|
||||
);
|
||||
|
||||
final center = widget.region.map.center;
|
||||
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 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),
|
||||
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),
|
||||
),
|
||||
),
|
||||
attributionButtonPosition: AttributionButtonPosition.bottomRight,
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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,
|
||||
|
||||
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(
|
||||
onSearch: () => unawaited(_openSearch()),
|
||||
onRecentre: () => unawaited(_recentre()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../core/region/geo.dart';
|
||||
|
||||
/// A municipality the user can search for.
|
||||
///
|
||||
/// Bundled rather than geocoded online: the app only has radar for one region,
|
||||
/// so a list of its 1180 municipalities is small enough to ship, searches
|
||||
/// instantly, works with no network, needs no API key, and cannot return a
|
||||
/// result somewhere the app has nothing to show.
|
||||
class Comune {
|
||||
Comune({
|
||||
required this.name,
|
||||
required this.province,
|
||||
required this.istatCode,
|
||||
required this.point,
|
||||
});
|
||||
|
||||
factory Comune.fromJson(Map<String, Object?> json) {
|
||||
final name = json['name'];
|
||||
final province = json['province'];
|
||||
final istat = json['istat'];
|
||||
final latitude = json['lat'];
|
||||
final longitude = json['lng'];
|
||||
|
||||
if (name is! String || name.isEmpty) {
|
||||
throw const FormatException('comune name must be a non-empty string');
|
||||
}
|
||||
if (province is! String || province.isEmpty) {
|
||||
throw const FormatException('comune province must be a non-empty string');
|
||||
}
|
||||
if (istat is! String || istat.isEmpty) {
|
||||
throw const FormatException(
|
||||
'comune istat code must be a non-empty string',
|
||||
);
|
||||
}
|
||||
if (latitude is! num || longitude is! num) {
|
||||
throw const FormatException('comune coordinates must be numbers');
|
||||
}
|
||||
|
||||
return Comune(
|
||||
name: name,
|
||||
province: province,
|
||||
istatCode: istat,
|
||||
point: GeoPoint(longitude.toDouble(), latitude.toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
final String name;
|
||||
|
||||
/// Two-letter province abbreviation, for example `TO`.
|
||||
final String province;
|
||||
|
||||
/// Istat municipality code, stable across renames.
|
||||
final String istatCode;
|
||||
|
||||
/// A representative point inside the municipality — the centroid of its
|
||||
/// largest part, so it is a few kilometres from the town centre at worst.
|
||||
final GeoPoint point;
|
||||
|
||||
/// How it reads in a list: `Agliè (TO)`.
|
||||
String get label => '$name ($province)';
|
||||
|
||||
/// Lower-case and stripped of accents, for matching.
|
||||
///
|
||||
/// Computed on first use rather than per keystroke: 1180 entries times a
|
||||
/// character-by-character fold on every letter typed is work worth doing
|
||||
/// exactly once. This is why the class is not const.
|
||||
late final String searchKey = foldForSearch(name);
|
||||
|
||||
@override
|
||||
String toString() => 'Comune($name, $province)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Comune && other.istatCode == istatCode;
|
||||
|
||||
@override
|
||||
int get hashCode => istatCode.hashCode;
|
||||
}
|
||||
|
||||
/// Lower-cases and removes the accents Italian place names use.
|
||||
///
|
||||
/// Nobody types "Agliè" with the accent when searching, and several names differ
|
||||
/// only by one. Dart has no Unicode normalisation in the core library, and the
|
||||
/// full table would be overkill: Italian municipality names use exactly these.
|
||||
String foldForSearch(String value) {
|
||||
const folded = <String, String>{
|
||||
'à': 'a',
|
||||
'á': 'a',
|
||||
'â': 'a',
|
||||
'ä': 'a',
|
||||
'ã': 'a',
|
||||
'å': 'a',
|
||||
'è': 'e',
|
||||
'é': 'e',
|
||||
'ê': 'e',
|
||||
'ë': 'e',
|
||||
'ì': 'i',
|
||||
'í': 'i',
|
||||
'î': 'i',
|
||||
'ï': 'i',
|
||||
'ò': 'o',
|
||||
'ó': 'o',
|
||||
'ô': 'o',
|
||||
'ö': 'o',
|
||||
'õ': 'o',
|
||||
'ù': 'u',
|
||||
'ú': 'u',
|
||||
'û': 'u',
|
||||
'ü': 'u',
|
||||
'ç': 'c',
|
||||
'ñ': 'n',
|
||||
};
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in value.toLowerCase().runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
buffer.write(folded[char] ?? char);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// The bundled municipality list, with its provenance.
|
||||
class ComuneIndex {
|
||||
ComuneIndex({
|
||||
required this.comuni,
|
||||
required this.source,
|
||||
required this.license,
|
||||
});
|
||||
|
||||
factory ComuneIndex.parse(String raw) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('comuni index must be a JSON object');
|
||||
}
|
||||
|
||||
final places = decoded['places'];
|
||||
if (places is! List || places.isEmpty) {
|
||||
throw const FormatException('comuni index must list places');
|
||||
}
|
||||
|
||||
final source = decoded['source'];
|
||||
final license = decoded['license'];
|
||||
if (source is! String || source.isEmpty) {
|
||||
throw const FormatException(
|
||||
'comuni index must name its source: the list is redistributed data and '
|
||||
'the credit travels with it',
|
||||
);
|
||||
}
|
||||
|
||||
return ComuneIndex(
|
||||
comuni: places
|
||||
.whereType<Map<String, Object?>>()
|
||||
.map(Comune.fromJson)
|
||||
.toList(growable: false),
|
||||
source: source,
|
||||
license: license is String && license.isNotEmpty ? license : null,
|
||||
);
|
||||
}
|
||||
|
||||
final List<Comune> comuni;
|
||||
|
||||
/// Where the list came from, shown on the Sources screen.
|
||||
final String source;
|
||||
|
||||
final String? license;
|
||||
|
||||
/// How many results a search returns at most.
|
||||
///
|
||||
/// A list longer than this is not a search result, it is a phone book: the
|
||||
/// user should type another letter instead of scrolling.
|
||||
static const int maxResults = 30;
|
||||
|
||||
/// Municipalities matching [query], best first.
|
||||
///
|
||||
/// Ranking is deliberately simple and predictable: names that *start* with
|
||||
/// the query come before names that merely contain it, and ties break
|
||||
/// alphabetically. Someone typing "tor" wants Torino before Cavallermaggiore.
|
||||
List<Comune> search(String query) {
|
||||
final needle = foldForSearch(query.trim());
|
||||
if (needle.isEmpty) return const <Comune>[];
|
||||
|
||||
final prefix = <Comune>[];
|
||||
final contains = <Comune>[];
|
||||
|
||||
for (final comune in comuni) {
|
||||
final key = comune.searchKey;
|
||||
if (key.startsWith(needle)) {
|
||||
prefix.add(comune);
|
||||
} else if (key.contains(needle)) {
|
||||
contains.add(comune);
|
||||
}
|
||||
}
|
||||
|
||||
int byName(Comune a, Comune b) => a.searchKey.compareTo(b.searchKey);
|
||||
prefix.sort(byName);
|
||||
contains.sort(byName);
|
||||
|
||||
return <Comune>[
|
||||
...prefix,
|
||||
...contains,
|
||||
].take(maxResults).toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/region/region_repository.dart';
|
||||
import 'comune.dart';
|
||||
|
||||
/// Loads the bundled municipality list.
|
||||
class ComuniRepository {
|
||||
const ComuniRepository({this.bundle});
|
||||
|
||||
/// Overridden in tests; null means the real asset bundle.
|
||||
final AssetBundle? bundle;
|
||||
|
||||
AssetBundle get _assets => bundle ?? rootBundle;
|
||||
|
||||
static String assetPathFor(String regionId) =>
|
||||
'assets/regions/${regionId}_comuni.json';
|
||||
|
||||
Future<ComuneIndex> load(String regionId) async {
|
||||
final source = await _assets.loadString(assetPathFor(regionId));
|
||||
return ComuneIndex.parse(source);
|
||||
}
|
||||
}
|
||||
|
||||
final comuniRepositoryProvider = Provider<ComuniRepository>(
|
||||
(ref) => const ComuniRepository(),
|
||||
);
|
||||
|
||||
/// The searchable municipality index for the active region.
|
||||
///
|
||||
/// Loaded once and kept: 1180 entries with a precomputed fold key is a
|
||||
/// negligible amount of memory next to a single radar frame, and rebuilding it
|
||||
/// per search would make typing feel slow for no reason.
|
||||
final comuneIndexProvider = FutureProvider<ComuneIndex>((ref) async {
|
||||
final regionId = ref.watch(regionIdProvider);
|
||||
return ref.watch(comuniRepositoryProvider).load(regionId);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import '../../core/region/geo.dart';
|
||||
|
||||
/// Parses coordinates typed or pasted by the user.
|
||||
///
|
||||
/// People paste from wherever they found them, so this accepts the shapes that
|
||||
/// actually turn up rather than one canonical form: decimal degrees separated
|
||||
/// by a comma, a semicolon or spaces, with or without hemisphere letters, and
|
||||
/// degrees-minutes-seconds.
|
||||
///
|
||||
/// Latitude always comes first, matching every consumer-facing map. Returns
|
||||
/// null when the text is not coordinates at all — that is the normal case while
|
||||
/// someone is typing a town name into the same field.
|
||||
GeoPoint? parseCoordinates(String input) {
|
||||
final text = input.trim();
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
final dms = _parseDms(text);
|
||||
if (dms != null) return dms;
|
||||
|
||||
return _parseDecimal(text);
|
||||
}
|
||||
|
||||
final RegExp _decimalPair = RegExp(
|
||||
r'^\s*([NS])?\s*([+-]?\d+(?:[.,]\d+)?)\s*°?\s*([NS])?'
|
||||
r'\s*[,;\s]\s*'
|
||||
r'([EWO])?\s*([+-]?\d+(?:[.,]\d+)?)\s*°?\s*([EWO])?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
GeoPoint? _parseDecimal(String text) {
|
||||
final match = _decimalPair.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final latitude = _applyHemisphere(
|
||||
_number(match.group(2)),
|
||||
match.group(1) ?? match.group(3),
|
||||
negative: 'S',
|
||||
);
|
||||
final longitude = _applyHemisphere(
|
||||
_number(match.group(5)),
|
||||
match.group(4) ?? match.group(6),
|
||||
negative: 'W',
|
||||
);
|
||||
if (latitude == null || longitude == null) return null;
|
||||
|
||||
return _validated(latitude, longitude);
|
||||
}
|
||||
|
||||
/// Degrees, minutes and optional seconds, for both halves of a pair.
|
||||
///
|
||||
/// The seconds mark can be a double-prime, two apostrophes, a plain quote or
|
||||
/// an `s`, because those are all shapes people paste. Built by string
|
||||
/// concatenation rather than one long literal: a raw string cannot contain the
|
||||
/// double quote this pattern needs, and a non-raw one would turn every
|
||||
/// backslash in the pattern into an escape.
|
||||
final RegExp _dmsPair = RegExp(
|
||||
'^\\s*$_dmsPart([NS])\\s*[,;\\s]\\s*$_dmsPart([EWO])\\s*\$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
const String _dmsPart =
|
||||
r'(\d+)\s*[°d]\s*'
|
||||
r'(\d+(?:[.,]\d+)?)\s*'
|
||||
"['m′]?\\s*"
|
||||
r'(?:(\d+(?:[.,]\d+)?)\s*'
|
||||
"[″\"']*s?\\s*)?";
|
||||
|
||||
GeoPoint? _parseDms(String text) {
|
||||
final match = _dmsPair.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final latitude = _fromDms(
|
||||
match.group(1),
|
||||
match.group(2),
|
||||
match.group(3),
|
||||
match.group(4),
|
||||
negative: 'S',
|
||||
);
|
||||
final longitude = _fromDms(
|
||||
match.group(5),
|
||||
match.group(6),
|
||||
match.group(7),
|
||||
match.group(8),
|
||||
negative: 'W',
|
||||
);
|
||||
if (latitude == null || longitude == null) return null;
|
||||
|
||||
return _validated(latitude, longitude);
|
||||
}
|
||||
|
||||
double? _fromDms(
|
||||
String? degrees,
|
||||
String? minutes,
|
||||
String? seconds,
|
||||
String? hemisphere, {
|
||||
required String negative,
|
||||
}) {
|
||||
final d = _number(degrees);
|
||||
final m = _number(minutes) ?? 0;
|
||||
final s = _number(seconds) ?? 0;
|
||||
if (d == null) return null;
|
||||
if (m >= 60 || s >= 60) return null;
|
||||
|
||||
final value = d + m / 60 + s / 3600;
|
||||
return _applyHemisphere(value, hemisphere, negative: negative);
|
||||
}
|
||||
|
||||
double? _applyHemisphere(
|
||||
double? value,
|
||||
String? hemisphere, {
|
||||
required String negative,
|
||||
}) {
|
||||
if (value == null) return null;
|
||||
if (hemisphere == null) return value;
|
||||
|
||||
final letter = hemisphere.toUpperCase();
|
||||
// "O" is Italian for west — someone reading coordinates off an Italian map
|
||||
// will type it, and silently treating it as east would put them the wrong
|
||||
// side of Greenwich.
|
||||
final isNegative = letter == negative || (negative == 'W' && letter == 'O');
|
||||
final magnitude = value.abs();
|
||||
return isNegative ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
/// Accepts both the decimal point and the comma Italian keyboards produce.
|
||||
double? _number(String? raw) =>
|
||||
raw == null ? null : double.tryParse(raw.replaceAll(',', '.'));
|
||||
|
||||
GeoPoint? _validated(double latitude, double longitude) {
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
if (longitude < -180 || longitude > 180) return null;
|
||||
return GeoPoint(longitude, latitude);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'saved_place.dart';
|
||||
import 'saved_places.dart';
|
||||
|
||||
/// What the map is pointed at.
|
||||
sealed class MapTarget {
|
||||
const MapTarget();
|
||||
|
||||
/// The value persisted as the preferred target, or null if it is not a
|
||||
/// sensible thing to reopen on.
|
||||
String? get storageValue;
|
||||
}
|
||||
|
||||
/// Follow the device: the blue dot stays centred until the user pans away.
|
||||
class FollowUser extends MapTarget {
|
||||
const FollowUser();
|
||||
|
||||
@override
|
||||
String? get storageValue => 'follow';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is FollowUser;
|
||||
|
||||
@override
|
||||
int get hashCode => 'follow'.hashCode;
|
||||
}
|
||||
|
||||
/// Sit on a saved place.
|
||||
class PlaceTarget extends MapTarget {
|
||||
const PlaceTarget(this.place);
|
||||
|
||||
final SavedPlace place;
|
||||
|
||||
@override
|
||||
String? get storageValue => 'place:${place.id}';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PlaceTarget && other.place.id == place.id;
|
||||
|
||||
@override
|
||||
int get hashCode => place.id.hashCode;
|
||||
}
|
||||
|
||||
/// The whole region, and wherever the user has panned to.
|
||||
///
|
||||
/// Also what following degrades to the moment the user drags the map: the
|
||||
/// camera stops chasing them, which is the behaviour every map app has trained
|
||||
/// people to expect.
|
||||
class FreeTarget extends MapTarget {
|
||||
const FreeTarget();
|
||||
|
||||
@override
|
||||
String? get storageValue => null;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is FreeTarget;
|
||||
|
||||
@override
|
||||
int get hashCode => 'free'.hashCode;
|
||||
}
|
||||
|
||||
/// Remembers what the app should open on.
|
||||
abstract interface class PreferredTargetStore {
|
||||
Future<String?> load();
|
||||
|
||||
Future<void> save(String? value);
|
||||
}
|
||||
|
||||
class SharedPreferencesPreferredTargetStore implements PreferredTargetStore {
|
||||
const SharedPreferencesPreferredTargetStore();
|
||||
|
||||
static const String storageKey = 'nuvolari.preferred_target.v1';
|
||||
|
||||
@override
|
||||
Future<String?> load() async =>
|
||||
(await SharedPreferences.getInstance()).getString(storageKey);
|
||||
|
||||
@override
|
||||
Future<void> save(String? value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (value == null) {
|
||||
await prefs.remove(storageKey);
|
||||
} else {
|
||||
await prefs.setString(storageKey, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryPreferredTargetStore implements PreferredTargetStore {
|
||||
InMemoryPreferredTargetStore([this._value]);
|
||||
|
||||
String? _value;
|
||||
|
||||
@override
|
||||
Future<String?> load() async => _value;
|
||||
|
||||
@override
|
||||
Future<void> save(String? value) async => _value = value;
|
||||
}
|
||||
|
||||
final preferredTargetStoreProvider = Provider<PreferredTargetStore>(
|
||||
(ref) => const SharedPreferencesPreferredTargetStore(),
|
||||
);
|
||||
|
||||
/// The map's current target, and the one it will reopen on.
|
||||
///
|
||||
/// One notion rather than two, because "the place I marked as preferred" and
|
||||
/// "what the app shows me when I open it" are the same thing to the person
|
||||
/// using it. Selecting from the dropdown sets both.
|
||||
class MapTargetController extends Notifier<MapTarget> {
|
||||
@override
|
||||
MapTarget build() {
|
||||
// Places load asynchronously, so the preferred target is resolved once they
|
||||
// arrive rather than guessed now.
|
||||
ref.listen<AsyncValue<List<SavedPlace>>>(savedPlacesProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
if (previous?.hasValue ?? false) return;
|
||||
final places = next.value;
|
||||
if (places != null) unawaited(_restorePreferred(places));
|
||||
}, fireImmediately: true);
|
||||
|
||||
return const FreeTarget();
|
||||
}
|
||||
|
||||
bool _restored = false;
|
||||
|
||||
Future<void> _restorePreferred(List<SavedPlace> places) async {
|
||||
if (_restored) return;
|
||||
_restored = true;
|
||||
|
||||
final stored = await ref.read(preferredTargetStoreProvider).load();
|
||||
if (stored == null) return;
|
||||
|
||||
if (stored == 'follow') {
|
||||
state = const FollowUser();
|
||||
return;
|
||||
}
|
||||
if (stored.startsWith('place:')) {
|
||||
final id = stored.substring('place:'.length);
|
||||
for (final place in places) {
|
||||
if (place.id == id) {
|
||||
state = PlaceTarget(place);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The preferred place was deleted. Forget it rather than reopening on
|
||||
// something that no longer exists.
|
||||
await ref.read(preferredTargetStoreProvider).save(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects [target] and remembers it as what to reopen on.
|
||||
Future<void> select(MapTarget target) async {
|
||||
state = target;
|
||||
await ref.read(preferredTargetStoreProvider).save(target.storageValue);
|
||||
}
|
||||
|
||||
/// Drops out of following without changing what the app reopens on.
|
||||
///
|
||||
/// Called when the user drags the map while it is chasing them: they want to
|
||||
/// look somewhere else *now*, which is not the same as changing their mind
|
||||
/// about what the app should do next launch.
|
||||
void releaseFollow() {
|
||||
if (state is FollowUser) state = const FreeTarget();
|
||||
}
|
||||
|
||||
/// Forgets a place that has been deleted, if it was the target.
|
||||
Future<void> forget(String placeId) async {
|
||||
final current = state;
|
||||
if (current is PlaceTarget && current.place.id == placeId) {
|
||||
state = const FreeTarget();
|
||||
await ref.read(preferredTargetStoreProvider).save(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final mapTargetProvider = NotifierProvider<MapTargetController, MapTarget>(
|
||||
MapTargetController.new,
|
||||
);
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/region/geo.dart';
|
||||
import '../../core/region/region_config.dart';
|
||||
import '../../core/region/region_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'comune.dart';
|
||||
import 'comuni_repository.dart';
|
||||
import 'coordinate_input.dart';
|
||||
|
||||
/// What the user picked out of search.
|
||||
class SearchResult {
|
||||
const SearchResult({required this.point, required this.label});
|
||||
|
||||
final GeoPoint point;
|
||||
|
||||
/// How to name it if it gets saved as a place.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// One field that takes either a municipality name or coordinates.
|
||||
///
|
||||
/// Two separate searches would make the user decide up front which kind of
|
||||
/// thing they are looking for, which they should not have to: a string either
|
||||
/// parses as coordinates or it does not, and the answer is obvious from the
|
||||
/// text itself.
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final index = ref.watch(comuneIndexProvider);
|
||||
final region = ref.watch(regionConfigProvider).value;
|
||||
|
||||
final coordinates = parseCoordinates(_query);
|
||||
final matches = switch (index) {
|
||||
AsyncData(:final value) => value.search(_query),
|
||||
_ => const <Comune>[],
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchHint,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
actions: [
|
||||
if (_query.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: switch (index) {
|
||||
AsyncError(:final error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('${l10n.dataUnavailable}\n$error'),
|
||||
),
|
||||
),
|
||||
AsyncData() => _results(
|
||||
context,
|
||||
coordinates: coordinates,
|
||||
matches: matches,
|
||||
region: region,
|
||||
),
|
||||
_ => Center(child: Text(l10n.loading)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _results(
|
||||
BuildContext context, {
|
||||
required GeoPoint? coordinates,
|
||||
required List<Comune> matches,
|
||||
required RegionConfig? region,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (_query.trim().isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.searchHelp, style: theme.textTheme.bodyMedium),
|
||||
);
|
||||
}
|
||||
|
||||
final outsideRegion =
|
||||
coordinates != null &&
|
||||
region != null &&
|
||||
!region.bounds.contains(coordinates);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
if (coordinates != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.my_location),
|
||||
title: Text(l10n.searchCoordinatesResult),
|
||||
subtitle: Text(
|
||||
'${coordinates.latitude.toStringAsFixed(4)}, '
|
||||
'${coordinates.longitude.toStringAsFixed(4)}'
|
||||
// Saying so beats letting them travel to an empty map.
|
||||
'${outsideRegion ? '\n${l10n.searchOutsideRegion}' : ''}',
|
||||
style: outsideRegion
|
||||
? TextStyle(color: theme.colorScheme.error)
|
||||
: null,
|
||||
),
|
||||
onTap: () => Navigator.of(context).pop(
|
||||
SearchResult(
|
||||
point: coordinates,
|
||||
label:
|
||||
'${coordinates.latitude.toStringAsFixed(4)}, '
|
||||
'${coordinates.longitude.toStringAsFixed(4)}',
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (coordinates != null && matches.isNotEmpty) const Divider(),
|
||||
|
||||
if (matches.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
l10n.searchComuniCount(matches.length),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
for (final comune in matches)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.location_city),
|
||||
title: Text(comune.name),
|
||||
subtitle: Text(comune.province),
|
||||
onTap: () =>
|
||||
Navigator.of(context)
|
||||
.pop(SearchResult(point: comune.point, label: comune.name)),
|
||||
),
|
||||
|
||||
if (matches.isEmpty && coordinates == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.searchNoResults, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.searchHelp, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -305,5 +305,77 @@
|
||||
"save": "Salva",
|
||||
"@save": {
|
||||
"description": "Generic save button"
|
||||
},
|
||||
"targetFollowMe": "Segui la mia posizione",
|
||||
"@targetFollowMe": {
|
||||
"description": "Dropdown entry that makes the map follow the device position"
|
||||
},
|
||||
"targetWholeRegion": "Tutto il {region}",
|
||||
"@targetWholeRegion": {
|
||||
"description": "Dropdown entry that frames the whole region",
|
||||
"placeholders": {
|
||||
"region": {
|
||||
"type": "String",
|
||||
"example": "Piemonte"
|
||||
}
|
||||
}
|
||||
},
|
||||
"targetChoose": "Scegli cosa seguire",
|
||||
"@targetChoose": {
|
||||
"description": "Accessibility label for the target dropdown on the map"
|
||||
},
|
||||
"targetIsPreferred": "Si apre qui",
|
||||
"@targetIsPreferred": {
|
||||
"description": "Marks the entry the app will reopen on"
|
||||
},
|
||||
"searchTitle": "Cerca",
|
||||
"@searchTitle": {
|
||||
"description": "Title of the search screen"
|
||||
},
|
||||
"searchOpen": "Cerca una località",
|
||||
"@searchOpen": {
|
||||
"description": "Tooltip on the map button that opens search"
|
||||
},
|
||||
"searchHint": "Comune o coordinate",
|
||||
"@searchHint": {
|
||||
"description": "Placeholder in the search field"
|
||||
},
|
||||
"searchHelp": "Digita il nome di un comune piemontese, oppure delle coordinate come 45.07, 7.69 o 45°04'13\"N 7°41'13\"E.",
|
||||
"@searchHelp": {
|
||||
"description": "Explains what the search field accepts, shown before anything is typed"
|
||||
},
|
||||
"searchNoResults": "Nessun comune trovato",
|
||||
"@searchNoResults": {
|
||||
"description": "Shown when the query matches no municipality"
|
||||
},
|
||||
"searchCoordinatesResult": "Vai a queste coordinate",
|
||||
"@searchCoordinatesResult": {
|
||||
"description": "Result entry shown when the typed text parses as coordinates"
|
||||
},
|
||||
"searchOutsideRegion": "Fuori dall'area coperta dai dati",
|
||||
"@searchOutsideRegion": {
|
||||
"description": "Warning under a coordinate result that falls outside the region"
|
||||
},
|
||||
"searchComuniCount": "{count, plural, one{1 comune} other{{count} comuni}}",
|
||||
"@searchComuniCount": {
|
||||
"description": "How many municipalities matched",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "12"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCentreOnTarget": "Centra",
|
||||
"@mapCentreOnTarget": {
|
||||
"description": "Tooltip on the button that recentres the map on whatever is being followed"
|
||||
},
|
||||
"mapSaveThisPoint": "Salva questo punto",
|
||||
"@mapSaveThisPoint": {
|
||||
"description": "Action that saves a searched point as a place"
|
||||
},
|
||||
"sourcesComuniList": "Elenco comuni",
|
||||
"@sourcesComuniList": {
|
||||
"description": "Sources screen heading for the bundled municipality list"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user