Narrow scope to radar, and put the app on a real OpenStreetMap base map

Drops forecasts, lightning, the home-screen widget and advertising. What
remains is radar on a map, the official ARPA alert bulletin, and rain
notifications.

The base map is now OpenFreeMap's Positron style: real OpenStreetMap vector
tiles with no API key, no registration, no request limits and commercial use
permitted. Every other free tier — MapTiler, Stadia, Jawg, Thunderforest —
needs a key, which is a secret to manage, a quota to outgrow and a signup to
complete before anyone can build the project, and the map is the one thing the
app cannot work without. Positron rather than Liberty or Bright because the
radar overlay has to be the loudest thing on screen, and a desaturated grey
base is built to sit under data.

Its style JSON carries no `attribution` field, so MapLibre displays no credits
by itself. The app renders them from the region config instead: the two
mandatory credits, OpenStreetMap and OpenMapTiles, go in the always-visible
bar, and OpenFreeMap's own credit — optional by their terms — is listed on the
Sources screen with the rest. The bundled offline style is still reachable with
MAP_STYLE_URL=offline, and still claims no base map attribution, because
crediting OpenStreetMap while showing it would be a false claim.

Radar-DPC stays the source. ARPA Piemonte's own radar remains a disabled stub
for two reasons that belong to the project owner, not to the code: the
real-time access link is only issued by email, and the open-data page states
the data is "gratuiti" and nothing else. Free of charge is not a licence, and
rendering those volumes into frames served from a CDN is redistribution. Both
questions go in the same email. An earlier draft of the docs recorded ARPA
radar as CC BY 4.0; the source page does not support that, so the claim is
removed rather than carried forward.

The documentation is updated throughout rather than annotated: CLAUDE.md gains
an explicit scope boundary, data-sources drops MET Norway and ISTAT and gains
the base map, licenses records that free of charge is not a licence, privacy
loses the whole advertising section, and the roadmap is renumbered so the
backend worker is next — until it exists, DpcRadarSource has nothing to read.

licenses.md keeps Open-Meteo and Blitzortung listed as excluded even though the
features that would have used them are gone: both are non-commercial-only, ads
are a plausible future, and neither should be adopted on the grounds that there
are none today.

