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:
@@ -62,3 +62,6 @@ backend/.cache/
|
||||
|
||||
# Editors
|
||||
.idea/
|
||||
|
||||
# Downloaded source archives for tool/ generators.
|
||||
tool/.cache/
|
||||
|
||||
+6
-2
@@ -10,8 +10,8 @@ regions via configuration.
|
||||
|
||||
In scope:
|
||||
- Animated precipitation radar over an OpenStreetMap base map
|
||||
- Saved places and optional device location, used to frame the map and later to anchor
|
||||
notifications
|
||||
- Saved places, municipality and coordinate search, and optional device location with a
|
||||
live position marker; used to frame the map and later to anchor notifications
|
||||
- Ground-station rain accumulations and 72-hour temperature from the ARPA realtime API,
|
||||
published through the worker
|
||||
- ARPA Piemonte official alert bulletin (XML-CAP), republished verbatim
|
||||
@@ -73,6 +73,10 @@ grounds that there are no ads today.
|
||||
- Base map: OpenFreeMap (https://openfreemap.org), free OSM vector tiles with no API key
|
||||
and no request limits. Mandatory credits: "© OpenMapTiles" and
|
||||
"© OpenStreetMap contributors" (ODbL). OpenFreeMap's own credit is optional.
|
||||
- Municipalities: 1180 Piedmont comuni bundled from Istat boundary shapefiles (CC BY 4.0),
|
||||
generated by tool/generate_places.py. Offline search, no geocoding service. The source
|
||||
DBF is UTF-8 despite appearances and the geometry is UTM 32N — see docs/data-sources.md
|
||||
before regenerating.
|
||||
|
||||
## Verification loop (run after each milestone)
|
||||
- `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle`
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -286,7 +286,38 @@ come from the ARPA zone documentation.
|
||||
A captured copy of the live feed is kept as a test fixture so the parser is verified
|
||||
against the real document rather than a hand-written approximation.
|
||||
|
||||
## 6. Base map — OpenFreeMap
|
||||
## 6. Municipalities — Istat boundaries
|
||||
|
||||
The 1180 municipalities of Piedmont with a representative point each, bundled as
|
||||
`app/assets/regions/piemonte_comuni.json` (98 KB) and generated by
|
||||
`tool/generate_places.py`.
|
||||
|
||||
**Source**: Istat, *Confini delle unita amministrative a fini statistici*,
|
||||
generalised boundaries at 1 January 2025, **CC BY 4.0**.
|
||||
`https://www.istat.it/storage/cartografia/confini_amministrativi/generalizzati/2025/Limiti01012025_g.zip`
|
||||
|
||||
Bundled rather than geocoded online because the app is region-scoped: the search
|
||||
is instant, works with no network, needs no API key, has no rate limit to
|
||||
respect, and cannot return a result somewhere the app has no radar for.
|
||||
|
||||
Two properties of the source file are worth knowing before touching the
|
||||
generator:
|
||||
|
||||
- **The shapefile is in WGS84 / UTM zone 32N**, not in degrees. The generator
|
||||
runs an inverse transverse Mercator to get latitude and longitude.
|
||||
- **The DBF is mixed-encoding.** It is UTF-8 — "Aglie" ends in a single U+00E8 —
|
||||
but at least one bilingual Friulian record makes strict cp1252 fail, and a
|
||||
terminal renders utf-8 and latin-1 output identically, so this cannot be
|
||||
settled by looking at printed text. The generator asserts that no output name
|
||||
contains the tell-tale mojibake sequences, and a test asserts it again against
|
||||
the shipped asset.
|
||||
|
||||
Points are the centroid of each municipality's **largest** part, not of the whole
|
||||
shape: several are multipart, and averaging a main body with its exclaves can
|
||||
land the point on a neighbour. Expect 1-4 km between the point and the town
|
||||
centre, which is well inside what framing a map needs.
|
||||
|
||||
## 7. Base map — OpenFreeMap
|
||||
|
||||
OpenStreetMap vector tiles served by **OpenFreeMap** (<https://openfreemap.org>).
|
||||
|
||||
@@ -321,7 +352,7 @@ bounding box — flat background plus the extent outline, no network sources at
|
||||
deliberately plain so it is never mistaken for a finished map, and it is what the widget
|
||||
tests run against.
|
||||
|
||||
## 7. Lightning — out of scope
|
||||
## 8. Lightning — out of scope
|
||||
|
||||
Not part of the app. If it is ever revisited: **Blitzortung prohibits commercial use**,
|
||||
and the DPC `LTG` product is the source to reach for instead.
|
||||
|
||||
@@ -14,6 +14,7 @@ to save screen space.
|
||||
| OpenStreetMap data | ODbL | "© OpenStreetMap contributors" | Must stay visible on the map |
|
||||
| OpenMapTiles schema | BSD 3-Clause | "© OpenMapTiles" | Must stay visible on the map |
|
||||
| OpenFreeMap service | MIT | "OpenFreeMap" — optional | Listed on the Sources screen |
|
||||
| Istat municipality boundaries | CC BY 4.0 | "Istat" | Bundled municipality list, commercial use permitted |
|
||||
|
||||
## Share-alike is the constraint that shapes the backend
|
||||
|
||||
|
||||
@@ -48,6 +48,17 @@ Accuracy", and declining it yields **no fix at all** — an absurd outcome for a
|
||||
that only ever wanted an approximate one. It also means location works on devices
|
||||
without Play Services.
|
||||
|
||||
### Following, and continuous updates
|
||||
|
||||
Selecting "segui la mia posizione" hands tracking to the map's own location
|
||||
component, which receives updates continuously while the app is in the
|
||||
foreground. That is more than the single fix everything else needs, so it is
|
||||
opt-in, it is visible — the chip at the top of the map says so — and it stops the
|
||||
moment the user drags the map away.
|
||||
|
||||
It never runs in the background: there is no background location permission and
|
||||
no foreground service, so closing the app ends it.
|
||||
|
||||
### The permission flow
|
||||
|
||||
- A **prominent disclosure** is shown before the system dialog, as Google Play requires,
|
||||
|
||||
@@ -78,7 +78,7 @@ and a disk layer belongs with the network adapter, where it would save a real re
|
||||
|
||||
---
|
||||
|
||||
## M4 — Saved places and device location ✅
|
||||
## M4 — Places, search and device location ✅
|
||||
|
||||
A named point the user keeps, used to frame the map and — once notifications exist — to
|
||||
anchor them. Stored in `SharedPreferences` on the device and nowhere else.
|
||||
@@ -104,6 +104,22 @@ A unit test also caught an id collision: ids came straight from the microsecond
|
||||
so two places saved in the same microsecond shared an id and rename, remove and the
|
||||
duplicate check all acted on the wrong one.
|
||||
|
||||
**Extended** with search and a target selector, also verified on the emulator:
|
||||
|
||||
- One search field takes either a municipality name or coordinates, because a string
|
||||
either parses as coordinates or it does not and the user should not have to declare
|
||||
which they meant. Accent-insensitive: typing "aglie" finds "Agliè". Coordinates accept
|
||||
decimal and degrees-minutes-seconds, comma or space separated, with hemisphere letters
|
||||
including the Italian **O** for west.
|
||||
- The 1180 municipalities are bundled from Istat boundaries rather than geocoded online.
|
||||
A test asserts every one of them falls inside the region bounds, which is what
|
||||
validates the UTM-to-degrees conversion end to end.
|
||||
- A dropdown over the map switches between following the device, a saved place, and the
|
||||
whole region; the selection is also what the app reopens on. Dragging the map while it
|
||||
is following stops the camera chasing but leaves that preference alone.
|
||||
- The blue position marker and the following are MapLibre's own, so there is no custom
|
||||
location stream to keep in step with the map.
|
||||
|
||||
## M5 — Backend worker ⛔
|
||||
|
||||
The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Generate the searchable list of Piedmont municipalities.
|
||||
|
||||
The app is region-scoped, so a bundled list beats a geocoding service on every
|
||||
axis that matters here: the search is instant, works with no network, needs no
|
||||
API key, has no rate limit to respect, and cannot return a result in a place the
|
||||
app has no radar for.
|
||||
|
||||
Source: Istat "Confini delle unità amministrative a fini statistici",
|
||||
generalised boundaries, published under CC BY 4.0. The shapefile is in WGS84 /
|
||||
UTM zone 32N, so each municipality's representative point is converted back to
|
||||
latitude and longitude here rather than at run time.
|
||||
|
||||
Dependencies: `pyshp` only. The inverse transverse Mercator is implemented below
|
||||
instead of pulling in pyproj, because that is the single piece of geodesy this
|
||||
script needs and a whole projection library is a heavy thing to make everyone
|
||||
install for it.
|
||||
|
||||
Usage:
|
||||
python -m pip install pyshp
|
||||
python tool/generate_places.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
import shapefile # pyshp
|
||||
|
||||
SOURCE_URL = (
|
||||
"https://www.istat.it/storage/cartografia/confini_amministrativi/"
|
||||
"generalizzati/2025/Limiti01012025_g.zip"
|
||||
)
|
||||
SHAPE_BASE = "Com01012025_g/Com01012025_g_WGS84"
|
||||
|
||||
# Istat region code for Piemonte.
|
||||
REGION_CODE = 1
|
||||
|
||||
# Istat province codes to the two-letter abbreviations people actually recognise.
|
||||
PROVINCES = {
|
||||
1: "TO",
|
||||
2: "VC",
|
||||
3: "NO",
|
||||
4: "CN",
|
||||
5: "AT",
|
||||
6: "AL",
|
||||
96: "BI",
|
||||
103: "VB",
|
||||
}
|
||||
|
||||
OUT_PATH = (
|
||||
pathlib.Path(__file__).resolve().parent.parent
|
||||
/ "app"
|
||||
/ "assets"
|
||||
/ "regions"
|
||||
/ "piemonte_comuni.json"
|
||||
)
|
||||
|
||||
CACHE_PATH = pathlib.Path(__file__).resolve().parent / ".cache" / "istat_limiti.zip"
|
||||
|
||||
# --- WGS84 / UTM 32N inverse -------------------------------------------------
|
||||
|
||||
_A = 6378137.0 # WGS84 semi-major axis
|
||||
_F = 1 / 298.257223563 # flattening
|
||||
_K0 = 0.9996 # UTM scale factor
|
||||
_FALSE_EASTING = 500000.0
|
||||
_CENTRAL_MERIDIAN = math.radians(9.0) # zone 32
|
||||
|
||||
|
||||
def utm32n_to_wgs84(easting: float, northing: float) -> tuple[float, float]:
|
||||
"""Converts UTM zone 32N metres to (longitude, latitude) in degrees.
|
||||
|
||||
Snyder's inverse transverse Mercator series, accurate to a few centimetres
|
||||
inside the zone — orders of magnitude finer than a municipality centroid
|
||||
needs to be.
|
||||
"""
|
||||
e2 = _F * (2 - _F)
|
||||
e_prime2 = e2 / (1 - e2)
|
||||
|
||||
x = easting - _FALSE_EASTING
|
||||
m = northing / _K0
|
||||
|
||||
mu = m / (_A * (1 - e2 / 4 - 3 * e2**2 / 64 - 5 * e2**3 / 256))
|
||||
e1 = (1 - math.sqrt(1 - e2)) / (1 + math.sqrt(1 - e2))
|
||||
|
||||
phi1 = (
|
||||
mu
|
||||
+ (3 * e1 / 2 - 27 * e1**3 / 32) * math.sin(2 * mu)
|
||||
+ (21 * e1**2 / 16 - 55 * e1**4 / 32) * math.sin(4 * mu)
|
||||
+ (151 * e1**3 / 96) * math.sin(6 * mu)
|
||||
+ (1097 * e1**4 / 512) * math.sin(8 * mu)
|
||||
)
|
||||
|
||||
sin_phi1 = math.sin(phi1)
|
||||
cos_phi1 = math.cos(phi1)
|
||||
tan_phi1 = math.tan(phi1)
|
||||
|
||||
c1 = e_prime2 * cos_phi1**2
|
||||
t1 = tan_phi1**2
|
||||
n1 = _A / math.sqrt(1 - e2 * sin_phi1**2)
|
||||
r1 = _A * (1 - e2) / (1 - e2 * sin_phi1**2) ** 1.5
|
||||
d = x / (n1 * _K0)
|
||||
|
||||
latitude = phi1 - (n1 * tan_phi1 / r1) * (
|
||||
d**2 / 2
|
||||
- (5 + 3 * t1 + 10 * c1 - 4 * c1**2 - 9 * e_prime2) * d**4 / 24
|
||||
+ (61 + 90 * t1 + 298 * c1 + 45 * t1**2 - 252 * e_prime2 - 3 * c1**2)
|
||||
* d**6
|
||||
/ 720
|
||||
)
|
||||
|
||||
longitude = _CENTRAL_MERIDIAN + (
|
||||
d
|
||||
- (1 + 2 * t1 + c1) * d**3 / 6
|
||||
+ (5 - 2 * c1 + 28 * t1 - 3 * c1**2 + 8 * e_prime2 + 24 * t1**2)
|
||||
* d**5
|
||||
/ 120
|
||||
) / cos_phi1
|
||||
|
||||
return math.degrees(longitude), math.degrees(latitude)
|
||||
|
||||
|
||||
# --- geometry ----------------------------------------------------------------
|
||||
|
||||
|
||||
def ring_area_and_centroid(ring: list[tuple[float, float]]) -> tuple[float, float, float]:
|
||||
"""Signed area and centroid of a closed ring, by the shoelace formula."""
|
||||
area2 = 0.0
|
||||
cx = 0.0
|
||||
cy = 0.0
|
||||
for i in range(len(ring) - 1):
|
||||
x0, y0 = ring[i]
|
||||
x1, y1 = ring[i + 1]
|
||||
cross = x0 * y1 - x1 * y0
|
||||
area2 += cross
|
||||
cx += (x0 + x1) * cross
|
||||
cy += (y0 + y1) * cross
|
||||
if area2 == 0:
|
||||
# Degenerate ring: fall back to the mean of its vertices.
|
||||
xs = [p[0] for p in ring]
|
||||
ys = [p[1] for p in ring]
|
||||
return 0.0, sum(xs) / len(xs), sum(ys) / len(ys)
|
||||
return area2 / 2, cx / (3 * area2), cy / (3 * area2)
|
||||
|
||||
|
||||
def representative_point(shape: shapefile.Shape) -> tuple[float, float]:
|
||||
"""A point inside the municipality, in the shapefile's own coordinates.
|
||||
|
||||
Uses the centroid of the **largest** ring rather than of the whole shape.
|
||||
Many Piedmont municipalities are multipart — a main body plus exclaves — and
|
||||
averaging those together can land the point on a neighbour.
|
||||
"""
|
||||
parts = list(shape.parts) + [len(shape.points)]
|
||||
best_area = -1.0
|
||||
best = (shape.points[0][0], shape.points[0][1])
|
||||
|
||||
for start, end in zip(parts, parts[1:]):
|
||||
ring = [(p[0], p[1]) for p in shape.points[start:end]]
|
||||
if len(ring) < 4:
|
||||
continue
|
||||
if ring[0] != ring[-1]:
|
||||
ring.append(ring[0])
|
||||
area, cx, cy = ring_area_and_centroid(ring)
|
||||
if abs(area) > best_area:
|
||||
best_area = abs(area)
|
||||
best = (cx, cy)
|
||||
|
||||
return best
|
||||
|
||||
|
||||
# --- main --------------------------------------------------------------------
|
||||
|
||||
|
||||
def fetch_archive() -> bytes:
|
||||
if CACHE_PATH.exists():
|
||||
print(f"using cached {CACHE_PATH}")
|
||||
return CACHE_PATH.read_bytes()
|
||||
|
||||
print(f"downloading {SOURCE_URL}")
|
||||
with urllib.request.urlopen(SOURCE_URL, timeout=300) as response:
|
||||
data = response.read()
|
||||
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_PATH.write_bytes(data)
|
||||
print(f"cached {len(data) / 1_000_000:.1f} MB at {CACHE_PATH}")
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
archive = zipfile.ZipFile(io.BytesIO(fetch_archive()))
|
||||
reader = shapefile.Reader(
|
||||
shp=io.BytesIO(archive.read(SHAPE_BASE + ".shp")),
|
||||
dbf=io.BytesIO(archive.read(SHAPE_BASE + ".dbf")),
|
||||
shx=io.BytesIO(archive.read(SHAPE_BASE + ".shx")),
|
||||
# UTF-8, confirmed by decoding a known name down to its codepoints:
|
||||
# "Aglie" ends in a single U+00E8 under utf-8, and in U+00C3 U+00A8
|
||||
# under latin-1. A terminal renders both identically, so do not
|
||||
# settle this by looking at printed output -- the guard below is
|
||||
# what actually holds the line.
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
places = []
|
||||
unknown_provinces = set()
|
||||
|
||||
for record, shape in zip(reader.iterRecords(), reader.iterShapes()):
|
||||
if record["COD_REG"] != REGION_CODE:
|
||||
continue
|
||||
|
||||
province = PROVINCES.get(record["COD_PROV"])
|
||||
if province is None:
|
||||
unknown_provinces.add(record["COD_PROV"])
|
||||
continue
|
||||
|
||||
easting, northing = representative_point(shape)
|
||||
longitude, latitude = utm32n_to_wgs84(easting, northing)
|
||||
|
||||
places.append(
|
||||
{
|
||||
"name": record["COMUNE"],
|
||||
"province": province,
|
||||
"istat": record["PRO_COM_T"],
|
||||
"lat": round(latitude, 5),
|
||||
"lng": round(longitude, 5),
|
||||
}
|
||||
)
|
||||
|
||||
# Latin-1 is an assumption about someone else's file. If Istat ever
|
||||
# re-encodes it, the accents turn into these tell-tale sequences rather
|
||||
# than into an error, so check for them explicitly. Written as escapes
|
||||
# so this guard cannot itself be broken by an encoding accident.
|
||||
MOJIBAKE_MARKERS = ("Ã", "Â", "�")
|
||||
mis_decoded = [
|
||||
place["name"]
|
||||
for place in places
|
||||
if any(marker in place["name"] for marker in MOJIBAKE_MARKERS)
|
||||
]
|
||||
if mis_decoded:
|
||||
print(f"ERROR: names look mis-decoded: {mis_decoded[:5]}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if unknown_provinces:
|
||||
print(f"ERROR: unmapped province codes {sorted(unknown_provinces)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
places.sort(key=lambda place: place["name"])
|
||||
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT_PATH.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"region": "piemonte",
|
||||
"source": "Istat — Confini delle unità amministrative a fini statistici, 1 gennaio 2025",
|
||||
"license": "CC BY 4.0",
|
||||
"sourceUrl": SOURCE_URL,
|
||||
"places": places,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
size_kb = OUT_PATH.stat().st_size / 1024
|
||||
print(f"{len(places)} comuni written to {OUT_PATH} ({size_kb:.0f} KB)")
|
||||
|
||||
by_province: dict[str, int] = {}
|
||||
for place in places:
|
||||
by_province[place["province"]] = by_province.get(place["province"], 0) + 1
|
||||
print("by province:", dict(sorted(by_province.items())))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user