diff --git a/Nuvolari/CLAUDE.md b/Nuvolari/CLAUDE.md index 60be76b..8aefd0b 100644 --- a/Nuvolari/CLAUDE.md +++ b/Nuvolari/CLAUDE.md @@ -10,6 +10,10 @@ 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 +- 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 - "Rain incoming" notifications by geographic cell @@ -28,6 +32,10 @@ grounds that there are no ads today. - Never commit secrets. Use .env + --dart-define; keep .gitignore updated. - Never poll ARPA/DPC servers directly from the app: always go through our backend/CDN. - Never store precise user locations server-side: rain notifications use geographic CELLS. +- Location is COARSE only. geolocator injects ACCESS_FINE_LOCATION; the app manifest + removes it with `tools:node="remove"`. Do not add it back without a feature that needs + it and a matching Data safety update. Saved places live in SharedPreferences on the + device and never leave it. - Do not use ARPA/DPC name, logo or the word "ufficiale" in a way implying an official app. ## Architecture @@ -45,10 +53,19 @@ grounds that there are no ads today. "Radar-DPC"; derivative data products must stay CC BY-SA. Docs: dpc-radar.readthedocs.io. The rasters are on a **custom projection centred on Italy**, not EPSG:4326 or 3857, and their GeoKeys are internally inconsistent — read the CRS from each file, never hardcode it. -- Radar (future): ARPA Piemonte (HDF5 ODIM, 5-minute volumes). The data is free, but the - real-time access link **must be requested by email** at info.meteo@arpa.piemonte.it, and - ARPA states no licence — only that the data is free of charge. Both the request and the - licence question are the project owner's to resolve. Adapter stays a disabled stub. +- Radar (future): ARPA Piemonte (HDF5 ODIM, 5-minute volumes). The real-time access link + **must be requested by email** at info.meteo@arpa.piemonte.it — that request is the + project owner's to make. Adapter stays a disabled stub until it exists. +- Ground stations: ARPA realtime API, https://utility.arpa.piemonte.it/api_realtime — + no key, no registration. `/pie_anag` gives 374 stations with coordinates (286 with a + rain gauge); `/data_pie` gives hourly `cum_rain_1h/3h/6h/12h/24h`, temperature, wind, + snow and hydrometric level for the last 3 days. **It lags ~4.5 hours** — an observation + archive, not a live feed, and it must never be shown as current next to 5-minute radar. + Fetched by the worker, never by the app. +- ARPA licence: CC BY 4.0 per https://www.arpa.piemonte.it/note-legali, commercial use + permitted, credit "Fonte: Arpa Piemonte - www.arpa.piemonte.it". Both REST APIs link + that notice from their OpenAPI description. The radar page does not repeat it, so ask + ARPA to confirm it covers the radar volumes in the same email. - Alerts: ARPA Piemonte XML-CAP bulletin at https://www.arpa.piemonte.it/export/xmlcap/allerta.xml — reproduce alert levels WITHOUT reinterpreting; link the official channel. Six level values, not four: VERDE, GIALLO, diff --git a/Nuvolari/app/android/app/src/main/AndroidManifest.xml b/Nuvolari/app/android/app/src/main/AndroidManifest.xml index 2e00092..f45462e 100644 --- a/Nuvolari/app/android/app/src/main/AndroidManifest.xml +++ b/Nuvolari/app/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,24 @@ - + + + + + + + 'GeoPoint($longitude, $latitude)'; diff --git a/Nuvolari/app/lib/features/map/radar_map_screen.dart b/Nuvolari/app/lib/features/map/radar_map_screen.dart index 522b24a..ef88d0e 100644 --- a/Nuvolari/app/lib/features/map/radar_map_screen.dart +++ b/Nuvolari/app/lib/features/map/radar_map_screen.dart @@ -5,11 +5,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import '../../core/config/env.dart'; +import '../../core/region/geo.dart'; import '../../core/region/region_config.dart'; import '../../core/region/region_repository.dart'; import '../../data/radar/radar_manifest.dart'; import '../../data/radar/radar_source.dart'; import '../../l10n/app_localizations.dart'; +import '../places/location_flow.dart'; +import '../places/places_screen.dart'; +import '../places/saved_place.dart'; +import '../places/saved_places.dart'; +import '../settings/settings_screen.dart'; import '../timeline/data_age_banner.dart'; import '../timeline/radar_timeline.dart'; import '../timeline/timeline_bar.dart'; @@ -17,6 +23,119 @@ import 'attribution_bar.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 { + @override + MapLibreMapController? build() => null; + + void attach(MapLibreMapController controller) => state = controller; + + void detach() => state = null; +} + +final mapControllerProvider = + NotifierProvider( + MapControllerHolder.new, + ); + +/// Moves the map onto [point], keeping the current zoom. +Future _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( + MaterialPageRoute( + 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(builder: (_) => const SettingsScreen())), + ); + } +} + /// The radar map: base map, animated precipitation overlay, timeline. class RadarMapScreen extends ConsumerWidget { const RadarMapScreen({super.key}); @@ -27,7 +146,10 @@ class RadarMapScreen extends ConsumerWidget { final region = ref.watch(regionConfigProvider); return Scaffold( - appBar: AppBar(title: Text(l10n.appTitle)), + appBar: AppBar( + title: Text(l10n.appTitle), + actions: const [_LocateAction(), _PlacesAction(), _SettingsAction()], + ), body: switch (region) { AsyncData(:final value) => _MapWithAttribution(region: value), AsyncError(:final error) => Center( @@ -136,6 +258,11 @@ class _RegionMapState extends ConsumerState<_RegionMap> { 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.microtask( + () => ref.read(mapControllerProvider.notifier).detach(), + ).ignore(); super.dispose(); } @@ -148,6 +275,7 @@ class _RegionMapState extends ConsumerState<_RegionMap> { /// bounds works on every screen size. Future _onMapCreated(MapLibreMapController controller) async { _controller = controller; + ref.read(mapControllerProvider.notifier).attach(controller); final bounds = widget.region.bounds; await controller.moveCamera( @@ -209,6 +337,10 @@ class _RegionMapState extends ConsumerState<_RegionMap> { 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), diff --git a/Nuvolari/app/lib/features/places/location_flow.dart b/Nuvolari/app/lib/features/places/location_flow.dart new file mode 100644 index 0000000..332c1e1 --- /dev/null +++ b/Nuvolari/app/lib/features/places/location_flow.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../../core/region/geo.dart'; +import '../../l10n/app_localizations.dart'; +import 'location_service.dart'; + +/// Obtains a position, explaining why before asking. +/// +/// Google Play requires a prominent disclosure **before** the system permission +/// dialog: the user has to be told what is collected and why while they can +/// still decline without a dialog in their face. That ordering is the whole +/// reason this lives in one function instead of being scattered across the +/// screens that need a fix. +/// +/// Returns null whenever no position is available, having already told the user +/// why. Callers fall back to the region centre rather than blocking. +Future requestPosition(BuildContext context, WidgetRef ref) async { + final l10n = AppLocalizations.of(context); + final service = ref.read(locationServiceProvider); + + var availability = await service.availability(); + if (!context.mounted) return null; + + if (availability == LocationAvailability.serviceDisabled) { + _explain( + context, + l10n.locationStatusServiceDisabled, + actionLabel: l10n.locationOpenSystemSettings, + onAction: Geolocator.openLocationSettings, + ); + return null; + } + + if (availability == LocationAvailability.deniedForever) { + _explain( + context, + l10n.locationStatusDeniedForever, + actionLabel: l10n.locationOpenSystemSettings, + onAction: Geolocator.openAppSettings, + ); + return null; + } + + if (availability == LocationAvailability.denied) { + final shown = ref.read(locationDisclosureShownProvider); + if (!shown) { + final accepted = await showLocationDisclosure(context); + if (!context.mounted) return null; + if (!accepted) return null; + ref.read(locationDisclosureShownProvider.notifier).markShown(); + } + + availability = await service.request(); + if (!context.mounted) return null; + + if (availability != LocationAvailability.granted) { + _explain( + context, + availability == LocationAvailability.deniedForever + ? l10n.locationStatusDeniedForever + : l10n.locationStatusDenied, + ); + return null; + } + } + + final point = await service.currentPoint(); + if (!context.mounted) return null; + + if (point == null) { + _explain(context, l10n.locationUnavailable); + return null; + } + return point; +} + +/// Shows the prominent disclosure. Returns true if the user chose to continue. +Future showLocationDisclosure(BuildContext context) async { + final l10n = AppLocalizations.of(context); + + final accepted = await showDialog( + context: context, + builder: (context) => AlertDialog( + icon: const Icon(Icons.my_location), + title: Text(l10n.locationDisclosureTitle), + content: SingleChildScrollView(child: Text(l10n.locationDisclosureBody)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.locationDisclosureDecline), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.locationDisclosureContinue), + ), + ], + ), + ); + return accepted ?? false; +} + +void _explain( + BuildContext context, + String message, { + String? actionLabel, + VoidCallback? onAction, +}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 6), + action: actionLabel == null || onAction == null + ? null + : SnackBarAction(label: actionLabel, onPressed: onAction), + ), + ); +} diff --git a/Nuvolari/app/lib/features/places/location_service.dart b/Nuvolari/app/lib/features/places/location_service.dart new file mode 100644 index 0000000..15b7686 --- /dev/null +++ b/Nuvolari/app/lib/features/places/location_service.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../../core/region/geo.dart'; + +/// What stands between the app and a position fix. +enum LocationAvailability { + /// Ready to locate. + granted, + + /// Location is switched off on the device. The user has to enable it in + /// system settings; asking for permission would not help. + serviceDisabled, + + /// Not granted yet, but askable. + denied, + + /// Refused permanently. Only system settings can undo it, so the UI must send + /// the user there rather than asking again into the void. + deniedForever, +} + +/// Reads the device position. +/// +/// Behind an interface so the permission dance can be tested without a device +/// and without the plugin's static methods. +abstract interface class LocationService { + Future availability(); + + /// Asks for permission if it has not been granted yet. + Future request(); + + /// A single coarse fix, or null if it could not be obtained. + Future currentPoint(); +} + +class GeolocatorLocationService implements LocationService { + const GeolocatorLocationService(); + + /// Deliberately coarse. + /// + /// This app centres a map and picks the nearest weather station, and stations + /// are kilometres apart. Street-level precision would buy nothing and would + /// mean asking for a more invasive permission, so the app declares only + /// `ACCESS_COARSE_LOCATION` and asks for low accuracy to match. + /// + /// `forceLocationManager` uses the platform's own LocationManager instead of + /// the Google Play Services fused provider. The fused provider triggers a + /// "your device will need to use Location Accuracy" prompt, and declining it + /// yields **no fix at all** — an absurd outcome for an app that only wants an + /// approximate one. It also drops the Play Services dependency, so location + /// works on devices that do not have them. + static LocationSettings get _settings { + if (defaultTargetPlatform == TargetPlatform.android) { + return AndroidSettings( + accuracy: LocationAccuracy.low, + forceLocationManager: true, + timeLimit: const Duration(seconds: 20), + ); + } + return const LocationSettings( + accuracy: LocationAccuracy.low, + timeLimit: Duration(seconds: 20), + ); + } + + @override + Future availability() async { + if (!await Geolocator.isLocationServiceEnabled()) { + return LocationAvailability.serviceDisabled; + } + return _map(await Geolocator.checkPermission()); + } + + @override + Future request() async { + if (!await Geolocator.isLocationServiceEnabled()) { + return LocationAvailability.serviceDisabled; + } + + final current = await Geolocator.checkPermission(); + if (current == LocationPermission.denied) { + return _map(await Geolocator.requestPermission()); + } + return _map(current); + } + + @override + Future currentPoint() async { + if (await availability() != LocationAvailability.granted) return null; + + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: _settings, + ); + return GeoPoint(position.longitude, position.latitude); + } on Object { + // A fresh fix can take seconds indoors, or time out entirely. For + // centring a map a slightly stale position is worth far more than + // nothing, so fall back to whatever the system already knows. + try { + final last = await Geolocator.getLastKnownPosition(); + if (last == null) return null; + return GeoPoint(last.longitude, last.latitude); + } on Object { + return null; + } + } + } + + static LocationAvailability _map(LocationPermission permission) => + switch (permission) { + LocationPermission.always || + LocationPermission.whileInUse => LocationAvailability.granted, + LocationPermission.deniedForever => LocationAvailability.deniedForever, + LocationPermission.denied || + LocationPermission.unableToDetermine => LocationAvailability.denied, + }; +} + +final locationServiceProvider = Provider( + (ref) => const GeolocatorLocationService(), +); + +/// Whether the user has seen the prominent disclosure in this session. +/// +/// Google Play requires the explanation to come **before** the system dialog. +/// Session-scoped rather than persisted: showing it again after an app restart +/// costs one tap, and a user who forgot what they agreed to deserves to be +/// reminded. +class LocationDisclosureShown extends Notifier { + @override + bool build() => false; + + void markShown() => state = true; +} + +final locationDisclosureShownProvider = + NotifierProvider( + LocationDisclosureShown.new, + ); diff --git a/Nuvolari/app/lib/features/places/places_repository.dart b/Nuvolari/app/lib/features/places/places_repository.dart new file mode 100644 index 0000000..9dc5c55 --- /dev/null +++ b/Nuvolari/app/lib/features/places/places_repository.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'saved_place.dart'; + +/// Stores saved places on the device. +/// +/// Local storage, and only local storage. A saved place is the closest thing +/// this app has to a home address; it never reaches a server, which is what +/// lets the Data safety declaration say that no location is collected. See +/// docs/privacy.md. +abstract interface class PlacesStore { + Future> load(); + + Future save(List places); +} + +class SharedPreferencesPlacesStore implements PlacesStore { + const SharedPreferencesPlacesStore(); + + static const String storageKey = 'nuvolari.saved_places.v1'; + + @override + Future> load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(storageKey); + if (raw == null || raw.isEmpty) return const []; + + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded + .whereType>() + .map(SavedPlace.fromJson) + .toList(growable: false); + } on Object { + // Corrupt storage loses the places, which is bad, but throwing here would + // make the app unopenable, which is worse. Starting empty is recoverable + // by the user; a crash loop is not. + return const []; + } + } + + @override + Future save(List places) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + storageKey, + jsonEncode(places.map((place) => place.toJson()).toList(growable: false)), + ); + } +} + +/// An in-memory store, for tests and for previewing without touching the device. +class InMemoryPlacesStore implements PlacesStore { + InMemoryPlacesStore([List? initial]) + : _places = List.of(initial ?? const []); + + List _places; + + @override + Future> load() async => + List.unmodifiable(_places); + + @override + Future save(List places) async { + _places = List.of(places); + } +} diff --git a/Nuvolari/app/lib/features/places/places_screen.dart b/Nuvolari/app/lib/features/places/places_screen.dart new file mode 100644 index 0000000..e4f69a8 --- /dev/null +++ b/Nuvolari/app/lib/features/places/places_screen.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/region/geo.dart'; +import '../../l10n/app_localizations.dart'; +import 'location_flow.dart'; +import 'saved_place.dart'; +import 'saved_places.dart'; + +/// The saved places list. +/// +/// Returns the chosen [SavedPlace] to the caller when one is tapped, so the map +/// can centre on it without this screen knowing anything about the map. +class PlacesScreen extends ConsumerWidget { + const PlacesScreen({required this.mapCentre, super.key}); + + /// Where the map is looking right now, offered as a one-tap thing to save. + final GeoPoint mapCentre; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final places = ref.watch(savedPlacesProvider); + + return Scaffold( + appBar: AppBar(title: Text(l10n.placesTitle)), + body: switch (places) { + AsyncData(:final value) => _PlacesList( + places: value, + mapCentre: mapCentre, + ), + AsyncError(:final error) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('${l10n.dataUnavailable}\n$error'), + ), + ), + _ => Center(child: Text(l10n.loading)), + }, + ); + } +} + +class _PlacesList extends ConsumerWidget { + const _PlacesList({required this.places, required this.mapCentre}); + + final List places; + final GeoPoint mapCentre; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final isFull = places.length >= SavedPlaces.maxPlaces; + + return Column( + children: [ + Expanded( + child: places.isEmpty + ? _EmptyState() + : ListView.separated( + itemCount: places.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) => + _PlaceTile(place: places[index]), + ), + ), + if (isFull) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + l10n.placesFull(SavedPlaces.maxPlaces), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + children: [ + FilledButton.icon( + onPressed: isFull + ? null + : () => _addFromDevicePosition(context, ref), + icon: const Icon(Icons.my_location), + label: Text(l10n.placeAddCurrent), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: isFull + ? null + : () => _promptAndAdd(context, ref, mapCentre), + icon: const Icon(Icons.center_focus_strong), + label: Text(l10n.placeAddMapCentre), + ), + ], + ), + ), + ), + ], + ); + } + + Future _addFromDevicePosition( + BuildContext context, + WidgetRef ref, + ) async { + final point = await requestPosition(context, ref); + if (point == null || !context.mounted) return; + await _promptAndAdd(context, ref, point); + } +} + +class _EmptyState extends StatelessWidget { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.place_outlined, + size: 48, + color: theme.colorScheme.outline, + ), + const SizedBox(height: 16), + Text(l10n.placesEmpty, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + l10n.placesEmptyHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ], + ), + ), + ); + } +} + +class _PlaceTile extends ConsumerWidget { + const _PlaceTile({required this.place}); + + final SavedPlace place; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + + return ListTile( + leading: const Icon(Icons.place), + title: Text(place.name), + subtitle: Text(formatPoint(place.point)), + onTap: () => Navigator.of(context).pop(place), + trailing: PopupMenuButton( + onSelected: (value) async { + if (value == 'rename') { + await _rename(context, ref, place); + } else if (value == 'delete') { + await _delete(context, ref, place); + } + }, + itemBuilder: (context) => [ + PopupMenuItem(value: 'rename', child: Text(l10n.placeRename)), + PopupMenuItem(value: 'delete', child: Text(l10n.placeDelete)), + ], + ), + ); + } +} + +/// Coordinates at a precision that matches how they were obtained. +/// +/// Four decimals is about 10 m, which is finer than the coarse fix the app asks +/// for and finer than anyone needs to read. More digits would imply an accuracy +/// the app does not have. +String formatPoint(GeoPoint point) => + '${point.latitude.toStringAsFixed(4)}, ${point.longitude.toStringAsFixed(4)}'; + +Future _promptAndAdd( + BuildContext context, + WidgetRef ref, + GeoPoint point, +) async { + final name = await _askForName(context); + if (name == null || !context.mounted) return; + + try { + await ref.read(savedPlacesProvider.notifier).add(name: name, point: point); + } on SavedPlaceException catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(messageFor(AppLocalizations.of(context), error))), + ); + } +} + +Future _rename( + BuildContext context, + WidgetRef ref, + SavedPlace place, +) async { + final name = await _askForName(context, initial: place.name); + if (name == null || !context.mounted) return; + + try { + await ref.read(savedPlacesProvider.notifier).rename(place.id, name); + } on SavedPlaceException catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(messageFor(AppLocalizations.of(context), error))), + ); + } +} + +Future _delete( + BuildContext context, + WidgetRef ref, + SavedPlace place, +) async { + final l10n = AppLocalizations.of(context); + await ref.read(savedPlacesProvider.notifier).remove(place.id); + if (!context.mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(l10n.placeDeleted(place.name)))); +} + +Future _askForName(BuildContext context, {String? initial}) { + final l10n = AppLocalizations.of(context); + final controller = TextEditingController(text: initial); + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(initial == null ? l10n.placeAddMapCentre : l10n.placeRename), + content: TextField( + controller: controller, + autofocus: true, + maxLength: SavedPlace.maxNameLength, + decoration: InputDecoration( + labelText: l10n.placeNameLabel, + hintText: l10n.placeNameHint, + ), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text), + child: Text(l10n.save), + ), + ], + ), + ); +} + +/// Turns a rejection into the sentence that explains it. +String messageFor(AppLocalizations l10n, SavedPlaceException error) => + switch (error.rejection) { + SavedPlaceRejection.emptyName => l10n.placeErrorEmptyName, + SavedPlaceRejection.nameTooLong => l10n.placeErrorNameTooLong( + SavedPlace.maxNameLength, + ), + SavedPlaceRejection.duplicateName => l10n.placeErrorDuplicateName, + SavedPlaceRejection.outsideRegion => l10n.placeErrorOutsideRegion, + }; diff --git a/Nuvolari/app/lib/features/places/saved_place.dart b/Nuvolari/app/lib/features/places/saved_place.dart new file mode 100644 index 0000000..4486073 --- /dev/null +++ b/Nuvolari/app/lib/features/places/saved_place.dart @@ -0,0 +1,120 @@ +import '../../core/region/geo.dart'; + +/// A place the user has named and kept. +/// +/// The anchor everything location-shaped hangs off: which part of the region the +/// radar map opens on, and — once the worker publishes them — which alert zone +/// and which rain cell the notifications for this place should follow. +/// +/// A free point rather than a station: people think in terms of home and work, +/// not in terms of which weather station happens to represent them. The nearest +/// station is derived from the point when readings are shown, so the user can +/// always see how far away the measurement actually came from. +class SavedPlace { + const SavedPlace({ + required this.id, + required this.name, + required this.point, + required this.createdAt, + }); + + factory SavedPlace.fromJson(Map json) { + final id = json['id']; + final name = json['name']; + final longitude = json['lng']; + final latitude = json['lat']; + final createdAt = json['createdAt']; + + if (id is! String || id.isEmpty) { + throw const FormatException('saved place id must be a non-empty string'); + } + if (name is! String || name.isEmpty) { + throw const FormatException( + 'saved place name must be a non-empty string', + ); + } + if (longitude is! num || latitude is! num) { + throw const FormatException('saved place coordinates must be numbers'); + } + if (createdAt is! int) { + throw const FormatException( + 'saved place createdAt must be an epoch in ms', + ); + } + + return SavedPlace( + id: id, + name: name, + point: GeoPoint(longitude.toDouble(), latitude.toDouble()), + createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt, isUtc: true), + ); + } + + /// Longest name the UI will accept. + /// + /// Not a storage limit — it keeps a name readable in the one-line list and in + /// a notification title, where a long string would simply be truncated + /// somewhere unhelpful. + static const int maxNameLength = 40; + + final String id; + final String name; + final GeoPoint point; + final DateTime createdAt; + + Map toJson() => { + 'id': id, + 'name': name, + 'lng': point.longitude, + 'lat': point.latitude, + 'createdAt': createdAt.millisecondsSinceEpoch, + }; + + SavedPlace copyWith({String? name, GeoPoint? point}) => SavedPlace( + id: id, + name: name ?? this.name, + point: point ?? this.point, + createdAt: createdAt, + ); + + @override + String toString() => 'SavedPlace($id, $name, $point)'; + + @override + bool operator ==(Object other) => + other is SavedPlace && + other.id == id && + other.name == name && + other.point == point && + other.createdAt == createdAt; + + @override + int get hashCode => Object.hash(id, name, point, createdAt); +} + +/// Why a place could not be saved. +/// +/// Modelled rather than returned as a bare bool so the UI can say which rule was +/// broken instead of a generic failure. +enum SavedPlaceRejection { + /// The name was empty or only whitespace. + emptyName, + + /// The name exceeded [SavedPlace.maxNameLength]. + nameTooLong, + + /// The point lies outside the region the app has data for. + outsideRegion, + + /// Another saved place already uses that name. + duplicateName, +} + +class SavedPlaceException implements Exception { + const SavedPlaceException(this.rejection); + + final SavedPlaceRejection rejection; + + @override + String toString() => 'SavedPlaceException(${rejection.name})'; +} diff --git a/Nuvolari/app/lib/features/places/saved_places.dart b/Nuvolari/app/lib/features/places/saved_places.dart new file mode 100644 index 0000000..7902032 --- /dev/null +++ b/Nuvolari/app/lib/features/places/saved_places.dart @@ -0,0 +1,158 @@ +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 'places_repository.dart'; +import 'saved_place.dart'; + +final placesStoreProvider = Provider( + (ref) => const SharedPreferencesPlacesStore(), +); + +/// The user's saved places, newest first. +class SavedPlaces extends AsyncNotifier> { + /// Beyond this the list stops being a shortcut and becomes a directory to + /// search through, which is not what it is for. + static const int maxPlaces = 20; + + @override + Future> build() => ref.watch(placesStoreProvider).load(); + + /// Adds a place at [point] called [name]. + /// + /// Throws [SavedPlaceException] naming the rule that was broken, so the UI can + /// explain the refusal rather than swallowing it. + Future add({ + required String name, + required GeoPoint point, + }) async { + final trimmed = name.trim(); + _validateName(trimmed); + await _validatePoint(point); + + final current = List.of(state.value ?? const []); + if (current.any((p) => p.name.toLowerCase() == trimmed.toLowerCase())) { + throw const SavedPlaceException(SavedPlaceRejection.duplicateName); + } + + final place = SavedPlace( + id: _newId(current), + name: trimmed, + point: point, + createdAt: DateTime.now().toUtc(), + ); + + // Newest first: the place just added is the one most likely to be tapped. + final updated = [place, ...current]; + if (updated.length > maxPlaces) { + updated.removeRange(maxPlaces, updated.length); + } + + await _persist(updated); + return place; + } + + Future rename(String id, String name) async { + final trimmed = name.trim(); + _validateName(trimmed); + + final current = List.of(state.value ?? const []); + if (current.any( + (p) => p.id != id && p.name.toLowerCase() == trimmed.toLowerCase(), + )) { + throw const SavedPlaceException(SavedPlaceRejection.duplicateName); + } + + await _persist([ + for (final place in current) + if (place.id == id) place.copyWith(name: trimmed) else place, + ]); + } + + Future remove(String id) async { + final current = state.value ?? const []; + await _persist( + current + .where((SavedPlace place) => place.id != id) + .toList(growable: false), + ); + } + + Future clear() => _persist(const []); + + Future _persist(List places) async { + await ref.read(placesStoreProvider).save(places); + state = AsyncData>(List.unmodifiable(places)); + } + + /// An id no existing place is using. + /// + /// The clock alone is not enough: two places saved in the same microsecond + /// get the same id, and everything that addresses a place by id — rename, + /// remove, the duplicate-name check — then acts on the wrong one or on both. + /// A test caught exactly that on a fast machine. + String _newId(List existing) { + final taken = existing.map((place) => place.id).toSet(); + final base = 'p${DateTime.now().microsecondsSinceEpoch}'; + if (!taken.contains(base)) return base; + + var suffix = 1; + while (taken.contains('${base}_$suffix')) { + suffix++; + } + return '${base}_$suffix'; + } + + void _validateName(String name) { + if (name.isEmpty) { + throw const SavedPlaceException(SavedPlaceRejection.emptyName); + } + if (name.length > SavedPlace.maxNameLength) { + throw const SavedPlaceException(SavedPlaceRejection.nameTooLong); + } + } + + /// Refuses points the app has no data for. + /// + /// Saving a place in Rome would look like it worked and then show nothing + /// forever. Better to say so at the moment of saving, while the user still + /// knows what they were trying to do. + Future _validatePoint(GeoPoint point) async { + final region = await ref.read(regionConfigProvider.future); + if (!region.bounds.contains(point)) { + throw const SavedPlaceException(SavedPlaceRejection.outsideRegion); + } + } +} + +final savedPlacesProvider = + AsyncNotifierProvider>(SavedPlaces.new); + +/// The place the map is currently centred on, or null for the region default. +/// +/// Session state on purpose: which place you last looked at is not worth +/// persisting, and restoring it on launch would surprise someone who opened the +/// app to see the weather where they are now. +class ActivePlace extends Notifier { + @override + SavedPlace? build() => null; + + void select(SavedPlace? place) => state = place; +} + +final activePlaceProvider = NotifierProvider( + ActivePlace.new, +); + +/// Returns [places] ordered by distance from [point], nearest first. +List byDistanceFrom(GeoPoint point, List places) { + final sorted = List.of(places); + sorted.sort( + (a, b) => point.distanceTo(a.point).compareTo(point.distanceTo(b.point)), + ); + return sorted; +} + +/// Convenience for the region default when no place is active. +GeoPoint defaultCentreOf(RegionConfig region) => region.map.center; diff --git a/Nuvolari/app/lib/features/settings/settings_screen.dart b/Nuvolari/app/lib/features/settings/settings_screen.dart new file mode 100644 index 0000000..f10bc2d --- /dev/null +++ b/Nuvolari/app/lib/features/settings/settings_screen.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../../l10n/app_localizations.dart'; +import '../places/location_flow.dart'; +import '../places/location_service.dart'; +import '../places/saved_places.dart'; +import '../sources/sources_screen.dart'; + +/// Settings, currently just location. +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + LocationAvailability? _availability; + + @override + void initState() { + super.initState(); + unawaited(_refreshAvailability()); + } + + Future _refreshAvailability() async { + final availability = await ref.read(locationServiceProvider).availability(); + if (!mounted) return; + setState(() => _availability = availability); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final places = ref.watch(savedPlacesProvider).value ?? const []; + + return Scaffold( + appBar: AppBar(title: Text(l10n.settingsTitle)), + body: ListView( + children: [ + _SectionHeader(l10n.settingsLocationSection), + + // The disclosure text is shown here too, not only before the system + // dialog: someone who already granted or refused should still be able + // to read what the permission is actually used for. + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + l10n.locationDisclosureBody, + style: theme.textTheme.bodyMedium, + ), + ), + + ListTile( + leading: Icon(_iconFor(_availability)), + title: Text(_statusLabel(l10n, _availability)), + subtitle: _availability == LocationAvailability.granted + ? null + : Text(l10n.locateMe), + trailing: switch (_availability) { + LocationAvailability.denied => FilledButton( + onPressed: () async { + await requestPosition(context, ref); + await _refreshAvailability(); + }, + child: Text(l10n.locationDisclosureContinue), + ), + LocationAvailability.deniedForever => TextButton( + onPressed: () async { + await Geolocator.openAppSettings(); + await _refreshAvailability(); + }, + child: Text(l10n.locationOpenSystemSettings), + ), + LocationAvailability.serviceDisabled => TextButton( + onPressed: () async { + await Geolocator.openLocationSettings(); + await _refreshAvailability(); + }, + child: Text(l10n.locationOpenSystemSettings), + ), + _ => null, + }, + ), + + const Divider(), + _SectionHeader(l10n.placesTitle), + ListTile( + leading: const Icon(Icons.place_outlined), + title: Text(l10n.placesTitle), + subtitle: Text('${places.length} / ${SavedPlaces.maxPlaces}'), + ), + + const Divider(), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(l10n.sourcesTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SourcesScreen()), + ), + ), + ], + ), + ); + } + + static IconData _iconFor(LocationAvailability? availability) => + switch (availability) { + LocationAvailability.granted => Icons.my_location, + LocationAvailability.serviceDisabled => Icons.location_disabled, + null => Icons.location_searching, + _ => Icons.location_off, + }; + + static String _statusLabel( + AppLocalizations l10n, + LocationAvailability? availability, + ) => switch (availability) { + LocationAvailability.granted => l10n.locationStatusGranted, + LocationAvailability.denied => l10n.locationStatusDenied, + LocationAvailability.deniedForever => l10n.locationStatusDeniedForever, + LocationAvailability.serviceDisabled => l10n.locationStatusServiceDisabled, + null => l10n.loading, + }; +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ); + } +} diff --git a/Nuvolari/app/lib/l10n/app_it.arb b/Nuvolari/app/lib/l10n/app_it.arb index 941da93..a4ae0f7 100644 --- a/Nuvolari/app/lib/l10n/app_it.arb +++ b/Nuvolari/app/lib/l10n/app_it.arb @@ -151,5 +151,159 @@ "example": "4" } } + }, + "placesTitle": "Località salvate", + "@placesTitle": { + "description": "Title of the saved places screen" + }, + "placesOpen": "Località", + "@placesOpen": { + "description": "Tooltip on the app bar action that opens the saved places screen" + }, + "placesEmpty": "Nessuna località salvata", + "@placesEmpty": { + "description": "Empty state heading on the saved places screen" + }, + "placesEmptyHint": "Salva un punto per centrarci sopra il radar. In futuro le stesse località riceveranno le allerte di pioggia.", + "@placesEmptyHint": { + "description": "Empty state body explaining what saved places are for" + }, + "placesFull": "Hai raggiunto il massimo di {max} località", + "@placesFull": { + "description": "Shown when the saved place limit is reached", + "placeholders": { + "max": { + "type": "int", + "example": "20" + } + } + }, + "placeAddCurrent": "Salva la mia posizione", + "@placeAddCurrent": { + "description": "Button that saves the current device position as a place" + }, + "placeAddMapCentre": "Salva il centro della mappa", + "@placeAddMapCentre": { + "description": "Button that saves whatever the map is centred on as a place" + }, + "placeNameLabel": "Nome", + "@placeNameLabel": { + "description": "Label of the text field for a saved place name" + }, + "placeNameHint": "Casa, Lavoro, Baita…", + "@placeNameHint": { + "description": "Placeholder examples for a saved place name" + }, + "placeRename": "Rinomina", + "@placeRename": { + "description": "Menu entry to rename a saved place" + }, + "placeDelete": "Elimina", + "@placeDelete": { + "description": "Menu entry to delete a saved place" + }, + "placeDeleted": "«{name}» eliminata", + "@placeDeleted": { + "description": "Confirmation after deleting a saved place", + "placeholders": { + "name": { + "type": "String", + "example": "Casa" + } + } + }, + "placeCentreOnMap": "Centra sulla mappa", + "@placeCentreOnMap": { + "description": "Action that moves the radar map onto a saved place" + }, + "placeErrorEmptyName": "Serve un nome", + "@placeErrorEmptyName": { + "description": "Validation error when the place name is blank" + }, + "placeErrorNameTooLong": "Nome troppo lungo: massimo {max} caratteri", + "@placeErrorNameTooLong": { + "description": "Validation error when the place name exceeds the limit", + "placeholders": { + "max": { + "type": "int", + "example": "40" + } + } + }, + "placeErrorDuplicateName": "Esiste già una località con questo nome", + "@placeErrorDuplicateName": { + "description": "Validation error when another saved place has the same name" + }, + "placeErrorOutsideRegion": "Questo punto è fuori dall'area coperta dai dati", + "@placeErrorOutsideRegion": { + "description": "Validation error when the chosen point lies outside the region the app has data for" + }, + "settingsTitle": "Impostazioni", + "@settingsTitle": { + "description": "Title of the settings screen" + }, + "settingsOpen": "Impostazioni", + "@settingsOpen": { + "description": "Tooltip on the app bar action that opens settings" + }, + "settingsLocationSection": "Posizione", + "@settingsLocationSection": { + "description": "Heading of the location section in settings" + }, + "locateMe": "La mia posizione", + "@locateMe": { + "description": "Tooltip on the button that centres the map on the device position" + }, + "locationDisclosureTitle": "Uso della posizione", + "@locationDisclosureTitle": { + "description": "Title of the prominent disclosure shown before the system permission dialog" + }, + "locationDisclosureBody": "Nuvolari usa la tua posizione approssimativa per centrare la mappa radar e per trovare la stazione di misura più vicina.\n\nLa posizione resta sul dispositivo: non viene inviata a nessun server e non è condivisa con nessuno.\n\nPuoi rifiutare e continuare a usare l'app scegliendo le località a mano.", + "@locationDisclosureBody": { + "description": "Prominent disclosure required by Google Play. Must state what is collected, why, and that it stays on the device, BEFORE the system dialog appears." + }, + "locationDisclosureContinue": "Continua", + "@locationDisclosureContinue": { + "description": "Button that proceeds to the system permission dialog" + }, + "locationDisclosureDecline": "Non ora", + "@locationDisclosureDecline": { + "description": "Button that dismisses the disclosure without requesting permission" + }, + "locationStatusGranted": "Permesso concesso", + "@locationStatusGranted": { + "description": "Location permission state shown in settings" + }, + "locationStatusDenied": "Permesso non concesso", + "@locationStatusDenied": { + "description": "Location permission state shown in settings" + }, + "locationStatusDeniedForever": "Permesso negato. Puoi concederlo dalle impostazioni di sistema.", + "@locationStatusDeniedForever": { + "description": "Location permission permanently denied; only system settings can change it" + }, + "locationStatusServiceDisabled": "La localizzazione è disattivata sul dispositivo", + "@locationStatusServiceDisabled": { + "description": "Device location services are switched off, so asking for permission would not help" + }, + "locationOpenSystemSettings": "Apri impostazioni di sistema", + "@locationOpenSystemSettings": { + "description": "Button that opens the system settings page for the app or for location" + }, + "locationUnavailable": "Posizione non disponibile", + "@locationUnavailable": { + "description": "Shown when a position fix could not be obtained" + }, + "locationOutsideRegion": "La tua posizione è fuori dall'area coperta dai dati", + "@locationOutsideRegion": { + "description": "Shown when the device position lies outside the region" + }, + "cancel": "Annulla", + "@cancel": { + "description": "Generic cancel button" + }, + "save": "Salva", + "@save": { + "description": "Generic save button" } } diff --git a/Nuvolari/app/pubspec.lock b/Nuvolari/app/pubspec.lock index 710fce1..e8d6561 100644 --- a/Nuvolari/app/pubspec.lock +++ b/Nuvolari/app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -57,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" dio: dependency: "direct main" description: @@ -89,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" fixnum: dependency: transitive description: @@ -133,6 +165,86 @@ packages: description: flutter source: sdk version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: e146a6d63776582651e97a79cbe459f8e1211b100101fadcd84db83361fa599f + url: "https://pub.dev" + source: hosted + version: "14.0.3" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "94db8255dc183d268765df682580440617ca35877fc82cacb5420ad03b86198d" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -253,6 +365,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -261,6 +389,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -285,6 +453,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -450,6 +674,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" sdks: dart: ">=3.13.3 <4.0.0" flutter: ">=3.44.0" diff --git a/Nuvolari/app/pubspec.yaml b/Nuvolari/app/pubspec.yaml index 873167e..47ad936 100644 --- a/Nuvolari/app/pubspec.yaml +++ b/Nuvolari/app/pubspec.yaml @@ -18,12 +18,14 @@ dependencies: flutter_riverpod: ^3.4.3 # Date and number formatting for the Italian locale. + geolocator: ^14.0.3 intl: ^0.20.3 # Native GPU-composited map. Radar animation redraws a full-viewport image # several times a second, which a Dart-side tile renderer cannot keep up with. maplibre_gl: ^0.27.1 # Opens attribution and official-bulletin links in the browser. + shared_preferences: ^2.5.5 url_launcher: ^6.3.2 dev_dependencies: diff --git a/Nuvolari/app/test/core/region/geo_test.dart b/Nuvolari/app/test/core/region/geo_test.dart index 870980e..68de6e1 100644 --- a/Nuvolari/app/test/core/region/geo_test.dart +++ b/Nuvolari/app/test/core/region/geo_test.dart @@ -102,6 +102,38 @@ void main() { }); }); + group('GeoPoint.distanceTo', () { + // Turin to Milan is about 125 km; a haversine on a sphere is well within a + // kilometre of that, which is far finer than anything this is used for. + test('matches a known separation', () { + const torino = GeoPoint(7.686, 45.070); + const milano = GeoPoint(9.190, 45.460); + + expect(torino.distanceTo(milano) / 1000, closeTo(125, 2)); + }); + + test('is zero to itself', () { + const point = GeoPoint(7.686, 45.070); + + expect(point.distanceTo(point), closeTo(0, 1e-6)); + }); + + test('is symmetric', () { + const a = GeoPoint(7.686, 45.070); + const b = GeoPoint(8.622, 45.446); + + expect(a.distanceTo(b), closeTo(b.distanceTo(a), 1e-6)); + }); + + test('orders nearby points correctly', () { + const torino = GeoPoint(7.686, 45.070); + const cuneo = GeoPoint(7.549, 44.393); + const novara = GeoPoint(8.622, 45.446); + + expect(torino.distanceTo(cuneo), lessThan(torino.distanceTo(novara))); + }); + }); + group('WebMercator', () { test('maps the prime meridian and equator to the centre', () { expect(WebMercator.xFromLongitude(0), closeTo(0.5, 1e-12)); diff --git a/Nuvolari/app/test/features/places/places_repository_test.dart b/Nuvolari/app/test/features/places/places_repository_test.dart new file mode 100644 index 0000000..2cdc435 --- /dev/null +++ b/Nuvolari/app/test/features/places/places_repository_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/core/region/geo.dart'; +import 'package:nuvolari/features/places/places_repository.dart'; +import 'package:nuvolari/features/places/saved_place.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +SavedPlace place(String id, String name) => SavedPlace( + id: id, + name: name, + point: const GeoPoint(7.686, 45.070), + createdAt: DateTime.utc(2026, 3, 4, 10), +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('SharedPreferencesPlacesStore', () { + test('starts empty', () async { + expect(await const SharedPreferencesPlacesStore().load(), isEmpty); + }); + + test('round-trips places through storage', () async { + const store = SharedPreferencesPlacesStore(); + final saved = [place('p1', 'Casa'), place('p2', 'Lavoro')]; + + await store.save(saved); + + expect(await store.load(), saved); + }); + + test('save replaces rather than appends', () async { + const store = SharedPreferencesPlacesStore(); + await store.save([place('p1', 'Casa')]); + + await store.save([place('p2', 'Lavoro')]); + + final loaded = await store.load(); + expect(loaded, hasLength(1)); + expect(loaded.single.name, 'Lavoro'); + }); + + test('writes JSON under a versioned key', () async { + await const SharedPreferencesPlacesStore().save([ + place('p1', 'Casa'), + ]); + + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(SharedPreferencesPlacesStore.storageKey); + + expect(SharedPreferencesPlacesStore.storageKey, endsWith('.v1')); + expect(raw, isNotNull); + expect(jsonDecode(raw!), isA>()); + }); + + // Corrupt storage loses the places, which is bad. Throwing here would make + // the app unopenable, which is worse: one is recoverable by the user, the + // other is a crash loop. + test('recovers from corrupt storage instead of throwing', () async { + SharedPreferences.setMockInitialValues({ + SharedPreferencesPlacesStore.storageKey: 'not json at all', + }); + + expect(await const SharedPreferencesPlacesStore().load(), isEmpty); + }); + + test('ignores entries that are not place objects', () async { + SharedPreferences.setMockInitialValues({ + SharedPreferencesPlacesStore.storageKey: jsonEncode([ + 'nonsense', + 42, + ]), + }); + + expect(await const SharedPreferencesPlacesStore().load(), isEmpty); + }); + }); +} diff --git a/Nuvolari/app/test/features/places/saved_places_test.dart b/Nuvolari/app/test/features/places/saved_places_test.dart new file mode 100644 index 0000000..9f0dcdd --- /dev/null +++ b/Nuvolari/app/test/features/places/saved_places_test.dart @@ -0,0 +1,325 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/core/region/geo.dart'; +import 'package:nuvolari/core/region/region_config.dart'; +import 'package:nuvolari/core/region/region_repository.dart'; +import 'package:nuvolari/features/places/places_repository.dart'; +import 'package:nuvolari/features/places/saved_place.dart'; +import 'package:nuvolari/features/places/saved_places.dart'; + +/// Inside the Piedmont bounding box. +const torino = GeoPoint(7.686, 45.070); +const cuneo = GeoPoint(7.549, 44.393); + +/// Well outside it. +const roma = GeoPoint(12.496, 41.902); + +RegionConfig loadPiemonte() => + RegionConfig.parse(File('assets/regions/piemonte.json').readAsStringSync()); + +({ProviderContainer container, InMemoryPlacesStore store}) harness( + RegionConfig region, { + List? initial, +}) { + final store = InMemoryPlacesStore(initial); + final container = ProviderContainer( + overrides: [ + regionConfigProvider.overrideWith((ref) async => region), + placesStoreProvider.overrideWithValue(store), + ], + ); + addTearDown(container.dispose); + return (container: container, store: store); +} + +Future> settled(ProviderContainer container) => + container.read(savedPlacesProvider.future); + +void main() { + late RegionConfig region; + + setUpAll(() { + region = loadPiemonte(); + }); + + group('SavedPlace serialisation', () { + test('round-trips through JSON', () { + final place = SavedPlace( + id: 'p1', + name: 'Casa', + point: torino, + createdAt: DateTime.utc(2026, 3, 4, 10), + ); + + expect(SavedPlace.fromJson(place.toJson()), place); + }); + + test('rejects a malformed document', () { + expect( + () => SavedPlace.fromJson({'id': 'p1'}), + throwsFormatException, + ); + expect( + () => SavedPlace.fromJson({ + 'id': '', + 'name': 'Casa', + 'lat': 45.0, + 'lng': 7.0, + 'createdAt': 0, + }), + throwsFormatException, + ); + }); + }); + + group('adding a place', () { + test('saves it and puts it first', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + + await notifier.add(name: 'Casa', point: torino); + await notifier.add(name: 'Lavoro', point: cuneo); + + final places = h.container.read(savedPlacesProvider).value!; + expect(places.map((p) => p.name), orderedEquals(['Lavoro', 'Casa'])); + }); + + test('persists to the store', () async { + final h = harness(region); + await settled(h.container); + + await h.container + .read(savedPlacesProvider.notifier) + .add(name: 'Casa', point: torino); + + expect(await h.store.load(), hasLength(1)); + expect((await h.store.load()).single.name, 'Casa'); + }); + + test('trims the name', () async { + final h = harness(region); + await settled(h.container); + + final place = await h.container + .read(savedPlacesProvider.notifier) + .add(name: ' Casa ', point: torino); + + expect(place.name, 'Casa'); + }); + + test('rejects a blank name', () async { + final h = harness(region); + await settled(h.container); + + await expectLater( + h.container + .read(savedPlacesProvider.notifier) + .add(name: ' ', point: torino), + throwsA( + isA().having( + (e) => e.rejection, + 'rejection', + SavedPlaceRejection.emptyName, + ), + ), + ); + }); + + test('rejects a name past the limit', () async { + final h = harness(region); + await settled(h.container); + + await expectLater( + h.container + .read(savedPlacesProvider.notifier) + .add(name: 'x' * (SavedPlace.maxNameLength + 1), point: torino), + throwsA( + isA().having( + (e) => e.rejection, + 'rejection', + SavedPlaceRejection.nameTooLong, + ), + ), + ); + }); + + test('rejects a duplicate name regardless of case', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + await notifier.add(name: 'Casa', point: torino); + + await expectLater( + notifier.add(name: 'casa', point: cuneo), + throwsA( + isA().having( + (e) => e.rejection, + 'rejection', + SavedPlaceRejection.duplicateName, + ), + ), + ); + }); + + // Saving a place the app has no data for would look like it worked and then + // show nothing forever. Better to refuse while the user still knows what + // they were trying to do. + test('rejects a point outside the region', () async { + final h = harness(region); + await settled(h.container); + + await expectLater( + h.container + .read(savedPlacesProvider.notifier) + .add(name: 'Roma', point: roma), + throwsA( + isA().having( + (e) => e.rejection, + 'rejection', + SavedPlaceRejection.outsideRegion, + ), + ), + ); + expect(h.container.read(savedPlacesProvider).value, isEmpty); + }); + + test('stops at the limit', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + + for (var i = 0; i < SavedPlaces.maxPlaces + 5; i++) { + await notifier.add(name: 'Posto $i', point: torino); + } + + expect( + h.container.read(savedPlacesProvider).value, + hasLength(SavedPlaces.maxPlaces), + ); + }); + }); + + group('identity', () { + // Ids used to come straight from the microsecond clock, so two places saved + // in the same microsecond collided and rename, remove and the duplicate + // check all acted on the wrong place. + test('rapid adds get distinct ids', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + + for (var i = 0; i < 10; i++) { + await notifier.add(name: 'Posto $i', point: torino); + } + + final ids = h.container + .read(savedPlacesProvider) + .value! + .map((place) => place.id) + .toSet(); + expect(ids, hasLength(10)); + }); + }); + + group('renaming and removing', () { + test('rename changes only the name', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + final place = await notifier.add(name: 'Casa', point: torino); + + await notifier.rename(place.id, 'Casa nuova'); + + final updated = h.container.read(savedPlacesProvider).value!.single; + expect(updated.name, 'Casa nuova'); + expect(updated.id, place.id); + expect(updated.point, torino); + expect(updated.createdAt, place.createdAt); + }); + + test('rename rejects a name another place already uses', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + await notifier.add(name: 'Casa', point: torino); + final second = await notifier.add(name: 'Lavoro', point: cuneo); + + await expectLater( + notifier.rename(second.id, 'Casa'), + throwsA(isA()), + ); + }); + + test('rename to its own name is allowed', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + final place = await notifier.add(name: 'Casa', point: torino); + + await notifier.rename(place.id, 'Casa'); + + expect(h.container.read(savedPlacesProvider).value!.single.name, 'Casa'); + }); + + test('remove deletes it from state and from the store', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + final place = await notifier.add(name: 'Casa', point: torino); + + await notifier.remove(place.id); + + expect(h.container.read(savedPlacesProvider).value, isEmpty); + expect(await h.store.load(), isEmpty); + }); + + test('removing an unknown id changes nothing', () async { + final h = harness(region); + await settled(h.container); + final notifier = h.container.read(savedPlacesProvider.notifier); + await notifier.add(name: 'Casa', point: torino); + + await notifier.remove('does-not-exist'); + + expect(h.container.read(savedPlacesProvider).value, hasLength(1)); + }); + }); + + group('loading', () { + test('reads what the store already held', () async { + final existing = SavedPlace( + id: 'p1', + name: 'Casa', + point: torino, + createdAt: DateTime.utc(2026), + ); + final h = harness(region, initial: [existing]); + + expect(await settled(h.container), [existing]); + }); + }); + + group('byDistanceFrom', () { + test('orders nearest first', () { + final near = SavedPlace( + id: 'a', + name: 'Vicino', + point: torino, + createdAt: DateTime.utc(2026), + ); + final far = SavedPlace( + id: 'b', + name: 'Lontano', + point: cuneo, + createdAt: DateTime.utc(2026), + ); + + final ordered = byDistanceFrom(torino, [far, near]); + + expect(ordered.map((p) => p.id), orderedEquals(['a', 'b'])); + }); + }); +} diff --git a/Nuvolari/docs/architecture.md b/Nuvolari/docs/architecture.md index f302bde..2901497 100644 --- a/Nuvolari/docs/architecture.md +++ b/Nuvolari/docs/architecture.md @@ -50,7 +50,7 @@ lib/ │ ├─ radar/ RadarSource + Dpc/Arpa/Mock implementations + models │ └─ alerts/ AlertSource + ArpaCap implementation ├─ features/ one folder per screen or coherent UI area -│ ├─ map/ timeline/ alerts/ sources/ +│ ├─ map/ timeline/ places/ settings/ alerts/ sources/ └─ l10n/ app_it.arb (template) ``` diff --git a/Nuvolari/docs/privacy.md b/Nuvolari/docs/privacy.md index 62bf895..e0c3251 100644 --- a/Nuvolari/docs/privacy.md +++ b/Nuvolari/docs/privacy.md @@ -15,16 +15,58 @@ neither of which carries a user identity. ## Location -The device may ask for location permission to centre the map. When it does: +The device position is used to centre the map and, once the worker publishes +station data, to pick the nearest measuring station. It is optional throughout. -- **Prominent disclosure** is shown before the system permission dialog, stating what - the location is used for, as Google Play requires. -- The permission is optional. Declining leaves the app fully usable: the map opens on - the region centre from the region config. -- The coordinates stay on the device. They are used to position the map and nothing - else, and are never sent to our backend. +### Coarse only, and enforced -There is no forecast provider, so no coordinates leave the device for one. +The app declares **`ACCESS_COARSE_LOCATION` and nothing else**. Stations are kilometres +apart and the map is looked at, not navigated by, so street-level precision would buy +nothing and cost a more invasive permission. + +This takes active enforcement. The `geolocator` plugin declares +`ACCESS_FINE_LOCATION` in its own manifest, and the merger pulls it into ours. Left +alone the system dialog offers "Precise", Play Services nags about Location Accuracy, +and the Data safety form has to declare precise location — all for accuracy the app +never asks for. The app manifest therefore removes it explicitly: + +```xml + +``` + +Verified on a device: the system dialog reads "access this device's **approximate** +location" and offers no Precise option. **Do not add the fine permission back** without +a feature that genuinely needs it and a Data safety update to match. + +### No Play Services in the path + +Location requests use the platform `LocationManager` +(`AndroidSettings(forceLocationManager: true)`) rather than the Google Play Services +fused provider. The fused provider prompts "your device will need to use Location +Accuracy", and declining it yields **no fix at all** — an absurd outcome for an app +that only ever wanted an approximate one. It also means location works on devices +without Play Services. + +### The permission flow + +- A **prominent disclosure** is shown before the system dialog, as Google Play requires, + stating what is used, why, and that it stays on the device. The same text is repeated + in Settings so someone who already answered can still read it. +- Declining leaves the app fully usable: the map opens on the region centre and places + are chosen by hand. +- The coordinates never leave the device. + +## Saved places + +A saved place — a name and a point — is the closest thing this app has to a home +address. It is stored in `SharedPreferences` **on the device only**, under a versioned +key, and is never uploaded, backed up to our servers or shared. There is no account to +attach it to and no server that would accept it. + +Places are what the rain notifications will eventually key off, and that must not change +where the data lives: the device will subscribe to the topic for the cell containing the +place, so the correspondence between a person and a place stays on their phone. ## Rain notifications without a server-side location @@ -44,6 +86,8 @@ simpler and it would destroy the property. Radar frames are cached in memory while the app runs. The cache holds published weather imagery only — no personal data — and does not outlive the process. +Saved places are the only thing written to persistent storage, and only locally. + ## What each third party receives | Party | What it receives | Why | diff --git a/Nuvolari/docs/roadmap.md b/Nuvolari/docs/roadmap.md index bc3132d..a862636 100644 --- a/Nuvolari/docs/roadmap.md +++ b/Nuvolari/docs/roadmap.md @@ -6,8 +6,9 @@ emulator** — twice now that step has caught defects the tests did not. Legend: ✅ done · 🔨 in progress · ⛔ blocked on something the project owner must supply -> **Scope**: radar on a map, official alerts, rain notifications. Forecasts, lightning, -> a home-screen widget and advertising are all out — see CLAUDE.md. +> **Scope**: radar on a map, saved places, ground-station observations, official alerts, +> rain notifications. Forecasts, lightning, a home-screen widget and advertising are all +> out — see CLAUDE.md. --- @@ -77,7 +78,33 @@ and a disk layer belongs with the network adapter, where it would save a real re --- -## M4 — Backend worker ⛔ +## M4 — Saved places 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. + +**Done and verified on the emulator.** The prominent disclosure appears before the +system dialog, the permission is optional throughout, places survive an app restart and +a reinstall, and tapping one moves the map onto it. + +Running it caught three defects the tests could not: + +- `geolocator` injects `ACCESS_FINE_LOCATION` into the merged manifest, so the system + dialog offered "Precise" despite the app declaring only coarse. Removed with + `tools:node="remove"`; the dialog now reads "approximate location" and offers no + choice. +- The Play Services fused provider prompts about Location Accuracy, and declining it + yields no fix at all. Switched to the platform `LocationManager`, which also drops the + Play Services dependency. +- `MapLibreMap` leaves `cameraPosition` null unless `trackCameraPosition` is set, so + "save the map centre" silently saved the region default instead of what the user was + looking at. + +A unit test also caught an id collision: ids came straight from the microsecond clock, +so two places saved in the same microsecond shared an id and rename, remove and the +duplicate check all acted on the wrong one. + +## M5 — Backend worker ⛔ The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app can only show demo frames. @@ -86,8 +113,14 @@ can only show demo frames. reproject to EPSG:3857 — the source CRS is read from each file, never assumed), `palette` (dBZ colormap, legend exported into the manifest), `render` (RGBA PNG, transparent below threshold), `manifest`, and a `publisher/` with `LocalPublisher` and -`S3Publisher`. Also fetches the ARPA CAP bulletin and publishes `alerts.json`, so the -app never polls ARPA directly. +`S3Publisher`. Also publishes, so the app never polls ARPA directly: + +- `alerts.json` from the ARPA CAP bulletin; +- `stations.json` from the ARPA realtime API — rain accumulations + (1/3/6/12/24 h) and 72 hours of hourly temperature for the 374 stations, joined to + their coordinates from `/pie_anag`. The feed lags about 4.5 hours, so every reading + carries its own timestamp and the UI must present it as an observation, never as the + current conditions. WebSocket trigger on `wss://radar-wss.protezionecivile.it`, with a 5-minute cron as fallback. @@ -99,7 +132,7 @@ precipitation on the emulator. **Blocked on (publishing only):** VPS / object storage endpoint and credentials. Development proceeds against `LocalPublisher` and a LAN `python -m http.server`. -## M5 — Official alerts +## M6 — Official alerts `ArpaCapAlertSource` reading `alerts.json` from our CDN. Zones `Piem-A`…`Piem-M` with levels shown **verbatim** and a link to the official bulletin next to every one. @@ -112,7 +145,7 @@ bulletin reports "not assessed". **Accepts when:** the eleven zones render with the levels the live feed carries, the captured fixture parses, and every alert view links the official bulletin. -## M6 — Rain notifications ⛔ +## M7 — Rain notifications ⛔ FCM topics per geographic cell, subscribed **from the device**, so no user location ever reaches a server. The worker publishes per-cell rain state. @@ -122,7 +155,7 @@ produces a notification on the emulator. **Blocked on:** Firebase project and `google-services.json`. -## M7 — Play Store release preparation ⛔ +## M8 — Play Store release preparation ⛔ Signing config reading `key.properties`, release AAB, target API 36, privacy policy, store listing copy, Data safety declaration, prominent disclosure for location. @@ -138,9 +171,9 @@ location leaving the device. | Needed for | Item | |---|---| -| M4 | VPS / object storage endpoint and credentials | -| M6 | Firebase project and `google-services.json` | -| M7 | Play Console account and upload keystore | +| M5 | VPS / object storage endpoint and credentials | +| M7 | Firebase project and `google-services.json` | +| M8 | Play Console account and upload keystore | | ARPA radar adapter | The email to `info.meteo@arpa.piemonte.it` asking for the real-time access link **and the reuse licence**. Free of charge is not a licence, and without stated terms the frames cannot be republished. | Nothing is needed for the base map: OpenFreeMap requires no key and no account.