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:
2026-09-10 21:47:20 +02:00
co-authored by Claude Opus 5
parent 06ab816ebe
commit b4b8d089e6
21 changed files with 2062 additions and 210 deletions
+121 -21
View File
@@ -2,42 +2,136 @@
"id": "piemonte",
"displayName": "Piemonte",
"timeZone": "Europe/Rome",
"bbox": [6.55, 43.95, 9.30, 46.55],
"bbox": [
6.55,
43.95,
9.3,
46.55
],
"map": {
"center": [7.95, 45.25],
"zoom": { "min": 6.0, "max": 13.0, "initial": 7.2 }
"center": [
7.95,
45.25
],
"zoom": {
"min": 6.0,
"max": 13.0,
"initial": 7.2
}
},
"sources": {
"radar": {
"defaultAdapter": "mock",
"availableAdapters": ["mock", "dpc"],
"availableAdapters": [
"mock",
"dpc"
],
"product": "VMI",
"frameIntervalMinutes": 5
},
"alerts": {
"defaultAdapter": "arpaCap",
"availableAdapters": ["arpaCap"],
"availableAdapters": [
"arpaCap"
],
"officialBulletinUrl": "https://www.arpa.piemonte.it/rischi_naturali/boll/bollettino_allerta.pdf"
}
},
"alertZones": [
{ "code": "Piem-A", "name": "Toce", "provinces": ["NO", "VB"] },
{ "code": "Piem-B", "name": "Val Sesia, Cervo e Chiusella", "provinces": ["BI", "NO", "TO", "VC"] },
{ "code": "Piem-C", "name": "Valli Orco, Lanzo, bassa Val Susa e Sangone", "provinces": ["TO"] },
{ "code": "Piem-D", "name": "Alta Val Susa, Valli Chisone, Pellice e Po", "provinces": ["CN", "TO"] },
{ "code": "Piem-E", "name": "Valli Varaita, Maira e Stura", "provinces": ["CN"] },
{ "code": "Piem-F", "name": "Valle Tanaro", "provinces": ["CN"] },
{ "code": "Piem-G", "name": "Belbo e Bormida", "provinces": ["AL", "AT", "CN"] },
{ "code": "Piem-H", "name": "Scrivia", "provinces": ["AL"] },
{ "code": "Piem-I", "name": "Pianura Settentrionale", "provinces": ["AL", "AT", "BI", "NO", "TO", "VC"] },
{ "code": "Piem-L", "name": "Pianura Torinese e Colline", "provinces": ["AL", "AT", "CN", "TO"] },
{ "code": "Piem-M", "name": "Pianura Cuneese", "provinces": ["CN", "TO"] }
{
"code": "Piem-A",
"name": "Toce",
"provinces": [
"NO",
"VB"
]
},
{
"code": "Piem-B",
"name": "Val Sesia, Cervo e Chiusella",
"provinces": [
"BI",
"NO",
"TO",
"VC"
]
},
{
"code": "Piem-C",
"name": "Valli Orco, Lanzo, bassa Val Susa e Sangone",
"provinces": [
"TO"
]
},
{
"code": "Piem-D",
"name": "Alta Val Susa, Valli Chisone, Pellice e Po",
"provinces": [
"CN",
"TO"
]
},
{
"code": "Piem-E",
"name": "Valli Varaita, Maira e Stura",
"provinces": [
"CN"
]
},
{
"code": "Piem-F",
"name": "Valle Tanaro",
"provinces": [
"CN"
]
},
{
"code": "Piem-G",
"name": "Belbo e Bormida",
"provinces": [
"AL",
"AT",
"CN"
]
},
{
"code": "Piem-H",
"name": "Scrivia",
"provinces": [
"AL"
]
},
{
"code": "Piem-I",
"name": "Pianura Settentrionale",
"provinces": [
"AL",
"AT",
"BI",
"NO",
"TO",
"VC"
]
},
{
"code": "Piem-L",
"name": "Pianura Torinese e Colline",
"provinces": [
"AL",
"AT",
"CN",
"TO"
]
},
{
"code": "Piem-M",
"name": "Pianura Cuneese",
"provinces": [
"CN",
"TO"
]
}
],
"attributions": [
{
"id": "dpc",
@@ -68,6 +162,12 @@
"text": "OpenFreeMap",
"license": "MIT",
"url": "https://openfreemap.org/"
},
{
"id": "istat",
"text": "Istat — Confini delle unità amministrative",
"license": "CC BY 4.0",
"url": "https://www.istat.it/notizia/confini-delle-unita-amministrative-a-fini-statistici/"
}
]
}
File diff suppressed because one or more lines are too long
@@ -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),
],
),
),
],
);
}
}
+72
View File
@@ -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"
}
}
@@ -130,7 +130,7 @@ void main() {
test('carries the mandatory attributions', () {
final ids = config.attributions.map((a) => a.id).toSet();
expect(ids, containsAll(<String>['dpc', 'osm', 'openmaptiles']));
expect(ids, containsAll(<String>['dpc', 'osm', 'openmaptiles', 'istat']));
final dpc = config.attributions.firstWhere((a) => a.id == 'dpc');
expect(dpc.license, 'CC BY-SA');
@@ -0,0 +1,181 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuvolari/core/region/region_config.dart';
import 'package:nuvolari/features/places/comune.dart';
import 'package:nuvolari/features/places/comuni_repository.dart';
ComuneIndex loadBundled() => ComuneIndex.parse(
File('assets/regions/piemonte_comuni.json').readAsStringSync(),
);
void main() {
group('foldForSearch', () {
test('lower-cases and strips the accents Italian names use', () {
expect(foldForSearch('Agliè'), 'aglie');
expect(foldForSearch('CIRIÈ'), 'cirie');
expect(foldForSearch('Mondovì'), 'mondovi');
expect(foldForSearch('Forlì'), 'forli');
});
test('leaves unaccented text alone apart from case', () {
expect(foldForSearch('Torino'), 'torino');
expect(foldForSearch("Reggio nell'Emilia"), "reggio nell'emilia");
});
});
group('the bundled Piedmont list', () {
late ComuneIndex index;
late RegionConfig region;
setUpAll(() {
index = loadBundled();
region = RegionConfig.parse(
File('assets/regions/piemonte.json').readAsStringSync(),
);
});
test('holds every Piedmont municipality', () {
// Piedmont had 1180 municipalities at 1 January 2025. A number that
// drifts means the generator picked up the wrong region or year.
expect(index.comuni, hasLength(1180));
});
test('names its source and licence', () {
expect(index.source, contains('Istat'));
expect(index.license, 'CC BY 4.0');
});
// A name decoded with the wrong codepage is the failure mode this data has
// already produced once, and it is silent unless something checks.
test('no name is mis-decoded', () {
final suspicious = index.comuni
.where(
(comune) => comune.name.contains('Ã') || comune.name.contains(''),
)
.map((comune) => comune.name)
.toList();
expect(suspicious, isEmpty);
});
test('accented names survived the pipeline intact', () {
final aglie = index.comuni.firstWhere((c) => c.istatCode == '001001');
expect(aglie.name, 'Agliè');
expect(aglie.province, 'TO');
});
test('every municipality falls inside the region bounds', () {
final outside = index.comuni
.where((comune) => !region.bounds.contains(comune.point))
.map((comune) => comune.label)
.toList();
expect(outside, isEmpty);
});
test('istat codes are unique', () {
final codes = index.comuni.map((c) => c.istatCode).toSet();
expect(codes, hasLength(index.comuni.length));
});
test('provinces are the eight of Piedmont', () {
final provinces = index.comuni.map((c) => c.province).toSet();
expect(provinces, {'TO', 'VC', 'NO', 'CN', 'AT', 'AL', 'BI', 'VB'});
});
test('the provincial capitals are where they should be', () {
final torino = index.comuni.firstWhere((c) => c.name == 'Torino');
// A centroid, so a few kilometres out is expected; tens would mean the
// projection maths is wrong.
expect(torino.point.latitude, closeTo(45.07, 0.1));
expect(torino.point.longitude, closeTo(7.68, 0.1));
});
});
group('search', () {
late ComuneIndex index;
setUpAll(() => index = loadBundled());
test('finds a town by its exact name', () {
final results = index.search('Torino');
expect(results.first.name, 'Torino');
});
// Someone typing "tor" wants Torino, not the first alphabetical name that
// happens to contain those letters somewhere.
test('ranks names that start with the query first', () {
final results = index.search('tor');
expect(results.first.searchKey, startsWith('tor'));
expect(results.map((c) => c.name), contains('Torino'));
});
test('ignores accents in the query and in the data', () {
expect(index.search('aglie').map((c) => c.name), contains('Agliè'));
expect(index.search('Agliè').map((c) => c.name), contains('Agliè'));
expect(index.search('mondovi').map((c) => c.name), contains('Mondovì'));
});
test('is case-insensitive', () {
expect(index.search('TORINO').first.name, 'Torino');
expect(index.search('torino').first.name, 'Torino');
});
test('matches in the middle of a name too', () {
final results = index.search('mondov');
expect(results, isNotEmpty);
});
test('an empty query returns nothing rather than everything', () {
expect(index.search(''), isEmpty);
expect(index.search(' '), isEmpty);
});
test('caps the result list', () {
// A single letter matches hundreds of names.
expect(
index.search('a').length,
lessThanOrEqualTo(ComuneIndex.maxResults),
);
});
test('an unmatched query returns nothing', () {
expect(index.search('zzzzzzzz'), isEmpty);
});
});
group('ComuneIndex.parse', () {
test('rejects a document with no source', () {
expect(
() => ComuneIndex.parse(
'{"places":[{"name":"X","province":"TO","istat":"1","lat":45,"lng":7}]}',
),
throwsFormatException,
);
});
test('rejects an empty list', () {
expect(
() => ComuneIndex.parse('{"source":"x","places":[]}'),
throwsFormatException,
);
});
});
group('ComuniRepository', () {
test('derives the asset path from the region id', () {
expect(
ComuniRepository.assetPathFor('piemonte'),
'assets/regions/piemonte_comuni.json',
);
});
});
}
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nuvolari/features/places/coordinate_input.dart';
void main() {
group('decimal degrees', () {
test('parses what people paste from a map', () {
final point = parseCoordinates('45.0703, 7.6869');
expect(point, isNotNull);
expect(point!.latitude, closeTo(45.0703, 1e-9));
expect(point.longitude, closeTo(7.6869, 1e-9));
});
test('accepts a space or a semicolon as the separator', () {
for (final text in <String>[
'45.0703 7.6869',
'45.0703; 7.6869',
'45.0703,7.6869',
]) {
final point = parseCoordinates(text);
expect(point, isNotNull, reason: text);
expect(point!.latitude, closeTo(45.0703, 1e-9), reason: text);
}
});
test('latitude comes first, as on every consumer map', () {
final point = parseCoordinates('7.6869, 45.0703')!;
expect(point.latitude, closeTo(7.6869, 1e-9));
expect(point.longitude, closeTo(45.0703, 1e-9));
});
test('handles hemisphere letters before or after the number', () {
expect(parseCoordinates('45.07N, 7.68E')!.latitude, closeTo(45.07, 1e-9));
expect(parseCoordinates('N45.07, E7.68')!.longitude, closeTo(7.68, 1e-9));
expect(
parseCoordinates('45.07S, 7.68W')!.latitude,
closeTo(-45.07, 1e-9),
);
expect(
parseCoordinates('45.07S, 7.68W')!.longitude,
closeTo(-7.68, 1e-9),
);
});
// Someone reading coordinates off an Italian map types O for ovest.
// Treating it as east would put them the wrong side of Greenwich.
test('reads O as west', () {
expect(
parseCoordinates('45.07N, 7.68O')!.longitude,
closeTo(-7.68, 1e-9),
);
});
test('accepts signed values', () {
final point = parseCoordinates('-33.87, 151.21')!;
expect(point.latitude, closeTo(-33.87, 1e-9));
expect(point.longitude, closeTo(151.21, 1e-9));
});
test('accepts the decimal comma an Italian keyboard produces', () {
final point = parseCoordinates('45,0703 7,6869')!;
expect(point.latitude, closeTo(45.0703, 1e-9));
expect(point.longitude, closeTo(7.6869, 1e-9));
});
});
group('degrees, minutes and seconds', () {
test('parses the full form', () {
final point = parseCoordinates('45°04\'13"N 7°41\'13"E');
expect(point, isNotNull);
expect(point!.latitude, closeTo(45 + 4 / 60 + 13 / 3600, 1e-6));
expect(point.longitude, closeTo(7 + 41 / 60 + 13 / 3600, 1e-6));
});
test('parses degrees and minutes without seconds', () {
final point = parseCoordinates("45°04'N 7°41'E")!;
expect(point.latitude, closeTo(45 + 4 / 60, 1e-6));
});
test('applies the southern and western hemispheres', () {
final point = parseCoordinates('33°52\'04"S 151°12\'36"W')!;
expect(point.latitude, lessThan(0));
expect(point.longitude, lessThan(0));
});
test('rejects minutes or seconds at 60 or above', () {
expect(parseCoordinates('45°60\'00"N 7°41\'13"E'), isNull);
expect(parseCoordinates('45°04\'60"N 7°41\'13"E'), isNull);
});
});
group('rejection', () {
// The same field takes a town name, so anything that is not coordinates has
// to come back null rather than throw or guess.
test('returns null for a town name', () {
expect(parseCoordinates('Torino'), isNull);
expect(parseCoordinates('Reggio nell Emilia'), isNull);
expect(parseCoordinates(''), isNull);
expect(parseCoordinates(' '), isNull);
});
test('returns null for a single number', () {
expect(parseCoordinates('45.07'), isNull);
});
test('returns null for out-of-range values', () {
expect(parseCoordinates('91.0, 7.0'), isNull);
expect(parseCoordinates('45.0, 181.0'), isNull);
expect(parseCoordinates('-91.0, 7.0'), isNull);
});
test('returns null for trailing rubbish', () {
expect(parseCoordinates('45.07, 7.68 and then some'), isNull);
});
});
}
@@ -36,7 +36,16 @@ void main() {
await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle();
// The list is longer than the test viewport, so each entry is scrolled to
// rather than expected to be on screen at once. Asserting only on what
// happens to fit would let a credit fall off the bottom unnoticed.
for (final attribution in region.attributions) {
await tester.scrollUntilVisible(
find.text(attribution.text),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(
find.text(attribution.text),
findsOneWidget,