Verified: analyze clean, 130 tests passing, and on the emulator the radar
overlay sits correctly over Piedmont on real OSM tiles with Turin, Milan and
Genoa labelled, the age reads "Aggiornato 4 minuti fa", and the Sources screen
lists all five credits with their licences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 16:10:29 +02:00
co-authored by Claude Opus 5
parent 91f9ac0fbf
commit f3a0e3794a
20 changed files with 430 additions and 350 deletions
+2 -2
View File
@@ -33,8 +33,8 @@
"preLaunchTask": "Avvia emulatore Android" "preLaunchTask": "Avvia emulatore Android"
}, },
{ {
// Same as the first, but with real endpoints and keys. Requires env.json, // Same as the first, but pointed at a live radar manifest. Requires
// which is git-ignored — copy env.example.json and fill it in. // env.json, which is git-ignored — copy env.example.json and fill it in.
"name": "Nuvolari — debug con env.json", "name": "Nuvolari — debug con env.json",
"type": "dart", "type": "dart",
"request": "launch", "request": "launch",
+44 -20
View File
@@ -1,50 +1,74 @@
# CLAUDE.md — Radar Meteo Piemonte # CLAUDE.md — Nuvolari
## Project ## Project
Cross-platform weather-radar app for the Piedmont region (Italy). Android first, iOS later, Cross-platform precipitation-radar app for the Piedmont region (Italy). Android first,
single Flutter codebase. Free app monetized with AdMob (GDPR consent via UMP). iOS later, single Flutter codebase. Region-scoped now (Piemonte) but extensible to other
Region-scoped now (Piemonte) but extensible to other regions via configuration. regions via configuration.
## Scope
**Radar on a map, plus official alerts and rain notifications. Nothing else.**
In scope:
- Animated precipitation radar over an OpenStreetMap base map
- ARPA Piemonte official alert bulletin (XML-CAP), republished verbatim
- "Rain incoming" notifications by geographic cell
Explicitly out of scope — do not add these back without being asked:
- Weather forecasts of any kind
- Lightning
- Home-screen widget
- Advertising and consent flows
The app is currently free with **no advertising**. Ads may return later, so prefer data
sources that permit commercial use; do not adopt a non-commercial-only source on the
grounds that there are no ads today.
## Golden rules ## Golden rules
- Code, identifiers and commit messages in English. UI text in Italian via ARB localization. - Code, identifiers and commit messages in English. UI text in Italian via ARB localization.
- Never commit secrets. Use .env + --dart-define; keep .gitignore updated. - Never commit secrets. Use .env + --dart-define; keep .gitignore updated.
- Use ONLY AdMob TEST ad unit IDs in development.
- Never poll ARPA/DPC servers directly from the app: always go through our backend/CDN. - 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. - Never store precise user locations server-side: rain notifications use geographic CELLS.
- Do not use ARPA/DPC name, logo or the word "ufficiale" in a way implying an official app. - Do not use ARPA/DPC name, logo or the word "ufficiale" in a way implying an official app.
## Architecture ## Architecture
- Monorepo: /app (Flutter), /backend (Python worker), /docs (detailed docs). - Monorepo: /app (Flutter), /backend (Python worker), /docs (detailed docs), /tool (scripts).
- Radar data via RadarSource interface with adapters: - Radar data via RadarSource interface with adapters:
DpcRadarSource (default, public), ArpaRadarSource (stub, awaits authorization), DpcRadarSource (live, reads our CDN), ArpaRadarSource (stub, awaits access),
MockRadarSource (offline/demo). Active source chosen by region config + runtime flag. MockRadarSource (offline/demo). Active source chosen by region config + runtime flag.
- App reads PNG frames + manifest.json from CDN produced by the backend worker - App reads PNG frames + manifest.json from CDN produced by the backend worker
(crop to Piemonte bbox, reproject to EPSG:3857). (crop to Piemonte bbox, reproject to EPSG:3857).
## Data sources & licenses (attribution is mandatory on the Sources screen) ## Data sources & licenses (attribution is mandatory on the Sources screen)
- Radar (MVP): Radar-DPC — base https://radar-api.protezionecivile.it/ , - Radar (active): Radar-DPC — base https://radar-api.protezionecivile.it/ ,
GET /findLastProductByType?type=VMI , POST /downloadProduct (GeoTIFF via presigned S3). GET /findLastProductByType?type=VMI , POST /downloadProduct (GeoTIFF via presigned S3).
REQUIRE header `origin: https://radar.protezionecivile.it`. License CC BY-SA — credit REQUIRE header `origin: https://radar.protezionecivile.it`. License CC BY-SA — credit
"Radar-DPC"; derivative data products must stay CC BY-SA. Docs: dpc-radar.readthedocs.io. "Radar-DPC"; derivative data products must stay CC BY-SA. Docs: dpc-radar.readthedocs.io.
- Radar (future): ARPA Piemonte (HDF5 ODIM), access via email info.meteo@arpa.piemonte.it. The rasters are on a **custom projection centred on Italy**, not EPSG:4326 or 3857, and
License CC BY 4.0credit "Fonte: Arpa Piemonte - www.arpa.piemonte.it". their GeoKeys are internally inconsistent — read the CRS from each file, never hardcode it.
- Forecast: MET Norway api.met.no (CC BY 4.0, commercial OK) — MANDATORY identifying - Radar (future): ARPA Piemonte (HDF5 ODIM, 5-minute volumes). The data is free, but the
User-Agent (app name + contact). Fallback: ItaliaMeteo ICON-2I via MeteoHub (CC BY 4.0, real-time access link **must be requested by email** at info.meteo@arpa.piemonte.it, and
credit "ItaliaMeteo-ARPAE"). Do NOT use Open-Meteo free tier (non-commercial only). ARPA states no licence — only that the data is free of charge. Both the request and the
- Alerts: ARPA Piemonte XML-CAP bulletin — reproduce alert levels WITHOUT reinterpreting; licence question are the project owner's to resolve. Adapter stays a disabled stub.
link the official channel. - Alerts: ARPA Piemonte XML-CAP bulletin at
- Base map: OSM data via PMTiles/MapTiler — show OSM/ODbL attribution. https://www.arpa.piemonte.it/export/xmlcap/allerta.xml — reproduce alert levels WITHOUT
- Lightning: DO NOT use Blitzortung (commercial use prohibited). reinterpreting; link the official channel. Six level values, not four: VERDE, GIALLO,
ARANCIONE, ROSSO, plus BIANCO (avalanche scale out of season) and "-" (no data).
- 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.
## Verification loop (run after each milestone) ## Verification loop (run after each milestone)
- `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle` - `dart format .` ; `flutter analyze` ; `flutter test` ; `flutter build appbundle`
- Backend: `python -m pytest` ; lint. Commit frequently with clear messages. - Backend: `python -m pytest` ; lint. Commit frequently with clear messages.
- `tool/verify.ps1` runs all of the above and skips stages that do not exist yet.
- Verify visually on the `nuvolari` AVD before calling a milestone done. Twice now,
running the app has caught defects that passing tests did not.
## Store / compliance targets ## Store / compliance targets
- Google Play: target API 36 (Android 16) by 2026-08-31; closed testing 12 testers / 14 days; - Google Play: target API 36 (Android 16); closed testing 12 testers / 14 days;
Data safety section; prominent disclosure for location; signed AAB. Data safety section; prominent disclosure for location; signed AAB.
- Consent: Google-certified CMP (UMP), IAB TCF 2.3, non-personalized ads if user declines. - No advertising, so no CMP and no IAB TCF obligations while that holds.
- iOS later: keep platform abstractions clean (ATT + privacy nutrition label to add). - iOS later: keep platform abstractions clean (privacy nutrition label to add).
## Docs to maintain in /docs ## Docs to maintain in /docs
architecture.md, data-sources.md, licenses.md, stack-decisions.md, roadmap.md, privacy.md architecture.md, data-sources.md, licenses.md, stack-decisions.md, roadmap.md, privacy.md
+22 -13
View File
@@ -2,9 +2,12 @@
Precipitation radar for Piedmont, Italy. Android first, iOS later, one Flutter codebase. Precipitation radar for Piedmont, Italy. Android first, iOS later, one Flutter codebase.
Radar imagery comes from the public **Radar-DPC** platform, forecasts from **MET Radar imagery comes from the public **Radar-DPC** platform and weather alerts from the
Norway**, weather alerts from the **ARPA Piemonte** XML-CAP bulletin. The app is free **ARPA Piemonte** XML-CAP bulletin, drawn over an OpenStreetMap base map. The app is
and ad-supported, with GDPR consent through Google's UMP. free, with no advertising and no accounts.
**Scope**: radar, official alerts, rain notifications. No forecasts, no lightning, no
home-screen widget. See [CLAUDE.md](CLAUDE.md).
Nuvolari is an independent app. It is not affiliated with, endorsed by, or operated by Nuvolari is an independent app. It is not affiliated with, endorsed by, or operated by
ARPA Piemonte or the Dipartimento della Protezione Civile. For civil-protection ARPA Piemonte or the Dipartimento della Protezione Civile. For civil-protection
@@ -46,11 +49,10 @@ cp env.example.json env.json # env.json is git-ignored
| Key | Purpose | | Key | Purpose |
|---|---| |---|---|
| `MAP_STYLE_URL` | MapLibre style URL. Empty falls back to a local offline style. | | `MAP_STYLE_URL` | MapLibre style URL. Empty uses the free OpenFreeMap style; `offline` forces the bundled no-network style. |
| `RADAR_MANIFEST_URL` | Base URL of the published `manifest.json`. | | `RADAR_MANIFEST_URL` | Base URL of the published `manifest.json`. |
| `RADAR_SOURCE` | `mock` (offline demo), `dpc` (live), `arpa` (disabled stub). | | `RADAR_SOURCE` | `mock` (offline demo), `dpc` (live), `arpa` (disabled stub). |
| `METNO_USER_AGENT_CONTACT` | Contact address for the MET Norway User-Agent — **mandatory** for forecasts. | | `REGION_ID` | Which region config to load. Defaults to `piemonte`. |
| `ADMOB_APP_ID`, `ADMOB_BANNER_UNIT_ID` | Empty means Google's test ad units are used. |
## Running ## Running
@@ -59,8 +61,12 @@ cd app
flutter run --dart-define-from-file=../env.json flutter run --dart-define-from-file=../env.json
``` ```
With no `env.json` the app starts in demo mode: mock radar frames from assets, offline With no `env.json` the app starts in demo mode: mock radar frames from assets on the
base map style, test ad units. No network and no credentials required. free OpenFreeMap base map. **No credentials are needed for anything** — OpenFreeMap has
no API key and no signup.
The radar frames work with no network at all. The base map needs one; run with
`--dart-define=MAP_STYLE_URL=offline` to drop it too and work entirely offline.
## Debugging in VS Code ## Debugging in VS Code
@@ -71,7 +77,7 @@ Open the **`Nuvolari` folder** as the workspace root — the configurations in
|---|---| |---|---|
| **Nuvolari — debug (emulatore)** | Everyday work. Demo mode, no `env.json` needed, hot reload on save. | | **Nuvolari — debug (emulatore)** | Everyday work. Demo mode, no `env.json` needed, hot reload on save. |
| **Nuvolari — profile** | Judging animation smoothness. Debug builds run the Dart VM unoptimised, so the radar timeline always looks worse than it is — never assess it in debug. | | **Nuvolari — profile** | Judging animation smoothness. Debug builds run the Dart VM unoptimised, so the radar timeline always looks worse than it is — never assess it in debug. |
| **Nuvolari — debug con env.json** | Real endpoints and keys. | | **Nuvolari — debug con env.json** | Pointing the app at a live radar manifest or a different base map style. |
| **Nuvolari — debug su Windows** | Fast UI iteration with no emulator. The native map does not render here. | | **Nuvolari — debug su Windows** | Fast UI iteration with no emulator. The native map does not render here. |
| **Test — tutti** / **Test — file corrente** | Debugging tests with breakpoints. | | **Test — tutti** / **Test — file corrente** | Debugging tests with breakpoints. |
@@ -94,7 +100,10 @@ Stages whose target does not exist yet are skipped, so this runs from day one.
## Attribution ## Attribution
Radar-DPC (CC BY-SA) · MET Norway (CC BY 4.0) · Arpa Piemonte · Radar-DPC (CC BY-SA) · Arpa Piemonte · © OpenStreetMap contributors (ODbL) ·
© OpenStreetMap contributors (ODbL). See [docs/licenses.md](docs/licenses.md) for the © OpenMapTiles · OpenFreeMap.
obligations these carry — in particular, the rendered radar frames are a derived
product of CC BY-SA data and inherit share-alike. See [docs/licenses.md](docs/licenses.md) for the obligations these carry — in
particular, the rendered radar frames are a derived product of CC BY-SA data and inherit
share-alike, and the OpenFreeMap style ships no `attribution` field, so the app has to
render the map credits itself.
+12 -10
View File
@@ -17,10 +17,6 @@
"product": "VMI", "product": "VMI",
"frameIntervalMinutes": 5 "frameIntervalMinutes": 5
}, },
"forecast": {
"defaultAdapter": "metno",
"availableAdapters": ["metno"]
},
"alerts": { "alerts": {
"defaultAdapter": "arpaCap", "defaultAdapter": "arpaCap",
"availableAdapters": ["arpaCap"], "availableAdapters": ["arpaCap"],
@@ -49,12 +45,6 @@
"license": "CC BY-SA", "license": "CC BY-SA",
"url": "https://radar.protezionecivile.it" "url": "https://radar.protezionecivile.it"
}, },
{
"id": "metno",
"text": "MET Norway",
"license": "CC BY 4.0",
"url": "https://api.met.no"
},
{ {
"id": "arpa", "id": "arpa",
"text": "Arpa Piemonte", "text": "Arpa Piemonte",
@@ -66,6 +56,18 @@
"text": "© OpenStreetMap contributors", "text": "© OpenStreetMap contributors",
"license": "ODbL", "license": "ODbL",
"url": "https://www.openstreetmap.org/copyright" "url": "https://www.openstreetmap.org/copyright"
},
{
"id": "openmaptiles",
"text": "© OpenMapTiles",
"license": "BSD 3-Clause",
"url": "https://openmaptiles.org/"
},
{
"id": "openfreemap",
"text": "OpenFreeMap",
"license": "MIT",
"url": "https://openfreemap.org/"
} }
] ]
} }
+15 -28
View File
@@ -1,9 +1,9 @@
/// Build-time configuration, supplied with `--dart-define-from-file=env.json`. /// Build-time configuration, supplied with `--dart-define-from-file=env.json`.
/// ///
/// Every value has a working default, so the app builds and runs with no /// Every value has a working default, so the app builds and runs with no
/// `env.json` at all: it starts in demo mode against bundled mock frames, with /// `env.json` at all: it starts in demo mode against bundled mock frames, on
/// the offline map style and Google's test ad units. That keeps the repository /// the free OpenFreeMap base map, with no credentials anywhere. That keeps the
/// free of secrets and keeps a fresh clone runnable. /// repository free of secrets and keeps a fresh clone runnable.
/// ///
/// Values are `const` reads of `String.fromEnvironment` so they are inlined and /// Values are `const` reads of `String.fromEnvironment` so they are inlined and
/// tree-shaken; they cannot be read from a file at run time. /// tree-shaken; they cannot be read from a file at run time.
@@ -20,12 +20,18 @@ class Env {
/// MapLibre style URL for the base map. /// MapLibre style URL for the base map.
/// ///
/// Empty means no key is configured, and [hasMapStyle] is false: the app /// Empty means the default OpenFreeMap style, which needs no key. Set it to
/// falls back to a minimal style bundled in assets so development and tests /// point at another provider, or to [offlineStyle] to force the bundled
/// work offline. /// no-network style.
static const String mapStyleUrl = String.fromEnvironment('MAP_STYLE_URL'); static const String mapStyleUrl = String.fromEnvironment('MAP_STYLE_URL');
static bool get hasMapStyle => mapStyleUrl.isNotEmpty; /// Sentinel for `MAP_STYLE_URL` that selects the bundled offline style.
static const String offlineStyle = 'offline';
static bool get wantsOfflineStyle => mapStyleUrl == offlineStyle;
static bool get hasCustomMapStyle =>
mapStyleUrl.isNotEmpty && !wantsOfflineStyle;
/// Base URL of the radar `manifest.json` published by our worker. /// Base URL of the radar `manifest.json` published by our worker.
/// ///
@@ -39,27 +45,8 @@ class Env {
/// Empty means "use whatever the region config says". /// Empty means "use whatever the region config says".
static const String radarSource = String.fromEnvironment('RADAR_SOURCE'); static const String radarSource = String.fromEnvironment('RADAR_SOURCE');
/// Contact address embedded in the MET Norway User-Agent. /// True when nothing is configured and the radar is running entirely on
///
/// MET Norway blocks generic User-Agent strings, so forecasts are disabled
/// rather than attempted when this is empty — see [hasForecastContact].
static const String metnoUserAgentContact = String.fromEnvironment(
'METNO_USER_AGENT_CONTACT',
);
static bool get hasForecastContact => metnoUserAgentContact.isNotEmpty;
/// AdMob identifiers. Empty means Google's test units are used.
static const String admobAppId = String.fromEnvironment('ADMOB_APP_ID');
static const String admobBannerUnitId = String.fromEnvironment(
'ADMOB_BANNER_UNIT_ID',
);
static bool get hasAdMobConfig =>
admobAppId.isNotEmpty && admobBannerUnitId.isNotEmpty;
/// True when nothing is configured and the app is running entirely on
/// bundled data. Surfaced in the UI so demo frames are never mistaken for a /// bundled data. Surfaced in the UI so demo frames are never mistaken for a
/// real forecast. /// real observation.
static bool get isDemoMode => radarManifestUrl.isEmpty; static bool get isDemoMode => radarManifestUrl.isEmpty;
} }
@@ -23,20 +23,6 @@ enum RadarAdapter {
); );
} }
enum ForecastAdapter {
/// MET Norway locationforecast.
metno,
/// ItaliaMeteo ICON-2I via MeteoHub.
iconIt2;
static ForecastAdapter parse(String value) =>
ForecastAdapter.values.firstWhere(
(adapter) => adapter.name == value,
orElse: () => throw FormatException('unknown forecast adapter: $value'),
);
}
enum AlertAdapter { enum AlertAdapter {
/// ARPA Piemonte XML-CAP bulletin, fetched through our backend. /// ARPA Piemonte XML-CAP bulletin, fetched through our backend.
arpaCap; arpaCap;
@@ -143,36 +129,6 @@ class RadarSourceConfig {
final Duration frameInterval; final Duration frameInterval;
} }
class ForecastSourceConfig {
const ForecastSourceConfig({
required this.defaultAdapter,
required this.availableAdapters,
});
factory ForecastSourceConfig.fromJson(Map<String, Object?> json) {
final available = _requireStringList(
json,
'availableAdapters',
).map(ForecastAdapter.parse).toList(growable: false);
final defaultAdapter = ForecastAdapter.parse(
_requireString(json, 'defaultAdapter'),
);
if (!available.contains(defaultAdapter)) {
throw FormatException(
'forecast defaultAdapter ${defaultAdapter.name} is not in '
'availableAdapters',
);
}
return ForecastSourceConfig(
defaultAdapter: defaultAdapter,
availableAdapters: available,
);
}
final ForecastAdapter defaultAdapter;
final List<ForecastAdapter> availableAdapters;
}
class AlertSourceConfig { class AlertSourceConfig {
const AlertSourceConfig({ const AlertSourceConfig({
required this.defaultAdapter, required this.defaultAdapter,
@@ -274,7 +230,6 @@ class RegionConfig {
required this.bounds, required this.bounds,
required this.map, required this.map,
required this.radar, required this.radar,
required this.forecast,
required this.alerts, required this.alerts,
required this.alertZones, required this.alertZones,
required this.attributions, required this.attributions,
@@ -328,7 +283,6 @@ class RegionConfig {
bounds: bounds, bounds: bounds,
map: map, map: map,
radar: RadarSourceConfig.fromJson(_requireMap(sources, 'radar')), radar: RadarSourceConfig.fromJson(_requireMap(sources, 'radar')),
forecast: ForecastSourceConfig.fromJson(_requireMap(sources, 'forecast')),
alerts: AlertSourceConfig.fromJson(_requireMap(sources, 'alerts')), alerts: AlertSourceConfig.fromJson(_requireMap(sources, 'alerts')),
alertZones: zones, alertZones: zones,
attributions: attributions, attributions: attributions,
@@ -347,13 +301,12 @@ class RegionConfig {
final String id; final String id;
final String displayName; final String displayName;
/// IANA time zone the region's forecasts and alerts are expressed in. /// IANA time zone the region's alert bulletins are expressed in.
final String timeZone; final String timeZone;
final GeoBounds bounds; final GeoBounds bounds;
final MapConfig map; final MapConfig map;
final RadarSourceConfig radar; final RadarSourceConfig radar;
final ForecastSourceConfig forecast;
final AlertSourceConfig alerts; final AlertSourceConfig alerts;
final List<AlertZone> alertZones; final List<AlertZone> alertZones;
final List<Attribution> attributions; final List<Attribution> attributions;
+35 -15
View File
@@ -5,9 +5,14 @@ import '../../core/region/region_config.dart';
/// Which base map the app is showing, and therefore which credits are owed. /// Which base map the app is showing, and therefore which credits are owed.
enum BaseMapKind { enum BaseMapKind {
/// A real vector base map from [Env.mapStyleUrl]. Carries OpenStreetMap data, /// The default: free OpenStreetMap vector tiles from OpenFreeMap. No API key,
/// so the ODbL credit is mandatory. /// no registration, no request limits. Owes the OpenMapTiles and
configured, /// OpenStreetMap credits.
openFreeMap,
/// A style URL supplied through `MAP_STYLE_URL`. Assumed to be OSM-derived,
/// since every practical alternative is, so it owes the same credits.
custom,
/// The bundled fallback: a flat background and the region outline, drawn from /// The bundled fallback: a flat background and the region outline, drawn from
/// the region config. Contains no third-party map data, so crediting /// the region config. Contains no third-party map data, so crediting
@@ -16,25 +21,35 @@ enum BaseMapKind {
} }
/// Resolves the MapLibre style the app should load. /// Resolves the MapLibre style the app should load.
///
/// With no `MAP_STYLE_URL` configured the app must still run — offline, in
/// tests, and on a fresh clone with no credentials — so it falls back to a
/// style generated from the region's own bounding box.
class MapStyle { class MapStyle {
const MapStyle({required this.kind, required this.styleString}); const MapStyle({required this.kind, required this.styleString});
/// OpenFreeMap's Positron style.
///
/// Chosen over Liberty and Bright because a radar overlay has to be the
/// loudest thing on screen. Positron is a desaturated grey base designed for
/// exactly this — the precipitation colours read against it instead of
/// competing with road casings and landuse fills.
///
/// Free, unmetered and key-less: <https://openfreemap.org>.
static const String openFreeMapStyleUrl =
'https://tiles.openfreemap.org/styles/positron';
factory MapStyle.forRegion(RegionConfig region) { factory MapStyle.forRegion(RegionConfig region) {
if (Env.hasMapStyle) { if (Env.wantsOfflineStyle) {
return MapStyle(
kind: BaseMapKind.configured,
styleString: Env.mapStyleUrl,
);
}
return MapStyle( return MapStyle(
kind: BaseMapKind.offlineFallback, kind: BaseMapKind.offlineFallback,
styleString: buildFallbackStyle(region), styleString: buildFallbackStyle(region),
); );
} }
if (Env.hasCustomMapStyle) {
return MapStyle(kind: BaseMapKind.custom, styleString: Env.mapStyleUrl);
}
return const MapStyle(
kind: BaseMapKind.openFreeMap,
styleString: openFreeMapStyleUrl,
);
}
final BaseMapKind kind; final BaseMapKind kind;
@@ -43,9 +58,14 @@ class MapStyle {
final String styleString; final String styleString;
/// Attribution ids owed by the base map itself, as they appear in the region /// Attribution ids owed by the base map itself, as they appear in the region
/// config. The radar and forecast layers add their own on top of these. /// config. The radar layer adds its own on top of these.
///
/// OpenFreeMap's own credit is optional by their terms, so it lives on the
/// Sources screen rather than in the always-visible bar; the OpenMapTiles and
/// OpenStreetMap credits are mandatory and go in the bar.
Set<String> get attributionIds => switch (kind) { Set<String> get attributionIds => switch (kind) {
BaseMapKind.configured => const <String>{'osm'}, BaseMapKind.openFreeMap ||
BaseMapKind.custom => const <String>{'openmaptiles', 'osm'},
BaseMapKind.offlineFallback => const <String>{}, BaseMapKind.offlineFallback => const <String>{},
}; };
-4
View File
@@ -8,10 +8,6 @@
"@navRadar": { "@navRadar": {
"description": "Bottom navigation label for the radar map" "description": "Bottom navigation label for the radar map"
}, },
"navForecast": "Previsioni",
"@navForecast": {
"description": "Bottom navigation label for the forecast"
},
"navAlerts": "Allerte", "navAlerts": "Allerte",
"@navAlerts": { "@navAlerts": {
"description": "Bottom navigation label for the official weather alerts" "description": "Bottom navigation label for the official weather alerts"
-1
View File
@@ -42,7 +42,6 @@ void main() {
expect(l10n.appTitle, 'Nuvolari'); expect(l10n.appTitle, 'Nuvolari');
expect(l10n.navRadar, 'Radar'); expect(l10n.navRadar, 'Radar');
expect(l10n.navForecast, 'Previsioni');
expect(l10n.navAlerts, 'Allerte'); expect(l10n.navAlerts, 'Allerte');
}); });
@@ -124,12 +124,13 @@ void main() {
} }
}); });
// Radar-DPC is CC BY-SA and OpenStreetMap is ODbL: both credits are legal // Radar-DPC is CC BY-SA, OpenStreetMap is ODbL and OpenMapTiles requires a
// obligations, not decoration, so their absence must fail the build. // credit for the tiles: all three are legal obligations, not decoration, so
// their absence must fail the build.
test('carries the mandatory attributions', () { test('carries the mandatory attributions', () {
final ids = config.attributions.map((a) => a.id).toSet(); final ids = config.attributions.map((a) => a.id).toSet();
expect(ids, containsAll(<String>['dpc', 'osm'])); expect(ids, containsAll(<String>['dpc', 'osm', 'openmaptiles']));
final dpc = config.attributions.firstWhere((a) => a.id == 'dpc'); final dpc = config.attributions.firstWhere((a) => a.id == 'dpc');
expect(dpc.license, 'CC BY-SA'); expect(dpc.license, 'CC BY-SA');
@@ -21,10 +21,6 @@ Map<String, Object?> validDocument() => <String, Object?>{
'product': 'VMI', 'product': 'VMI',
'frameIntervalMinutes': 5, 'frameIntervalMinutes': 5,
}, },
'forecast': <String, Object?>{
'defaultAdapter': 'metno',
'availableAdapters': <String>['metno'],
},
'alerts': <String, Object?>{ 'alerts': <String, Object?>{
'defaultAdapter': 'arpaCap', 'defaultAdapter': 'arpaCap',
'availableAdapters': <String>['arpaCap'], 'availableAdapters': <String>['arpaCap'],
@@ -68,7 +64,6 @@ void main() {
expect(config.radar.defaultAdapter, RadarAdapter.mock); expect(config.radar.defaultAdapter, RadarAdapter.mock);
expect(config.radar.product, 'VMI'); expect(config.radar.product, 'VMI');
expect(config.radar.frameInterval, const Duration(minutes: 5)); expect(config.radar.frameInterval, const Duration(minutes: 5));
expect(config.forecast.defaultAdapter, ForecastAdapter.metno);
expect(config.alerts.defaultAdapter, AlertAdapter.arpaCap); expect(config.alerts.defaultAdapter, AlertAdapter.arpaCap);
expect(config.alertZones, hasLength(1)); expect(config.alertZones, hasLength(1));
expect(config.attributions, hasLength(1)); expect(config.attributions, hasLength(1));
@@ -95,28 +95,48 @@ void main() {
}); });
group('MapStyle.forRegion', () { group('MapStyle.forRegion', () {
// The test process is built without --dart-define, so MAP_STYLE_URL is // The test binary is built with no --dart-define, so MAP_STYLE_URL is empty
// empty and the fallback is what must come back. // and the free key-less base map is what must come back.
test('falls back when no style URL is configured', () { test('defaults to OpenFreeMap when nothing is configured', () {
final style = MapStyle.forRegion(region); final style = MapStyle.forRegion(region);
expect(style.kind, BaseMapKind.offlineFallback); expect(style.kind, BaseMapKind.openFreeMap);
expect(style.styleString, startsWith('{')); expect(style.styleString, MapStyle.openFreeMapStyleUrl);
expect(style.styleString, startsWith('https://'));
});
// OpenMapTiles and OpenStreetMap are mandatory credits for these tiles.
// OpenFreeMap's own credit is optional by their terms and lives on the
// Sources screen instead of the always-visible bar.
test('the default base map owes the OpenMapTiles and OSM credits', () {
final ids = MapStyle.forRegion(region).attributionIds;
expect(ids, containsAll(<String>['openmaptiles', 'osm']));
expect(ids, isNot(contains('openfreemap')));
});
test('a custom style URL owes the same credits', () {
const custom = MapStyle(
kind: BaseMapKind.custom,
styleString: 'https://example.invalid/style.json',
);
expect(
custom.attributionIds,
containsAll(<String>['openmaptiles', 'osm']),
);
}); });
// Crediting OpenStreetMap while showing a style that contains no OSM data // Crediting OpenStreetMap while showing a style that contains no OSM data
// would be a false attribution, so the fallback owes nothing. // would be a false attribution, so the fallback owes nothing.
test('the fallback claims no base map attribution', () { test('the offline fallback claims no base map attribution', () {
expect(MapStyle.forRegion(region).attributionIds, isEmpty); final fallback = MapStyle(
}); kind: BaseMapKind.offlineFallback,
styleString: MapStyle.buildFallbackStyle(region),
test('a configured base map owes the OpenStreetMap credit', () {
const configured = MapStyle(
kind: BaseMapKind.configured,
styleString: 'https://example.invalid/style.json',
); );
expect(configured.attributionIds, contains('osm')); expect(fallback.attributionIds, isEmpty);
expect(fallback.styleString, startsWith('{'));
}); });
}); });
} }
@@ -69,6 +69,14 @@ void main() {
await tester.pumpWidget(wrap(const SourcesScreen(), region)); await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Below the fold in the test viewport once the base-map credits are listed.
await tester.scrollUntilVisible(
find.text('Dati derivati'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('Dati derivati'), findsOneWidget); expect(find.text('Dati derivati'), findsOneWidget);
expect(find.textContaining('CC BY-SA'), findsWidgets); expect(find.textContaining('CC BY-SA'), findsWidgets);
}); });
+14 -12
View File
@@ -14,7 +14,7 @@
cells.json cells.json
Flutter app ── api.met.no (direct, identifying User-Agent) Flutter app ── OpenFreeMap (base map tiles)
``` ```
**The app never talks to DPC or ARPA directly.** Both would be rate-limited by **The app never talks to DPC or ARPA directly.** Both would be rate-limited by
@@ -22,8 +22,9 @@ thousands of clients, DPC presigned URLs expire in minutes, and the rasters are
whole-Italy GeoTIFFs that a phone should not decode. The worker is the only client of whole-Italy GeoTIFFs that a phone should not decode. The worker is the only client of
those services, and it fans out through a CDN. those services, and it fans out through a CDN.
MET Norway is the exception: it is designed for per-client access, its license permits The base map is the one exception, and it is not our data: OpenFreeMap serves public
it, and forecasts are per-location so a shared cache would not help. OpenStreetMap vector tiles with no key and no limits, and proxying them through our own
infrastructure would add cost and latency for nothing.
## Monorepo ## Monorepo
@@ -47,10 +48,9 @@ lib/
│ └─ l10n/ localisation plumbing │ └─ l10n/ localisation plumbing
├─ data/ one folder per domain, each exposing an interface ├─ data/ one folder per domain, each exposing an interface
│ ├─ radar/ RadarSource + Dpc/Arpa/Mock implementations + models │ ├─ radar/ RadarSource + Dpc/Arpa/Mock implementations + models
│ ├─ forecast/ ForecastSource + MetNo/IconIt2 implementations
│ └─ alerts/ AlertSource + ArpaCap implementation │ └─ alerts/ AlertSource + ArpaCap implementation
├─ features/ one folder per screen or coherent UI area ├─ features/ one folder per screen or coherent UI area
│ ├─ map/ timeline/ forecast/ alerts/ sources/ consent/ ads/ │ ├─ map/ timeline/ alerts/ sources/
└─ l10n/ app_it.arb (template) └─ l10n/ app_it.arb (template)
``` ```
@@ -75,8 +75,7 @@ Three implementations:
| `ArpaRadarSource` | **disabled stub** | Placeholder until ARPA authorization exists. Throws if constructed while its feature flag is off. | | `ArpaRadarSource` | **disabled stub** | Placeholder until ARPA authorization exists. Throws if constructed while its feature flag is off. |
The active source is resolved from the region config plus a runtime flag, so switching The active source is resolved from the region config plus a runtime flag, so switching
sources is configuration, never a code change. `ForecastSource` and `AlertSource` sources is configuration, never a code change. `AlertSource` follows the same pattern.
follow the same pattern.
Because `MockRadarSource` is a first-class implementation rather than test scaffolding, Because `MockRadarSource` is a first-class implementation rather than test scaffolding,
the whole UI — timeline, scrubbing, prefetch, cache eviction, degraded states — is the whole UI — timeline, scrubbing, prefetch, cache eviction, degraded states — is
@@ -116,7 +115,9 @@ can be offline. The rules:
- Manifest unreachable → keep the last good manifest from cache, show a - Manifest unreachable → keep the last good manifest from cache, show a
"dati non disponibili" banner with the age of the newest frame. "dati non disponibili" banner with the age of the newest frame.
- Individual frame missing → hold the previous frame in the timeline; never a blank map. - Individual frame missing → hold the previous frame in the timeline; never a blank map.
- No frames at all → the map and the forecast still work; only the radar layer is empty. - No frames at all → the base map and the alerts still work; only the radar layer is empty.
- Base map tiles unreachable → MapLibre draws what it has; the radar overlay is
positioned geographically, not relative to the tiles, so it stays correct.
- Animation stops when the app leaves the foreground (`AppLifecycleState`) so a - Animation stops when the app leaves the foreground (`AppLifecycleState`) so a
backgrounded app never burns battery prefetching. backgrounded app never burns battery prefetching.
@@ -125,7 +126,8 @@ worse than no radar.
## Privacy by construction ## Privacy by construction
No user location ever reaches a server. Forecast requests go from the device straight No user location ever reaches a server. Rain notifications work by the device
to MET Norway. Rain notifications work by the device subscribing to FCM topics named subscribing to FCM topics named after geographic cells — the subscription happens on the
after geographic cells — the subscription happens on the device, so the backend holds device, so the backend holds no user records at all. With no advertising and no forecast
no user records at all. provider, nothing about the user leaves the device except the area the map is looking at,
which is inherent to any hosted base map.
+57 -30
View File
@@ -3,7 +3,7 @@
Every endpoint below was verified on 2026-09-10. Anything not verified is marked Every endpoint below was verified on 2026-09-10. Anything not verified is marked
**TO VERIFY** and must not be relied on until checked. **TO VERIFY** and must not be relied on until checked.
## 1. Radar — Radar-DPC (default for the MVP) ## 1. Radar — Radar-DPC (the active source)
Public platform of the Italian Dipartimento della Protezione Civile. Public platform of the Italian Dipartimento della Protezione Civile.
@@ -114,30 +114,36 @@ Docs: <https://dpc-radar.readthedocs.io/it/latest/>
## 2. Radar — ARPA Piemonte (future adapter, disabled) ## 2. Radar — ARPA Piemonte (future adapter, disabled)
C-band polarimetric radars at Bric della Croce and Monte Settepani, HDF5 ODIM every C-band polarimetric radars at Bric della Croce and Monte Settepani, scanning every
5 minutes. Access requires authorization requested at `info.meteo@arpa.piemonte.it`. 5 minutes. Real-time volumes of reflectivity, Doppler velocity and differential
reflectivity are published in **OPERA HDF5 (ODIM)** format, covering the last hour.
License CC BY 4.0, attribution "Fonte: Arpa Piemonte - www.arpa.piemonte.it". Listed as open data at <https://www.arpa.piemonte.it/dato/open-data>, described there as
constantly updated, machine-readable and free of charge.
`ArpaRadarSource` exists as a **disabled stub**. No request for authorization has been **Two things block adoption, and both belong to the project owner, not to the code:**
sent and none will be sent without an explicit instruction from the project owner.
## 3. Forecast — MET Norway 1. **The access link is not public.** The page states that to obtain the real-time link
for radar and radiosonde data *"è necessario inviare una e-mail all'indirizzo
info.meteo@arpa.piemonte.it"*. There is no documented endpoint to call without it.
2. **No licence is stated.** The page says the data is *gratuiti* — free of charge — and
nothing more. Free of charge is not a licence. Rendering these volumes into PNG frames
and republishing them through a CDN to app users is redistribution, and without stated
terms there is no permission to point at. The email should ask for the reuse terms as
well as the link.
- `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=<lat>&lon=<lon>` Until both are answered, `ArpaRadarSource` stays a stub whose every method throws. It is
- A **descriptive, identifying `User-Agent` is mandatory** (application name plus a named by the region config as unavailable so the seam is visible, not because it is
contact address). Requests with a generic or missing User-Agent are blocked. nearly ready.
- Honour `Expires` and use `If-Modified-Since`; do not re-request before expiry.
- License CC BY 4.0, **commercial use allowed**.
### Fallback — ItaliaMeteo ICON-2I via MeteoHub > Earlier drafts of this document recorded ARPA radar as CC BY 4.0. The open-data page
> does not support that, so the claim has been removed rather than carried forward.
License CC BY 4.0, attribution "ItaliaMeteo-ARPAE". Endpoint details **TO VERIFY** ## 3. Forecast — out of scope
before the adapter is enabled.
### Explicitly excluded The app shows radar, not forecasts. No forecast provider is integrated, and MET Norway,
ItaliaMeteo ICON-2I and Open-Meteo are all out of scope. See CLAUDE.md for the current
**Open-Meteo free tier** — non-commercial only, and this app carries ads. scope boundary.
## 4. Alerts — ARPA Piemonte XML-CAP ## 4. Alerts — ARPA Piemonte XML-CAP
@@ -221,21 +227,42 @@ come from the ARPA zone documentation.
A captured copy of the live feed is kept as a test fixture so the parser is verified 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. against the real document rather than a hand-written approximation.
## 5. Municipalities — ISTAT ## 5. Base map — OpenFreeMap
Piedmont municipality list to bundle in assets. Source **TO VERIFY** — the ISTAT OpenStreetMap vector tiles served by **OpenFreeMap** (<https://openfreemap.org>).
"Codici statistici delle unità amministrative territoriali" dataset is the intended
origin but the stable download URL has not been confirmed yet.
## 6. Base map — OpenStreetMap - Style in use: `https://tiles.openfreemap.org/styles/positron`
- Tiles: `https://tiles.openfreemap.org/planet`
- Glyphs: `https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf`
Vector tiles via MapTiler (API key required, supplied through `MAP_STYLE_URL`) or **No API key, no registration, no request limits, commercial use permitted.** That is
self-hosted PMTiles. **OSM/ODbL attribution must stay visible on the map at all times.** what makes it the right starting point: every other free tier — MapTiler, Stadia, Jawg,
Thunderforest — requires a key, which means a secret to manage and a quota to outgrow.
Until a key is configured the app falls back to a minimal local style so that Positron rather than Liberty or Bright: a radar overlay has to be the loudest thing on
development and tests run fully offline. screen, and Positron is a desaturated grey base designed to sit under data. On Liberty
the precipitation colours compete with road casings and landuse fills.
## 7. Lightning — excluded ### Mandatory credits
**Blitzortung must not be used**: its data prohibits commercial use. If a lightning | Credit | Required? |
layer is ever added it will come from the DPC `LTG` product instead. |---|---|
| `© OpenStreetMap contributors` (ODbL) | **yes** |
| `© OpenMapTiles` | **yes** |
| `OpenFreeMap` | optional, and appreciated |
The style JSON carries **no `attribution` field**, so MapLibre will not display these on
its own. The app renders them itself from the region config: the two mandatory credits
go in the always-visible attribution bar, and all three are listed on the Sources screen.
### When there is no network
With `MAP_STYLE_URL=offline` the app generates a self-contained style from the region
bounding box — flat background plus the extent outline, no network sources at all. It is
deliberately plain so it is never mistaken for a finished map, and it is what the widget
tests run against.
## 6. 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.
+38 -16
View File
@@ -9,11 +9,11 @@ to save screen space.
| Data | License | Mandatory credit | Notes | | Data | License | Mandatory credit | Notes |
|---|---|---|---| |---|---|---|---|
| Radar-DPC rasters | CC BY-SA | "Radar-DPC" | **Share-alike propagates to our frames** | | Radar-DPC rasters | CC BY-SA | "Radar-DPC" | **Share-alike propagates to our frames** |
| ARPA Piemonte radar | CC BY 4.0 | "Fonte: Arpa Piemonte - www.arpa.piemonte.it" | Adapter disabled, unused | | ARPA Piemonte radar | **none stated** | — | Adapter disabled; see below |
| MET Norway forecast | CC BY 4.0 | "MET Norway" | Identifying User-Agent required |
| ItaliaMeteo ICON-2I | CC BY 4.0 | "ItaliaMeteo-ARPAE" | Fallback, behind a flag |
| ARPA Piemonte alerts | not stated | "Arpa Piemonte" + link to the official bulletin | Levels republished verbatim | | ARPA Piemonte alerts | not stated | "Arpa Piemonte" + link to the official bulletin | Levels republished verbatim |
| OpenStreetMap base map | ODbL | "© OpenStreetMap contributors" | Must stay visible on the map | | 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 |
## Share-alike is the constraint that shapes the backend ## Share-alike is the constraint that shapes the backend
@@ -25,23 +25,45 @@ consequences:
- We cannot relicense them, and we cannot restrict their reuse. - We cannot relicense them, and we cannot restrict their reuse.
- The share-alike obligation covers the **data**, not the app's source code or UI. - The share-alike obligation covers the **data**, not the app's source code or UI.
## MET Norway User-Agent The manifest carries its own `attribution` string for exactly this reason: the credit
travels with the frames rather than being remembered at render time, and the app
displays whatever the publisher of those frames says to display.
MET Norway blocks generic User-Agent strings. Requests must carry an identifying ## ARPA Piemonte radar: free of charge is not a licence
string with a real contact address:
``` The ARPA open-data page describes the radar volumes as *gratuiti* and says nothing about
Nuvolari/<version> (<contact address>) reuse terms. That is not permission to redistribute. Rendering those volumes into frames
``` and serving them from our CDN to app users **is** redistribution.
The contact comes from `METNO_USER_AGENT_CONTACT` in `env.json` and is never hardcoded. So the adapter stays disabled until ARPA states terms, and the email that requests the
The `Expires` header must be honoured — re-requesting before expiry risks a block. access link should ask for the reuse terms in the same breath. Both are the project
owner's to obtain — see [data-sources.md](data-sources.md) section 2.
## Not usable An earlier draft of this file recorded ARPA radar as CC BY 4.0. The source page does not
support that, so the claim has been removed rather than carried forward.
- **Blitzortung** — commercial use prohibited. This app carries ads, so it is ## Base map credits are not automatic
commercial. Never integrate it.
- **Open-Meteo free tier** — non-commercial only. Same reasoning. The OpenFreeMap style JSON has no `attribution` field, so MapLibre displays nothing on
its own. The app renders the credits itself:
- the always-visible attribution bar carries the two **mandatory** credits;
- the Sources screen lists all three with their licences and links.
The bar lists only what is actually on screen. With the offline fallback style there is
no OpenStreetMap data being displayed, so crediting OpenStreetMap there would be a false
attribution — the bar says "Mappa base non configurata" instead.
## Advertising, and why it still matters here
The app currently carries **no advertising**, so it is not a commercial product today.
Ads are a plausible future, though, and a source adopted now on non-commercial terms
would have to be ripped out then. Prefer sources that permit commercial use.
This is why **Open-Meteo's free tier** and **Blitzortung** remain listed as excluded even
though the features that would have used them are out of scope: both are
non-commercial-only, and neither should be reached for on the grounds that there are no
ads at the moment.
## Naming and independence ## Naming and independence
+27 -28
View File
@@ -1,7 +1,7 @@
# Privacy design # Privacy design
This is the engineering note. The user-facing privacy policy is drafted in M9 and must This is the engineering note. The user-facing privacy policy is drafted before release
stay consistent with what is written here. and must stay consistent with what is written here.
## Principle ## Principle
@@ -9,20 +9,22 @@ The backend holds no user data of any kind. There is no account, no device regis
no user table. This is not a policy promise — it is a property of the architecture, no user table. This is not a policy promise — it is a property of the architecture,
and it is what makes the Data safety declaration simple and honest. and it is what makes the Data safety declaration simple and honest.
With advertising out of scope, there is currently **no third party that receives anything
about the user at all**. The only outbound requests are for map tiles and radar frames,
neither of which carries a user identity.
## Location ## Location
The device may ask for location permission to centre the map and to pick a forecast The device may ask for location permission to centre the map. When it does:
point. When it does:
- **Prominent disclosure** is shown before the system permission dialog, stating what - **Prominent disclosure** is shown before the system permission dialog, stating what
the location is used for, as Google Play requires. the location is used for, as Google Play requires.
- The permission is optional. Declining leaves the app fully usable: the map opens on - The permission is optional. Declining leaves the app fully usable: the map opens on
the region centre from the region config and the forecast point is chosen manually. the region centre from the region config.
- The coordinates stay on the device. They are used to render the map and to build the - The coordinates stay on the device. They are used to position the map and nothing
MET Norway request, and are never sent to our backend. else, and are never sent to our backend.
MET Norway does receive coordinates — it cannot return a forecast otherwise. This is There is no forecast provider, so no coordinates leave the device for one.
disclosed on the Sources screen, and the coordinates are rounded before being sent.
## Rain notifications without a server-side location ## Rain notifications without a server-side location
@@ -37,31 +39,28 @@ area, not a household.
**Never** replace this with device tokens registered against coordinates. It would be **Never** replace this with device tokens registered against coordinates. It would be
simpler and it would destroy the property. simpler and it would destroy the property.
## Advertising and consent
AdMob is initialised only after the UMP consent flow completes:
- The consent form is shown before **any** ad request.
- Declining consent yields non-personalised ads. It never yields no app.
- The consent choice is revocable from the settings screen.
- A Google-certified CMP (UMP) is used, IAB TCF 2.3.
Only test ad unit IDs are used in development. Real IDs arrive through `env.json`,
which is git-ignored.
## Caching on the device ## Caching on the device
Radar frames and forecast responses are cached on disk under the app's private Radar frames are cached in memory while the app runs. The cache holds published weather
directory. The cache holds published weather data only — no personal data — and is imagery only — no personal data — and does not outlive the process.
cleared with the app.
## Third parties that receive data ## What each third party receives
| Party | What it receives | Why | | Party | What it receives | Why |
|---|---|---| |---|---|---|
| MET Norway | rounded coordinates, User-Agent | to return a forecast | | OpenFreeMap | tile requests for the area being viewed | to draw the base map |
| Google AdMob | ad request data per the consent choice | monetisation |
| Firebase Cloud Messaging | topic subscriptions (no coordinates) | rain notifications |
| Our CDN | frame and manifest requests | radar imagery | | Our CDN | frame and manifest requests | radar imagery |
| Firebase Cloud Messaging | topic subscriptions, no coordinates | rain notifications |
Map tile requests inevitably reveal roughly where the map is looking, to whoever serves
the tiles. That is inherent to any hosted base map; the alternative is self-hosting, which
is noted in [stack-decisions.md](stack-decisions.md) as the escape hatch if it ever
matters enough.
Our own backend receives nothing that identifies a user, by construction. Our own backend receives nothing that identifies a user, by construction.
## Out of scope, and therefore absent
No advertising, no consent management platform, no IAB TCF, no ad identifiers, no
forecast provider. If advertising returns, this document and the privacy policy must be
revised **before** the SDK is added, not after.
+78 -83
View File
@@ -1,9 +1,13 @@
# Roadmap # Roadmap
Each milestone ends with `tool/verify.ps1` green and one commit. A milestone is not Each milestone ends with `tool/verify.ps1` green and one commit. A milestone is not
done until its acceptance criteria hold. done until its acceptance criteria hold, **and until it has been seen running on the
emulator** — twice now that step has caught defects the tests did not.
Legend: ✅ done · 🔨 in progress · ⛔ blocked on credentials from the project owner 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.
--- ---
@@ -12,14 +16,10 @@ Legend: ✅ done · 🔨 in progress · ⛔ blocked on credentials from the proj
Flutter 3.47.3 stable, Android SDK (platform-tools, platform 36, build-tools 36.0.0), Flutter 3.47.3 stable, Android SDK (platform-tools, platform 36, build-tools 36.0.0),
JDK 21, environment variables. Commit the removal of the old Xamarin skeleton. JDK 21, environment variables. Commit the removal of the old Xamarin skeleton.
**Accepts when:** `flutter doctor -v` reports no blocking Android toolchain error and
`git status` is clean.
**Done.** `flutter doctor -v` reports "No issues found!". Installed: Flutter 3.47.3 at **Done.** `flutter doctor -v` reports "No issues found!". Installed: Flutter 3.47.3 at
`C:\src\flutter` (telemetry disabled), Android SDK at `C:\Android\Sdk` with `C:\src\flutter` (telemetry disabled), Android SDK at `C:\Android\Sdk` with platform 36,
platform 36, build-tools 36.0.0, platform-tools and all licences accepted, and build-tools 36.0.0, platform-tools and all licences accepted, and `JAVA_HOME` pointing at
`JAVA_HOME` pointing at the JDK 21 a previous Visual Studio install had already left the JDK 21 a previous Visual Studio install had already left on the machine.
on the machine.
## M1 — Scaffold, Italian l10n, region config, CI ✅ ## M1 — Scaffold, Italian l10n, region config, CI ✅
@@ -28,52 +28,41 @@ with `app_it.arb` as template and no hardcoded UI strings; `RegionConfig` loaded
`assets/regions/piemonte.json` with parsing tests; `.gitignore`, `env.example.json`, `assets/regions/piemonte.json` with parsing tests; `.gitignore`, `env.example.json`,
`tool/verify.ps1`, `.gitea/workflows/ci.yml`. `tool/verify.ps1`, `.gitea/workflows/ci.yml`.
**Accepts when:** `flutter analyze` reports 0 issues, `flutter test` passes,
`flutter build appbundle --debug` succeeds.
**Done.** All four stages green; the AAB carries applicationId `it.nuvolari.app`, **Done.** All four stages green; the AAB carries applicationId `it.nuvolari.app`,
minSdk 24, targetSdk 36. 50 tests, including assertions against the shipped Piemonte minSdk 24, targetSdk 36. Tests assert against the shipped Piemonte asset and a captured
asset and a captured copy of the live ARPA CAP feed. copy of the live ARPA CAP feed.
> The Gitea instance may have no Actions runner. The workflow file is written to be > The Gitea instance may have no Actions runner. The workflow file is written to be
> GitHub-Actions compatible, but `tool/verify.ps1` is the verification that must pass. > GitHub-Actions compatible, but `tool/verify.ps1` is the verification that must pass.
## M2 — Map, attribution, Sources screen ✅ ## M2 — Map, attribution, Sources screen ✅
MapLibre with the style from `MAP_STYLE_URL`, falling back to a local minimal style. MapLibre on OpenStreetMap tiles, permanent OSM/OpenMapTiles attribution, and a
Permanent OSM/ODbL attribution. Sources / Licenses / Disclaimer screen covering Sources / Licenses / Disclaimer screen with the explicit "not an official app"
Radar-DPC (CC BY-SA), MET Norway, ARPA, OSM, with the explicit "not an official app"
disclaimer. disclaimer.
**Accepts when:** the map opens centred on Piedmont, attribution is visible at all **Done and verified on the emulator.** The attribution bar lists only the sources
times, and the Sources screen is reachable and complete. actually rendered — crediting OpenStreetMap while showing the offline fallback would be
a false attribution — and sits below the map, inside a SafeArea, so neither a map control
nor the system gesture pill can cover a credit the licences require to be visible.
**Done**, with one gap. The fallback style is generated from the region bounding box Running it caught two defects the unit tests could not: the configured initial zoom put
rather than loaded from an asset, so it needs no third-party boundary dataset and no the viewport entirely inside the region so the map read as a blank expanse, and the
network. The attribution bar lists only the sources actually rendered — crediting attribution bar sat behind the gesture pill. The camera now fits the region bounds at
OpenStreetMap while showing the fallback would be a false attribution — and sits below runtime, which works at any screen size.
the map rather than floating over it so no map control can occlude it.
**Verified on an emulator** (`nuvolari`, API 36.1, x86_64, GPU host). Running it caught
two things the unit tests could not: the configured initial zoom put the viewport
entirely inside the region so the map read as a blank expanse, and the attribution bar
sat behind the system gesture pill. Both fixed — the camera now fits the region bounds
at runtime, which works on any screen size, and the bar is inside a SafeArea.
## M3 — Animation and timeline ✅ ## M3 — Animation and timeline ✅
`RadarSource` with `MockRadarSource` (synthetic frames in assets) and `DpcRadarSource`. `RadarSource` with `MockRadarSource` (synthetic frames in assets), `DpcRadarSource`
Timeline scrubber, play/pause, adjacent-frame prefetch, `FrameCache` LRU, animation (reads our CDN) and `ArpaRadarSource` (disabled stub). Timeline scrubber, play/pause,
suspended in background, graceful degradation with a data-age banner. adjacent-frame prefetch, `FrameCache` LRU, animation suspended in background, graceful
degradation with a data-age banner.
**Accepts when:** animation runs smoothly in demo mode **with the network off**, and
prefetch, LRU eviction and the fallback paths are covered by tests.
**Done and verified on the emulator.** The overlay is double buffered, the timeline **Done and verified on the emulator.** The overlay is double buffered, the timeline
lands on the newest frame, playback advances and wraps, scrubbing takes over from lands on the newest frame, playback advances and wraps, scrubbing takes over from
playback, and the legend is drawn from the manifest rather than a constant. playback, and the legend is drawn from the manifest rather than a constant.
Running it caught three things the tests had not: Running it caught three more defects:
- The notifier wrote to `state` from inside `build()`, which Riverpod rejects as an - The notifier wrote to `state` from inside `build()`, which Riverpod rejects as an
uninitialised provider. This broke startup, not just tests. uninitialised provider. This broke startup, not just tests.
@@ -86,54 +75,60 @@ Running it caught three things the tests had not:
Frame caching is in memory only for now: mock frames are already in the asset bundle Frame caching is in memory only for now: mock frames are already in the asset bundle
and a disk layer belongs with the network adapter, where it would save a real request. and a disk layer belongs with the network adapter, where it would save a real request.
## M4 — Forecast ---
`MetNoForecastSource` with the mandatory identifying User-Agent, honouring `Expires` ## M4 — Backend worker ⛔
and `If-Modified-Since`. `IconIt2Source` as a flagged fallback. Hourly and daily views.
**Accepts when:** a forecast renders for a Piedmont location, caching respects the The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app
response headers, and fixture-based tests pass. can only show demo frames.
## M5 — UMP consent and AdMob ⛔ `backend/nuvolari_worker/`: `dpc_client` (with the `origin` header), `crop` (bbox +
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.
UMP consent form before any ad request; non-personalised ads when consent is declined. WebSocket trigger on `wss://radar-wss.protezionecivile.it`, with a 5-minute cron as
Anchored adaptive banner — never over the map, never an interstitial during animation. fallback.
**Test ad unit IDs only** until real ones are supplied.
**Blocked on:** AdMob App ID and ad unit IDs. **Accepts when:** `python -m pytest` passes and one full run produces PNGs plus a
`manifest.json` that the app consumes from a local server, showing real Piedmont
precipitation on the emulator.
## M6 — Rain notifications and alerts ⛔ **Blocked on (publishing only):** VPS / object storage endpoint and credentials.
Development proceeds against `LocalPublisher` and a LAN `python -m http.server`.
`ArpaCapAlertSource` reading the CAP feed through the backend; zones `Piem-A``Piem-M` ## M5 — Official alerts
with levels shown verbatim and a link to the official bulletin. Rain notifications via
FCM topics per geographic cell, subscribed from the device. `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.
Six level values, not four: `VERDE`, `GIALLO`, `ARANCIONE`, `ROSSO`, plus `BIANCO`
(avalanche scale, out of season) and `-` (not published). `BIANCO` and `-` are their own
state and must never be collapsed into `VERDE` — that would report "no alert" where the
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 ⛔
FCM topics per geographic cell, subscribed **from the device**, so no user location ever
reaches a server. The worker publishes per-cell rain state.
**Accepts when:** subscribing and unsubscribing works, and a published cell state
produces a notification on the emulator.
**Blocked on:** Firebase project and `google-services.json`. **Blocked on:** Firebase project and `google-services.json`.
## M7 — Android home widget ## M7 — Play Store release preparation ⛔
Glance widget showing the latest frame and the next rain, refreshed by WorkManager. Signing config reading `key.properties`, release AAB, target API 36, privacy policy,
store listing copy, Data safety declaration, prominent disclosure for location.
**Accepts when:** the widget renders a real frame on the home screen and updates. Data safety is unusually simple here: no advertising, no analytics, no accounts, and no
location leaving the device.
## M8 — Backend worker ⛔
`dpc_client` (with the `origin` header), `crop` (bbox + reproject to EPSG:3857),
`palette` (dBZ colormap, legend exported into the manifest), `render` (RGBA PNG,
transparent below threshold), `manifest`, and `publisher/` with `LocalPublisher` and
`S3Publisher`. WebSocket trigger with cron fallback. pysteps nowcast present but
disabled behind a flag.
**Accepts when:** `python -m pytest` passes and one full run produces PNGs plus a
`manifest.json` that the app consumes from a local server.
**Blocked on (publishing only):** VPS / object storage endpoint and credentials.
Development proceeds against `LocalPublisher`.
## M9 — Play Store release preparation ⛔
Signing config reading `key.properties`, release AAB, target API 36, privacy policy
draft, store listing copy, Data safety draft, prominent disclosure for location.
**Blocked on:** upload keystore and Play Console account. **Blocked on:** upload keystore and Play Console account.
@@ -143,11 +138,12 @@ draft, store listing copy, Data safety draft, prominent disclosure for location.
| Needed for | Item | | Needed for | Item |
|---|---| |---|---|
| M2 (quality) | MapTiler API key — until then the local fallback style is used | | M4 | VPS / object storage endpoint and credentials |
| M5 | AdMob App ID and ad unit IDs |
| M6 | Firebase project and `google-services.json` | | M6 | Firebase project and `google-services.json` |
| M8 | VPS / object storage endpoint and credentials | | M7 | Play Console account and upload keystore |
| M9 | 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.
--- ---
@@ -158,9 +154,12 @@ GPU passthrough to the host. It uses a 2 GB data partition and no SD card becaus
is tight, and quickboot snapshots are disabled — they cost 2.6 GB to save a few seconds is tight, and quickboot snapshots are disabled — they cost 2.6 GB to save a few seconds
of boot, which is the wrong trade here. Expect a cold boot of a minute or so. of boot, which is the wrong trade here. Expect a cold boot of a minute or so.
In VS Code, just press **F5**: every emulator debug configuration boots it first. From a
shell:
```bash ```bash
# Boot it # Boot it (idempotent — returns at once if a device is attached)
"$ANDROID_HOME/emulator/emulator" -avd nuvolari -no-audio -no-boot-anim -gpu host powershell -File tool/start_emulator.ps1
# Build, install, launch # Build, install, launch
cd app cd app
@@ -174,7 +173,3 @@ adb exec-out screencap -p > screen.png
`flutter run -d emulator-5554` works too and gives hot reload; the steps above are what `flutter run -d emulator-5554` works too and gives hot reload; the steps above are what
a non-interactive session uses. a non-interactive session uses.
> An unrelated AVD, `pixel_7_-_api_35`, is present but broken — its system image
> (`android-35/google_apis_playstore`) is not installed, so it cannot boot. It occupies
> about 4.8 GB. It predates this project and has been left alone.
+37 -14
View File
@@ -9,16 +9,19 @@ Android ships first, iOS follows on the same code. Platform-specific work stays
interfaces (`core/platform/`) so the iOS port is additive rather than a rewrite. interfaces (`core/platform/`) so the iOS port is additive rather than a rewrite.
Toolchain pinned during setup: **Flutter 3.47.3 stable / Dart 3.13.3**, Toolchain pinned during setup: **Flutter 3.47.3 stable / Dart 3.13.3**,
compileSdk/targetSdk **36**, minSdk **21** (the floor imposed by `maplibre_gl`), compileSdk/targetSdk **36**, minSdk **24**, JDK **21**.
JDK **21**.
`maplibre_gl` only needs API 21; 24 is the floor `firebase_messaging` imposes
for the rain notifications, and it is Flutter's own default, so it is the
best-tested path.
## State management — Riverpod without code generation ## State management — Riverpod without code generation
`flutter_riverpod` with hand-written `Notifier` / `AsyncNotifier` classes. `flutter_riverpod` with hand-written `Notifier` / `AsyncNotifier` classes.
Chosen over: Bloc (more ceremony than this app's state needs), plain `setState` Chosen over: Bloc (more ceremony than this app's state needs), plain `setState`
(the radar timeline, the frame cache and the consent flow all share state across (the radar timeline, the frame cache and the alert state all cross screen
screens), Riverpod *with* `riverpod_generator`. boundaries), and Riverpod *with* `riverpod_generator`.
Dropping code generation keeps `build_runner` out of CI and out of every edit-run Dropping code generation keeps `build_runner` out of CI and out of every edit-run
cycle. The generator's benefit — less boilerplate on providers — is small at this cycle. The generator's benefit — less boilerplate on providers — is small at this
@@ -26,12 +29,10 @@ size, and its cost is paid on every build. Reconsider if provider count passes ~
## Networking — Dio ## Networking — Dio
`dio` with interceptors for: the mandatory MET Norway User-Agent, retry with `dio` with interceptors for retry with exponential backoff and timeouts.
exponential backoff, and timeouts.
Chosen over `http`, which has no interceptor model — the User-Agent obligation is a Chosen over `http` for the interceptor model: cross-cutting request policy belongs in
licensing requirement, so it belongs in one enforced place rather than at each call one enforced place rather than at each call site where it can be forgotten.
site where it can be forgotten.
The DPC `origin` header lives **only in the Python worker**. The app never talks to The DPC `origin` header lives **only in the Python worker**. The app never talks to
DPC directly. DPC directly.
@@ -39,7 +40,7 @@ DPC directly.
## Models — hand-written `fromJson` ## Models — hand-written `fromJson`
Chosen over `freezed` + `json_serializable`. The model set is small (radar manifest and Chosen over `freezed` + `json_serializable`. The model set is small (radar manifest and
frames, forecast series, CAP alerts, region config) and the parsers are covered by frames, CAP alerts, region config) and the parsers are covered by
tests against real fixtures. Same rationale as Riverpod: no `build_runner`. tests against real fixtures. Same rationale as Riverpod: no `build_runner`.
The tests, not the generator, are what guarantee the parsing is right — fixtures The tests, not the generator, are what guarantee the parsing is right — fixtures
@@ -53,9 +54,29 @@ Chosen over `flutter_map`, which renders tiles in Dart. Radar animation redraws
full-viewport image several times a second; a GPU-composited native renderer holds full-viewport image several times a second; a GPU-composited native renderer holds
frame rate where a Dart canvas does not. frame rate where a Dart canvas does not.
The base map style URL comes from `MAP_STYLE_URL`. With no key configured the app ## Base map — OpenFreeMap Positron
loads a minimal local style — flat background plus the Piedmont boundary from a
bundled GeoJSON — so development, tests and the demo mode all work offline. OpenStreetMap vector tiles from [OpenFreeMap](https://openfreemap.org): no API key, no
registration, no request limits, commercial use permitted.
Chosen over MapTiler, Stadia, Jawg and Thunderforest, whose free tiers all require a key.
A key is a secret to manage, a quota to outgrow and a signup to complete before anyone
can build the project — and the map is the one thing the app cannot work without.
Positron rather than Liberty or Bright: the radar overlay has to be the loudest thing on
screen, and Positron is a desaturated grey base built to sit under data. On a full-colour
style the precipitation ramp competes with road casings and landuse fills.
The trade is a dependency on someone else's free service. The escape hatch is
self-hosting — OpenFreeMap is MIT-licensed and publishes its planet tiles — and
`MAP_STYLE_URL` already points the app anywhere else without a code change.
Its style JSON carries no `attribution` field, so MapLibre shows no credits by itself and
the app renders them from the region config. See [licenses.md](licenses.md).
`MAP_STYLE_URL=offline` selects a style generated from the region bounding box, with no
network sources at all: a flat background and the extent outline. Deliberately plain so
it is never mistaken for a finished map, and it is what the widget tests run against.
## Radar frame rendering — image source, double buffered ## Radar frame rendering — image source, double buffered
@@ -97,7 +118,9 @@ centre and zoom limits, alert zones, active data sources, attribution strings. A
a region is a new JSON file plus its assets — no Dart changes. a region is a new JSON file plus its assets — no Dart changes.
Piedmont bounding box, padded: `[6.55, 43.95, 9.30, 46.55]` (W, S, E, N). Piedmont bounding box, padded: `[6.55, 43.95, 9.30, 46.55]` (W, S, E, N).
To be refined against the ISTAT geometry once that dataset is confirmed. It is a render extent, not an administrative boundary: it deliberately overshoots
the region so nothing is clipped at the edges, and the tests assert that every
provincial capital falls inside it.
## Backend — Python worker on a VPS ## Backend — Python worker on a VPS
+2 -4
View File
@@ -1,8 +1,6 @@
{ {
"REGION_ID": "piemonte",
"MAP_STYLE_URL": "", "MAP_STYLE_URL": "",
"RADAR_MANIFEST_URL": "", "RADAR_MANIFEST_URL": "",
"RADAR_SOURCE": "mock", "RADAR_SOURCE": "mock"
"METNO_USER_AGENT_CONTACT": "you@example.com",
"ADMOB_APP_ID": "",
"ADMOB_BANNER_UNIT_ID": ""
} }