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
+14 -12
View File
@@ -14,7 +14,7 @@
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
@@ -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
those services, and it fans out through a CDN.
MET Norway is the exception: it is designed for per-client access, its license permits
it, and forecasts are per-location so a shared cache would not help.
The base map is the one exception, and it is not our data: OpenFreeMap serves public
OpenStreetMap vector tiles with no key and no limits, and proxying them through our own
infrastructure would add cost and latency for nothing.
## Monorepo
@@ -47,10 +48,9 @@ lib/
│ └─ l10n/ localisation plumbing
├─ data/ one folder per domain, each exposing an interface
│ ├─ radar/ RadarSource + Dpc/Arpa/Mock implementations + models
│ ├─ forecast/ ForecastSource + MetNo/IconIt2 implementations
│ └─ alerts/ AlertSource + ArpaCap implementation
├─ 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)
```
@@ -75,8 +75,7 @@ Three implementations:
| `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
sources is configuration, never a code change. `ForecastSource` and `AlertSource`
follow the same pattern.
sources is configuration, never a code change. `AlertSource` follows the same pattern.
Because `MockRadarSource` is a first-class implementation rather than test scaffolding,
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
"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.
- 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
backgrounded app never burns battery prefetching.
@@ -125,7 +126,8 @@ worse than no radar.
## Privacy by construction
No user location ever reaches a server. Forecast requests go from the device straight
to MET Norway. Rain notifications work by the device subscribing to FCM topics named
after geographic cells — the subscription happens on the device, so the backend holds
no user records at all.
No user location ever reaches a server. Rain notifications work by the device
subscribing to FCM topics named after geographic cells — the subscription happens on the
device, so the backend holds no user records at all. With no advertising and no forecast
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
**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.
@@ -114,30 +114,36 @@ Docs: <https://dpc-radar.readthedocs.io/it/latest/>
## 2. Radar — ARPA Piemonte (future adapter, disabled)
C-band polarimetric radars at Bric della Croce and Monte Settepani, HDF5 ODIM every
5 minutes. Access requires authorization requested at `info.meteo@arpa.piemonte.it`.
C-band polarimetric radars at Bric della Croce and Monte Settepani, scanning every
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
sent and none will be sent without an explicit instruction from the project owner.
**Two things block adoption, and both belong to the project owner, not to the code:**
## 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>`
- A **descriptive, identifying `User-Agent` is mandatory** (application name plus a
contact address). Requests with a generic or missing User-Agent are blocked.
- Honour `Expires` and use `If-Modified-Since`; do not re-request before expiry.
- License CC BY 4.0, **commercial use allowed**.
Until both are answered, `ArpaRadarSource` stays a stub whose every method throws. It is
named by the region config as unavailable so the seam is visible, not because it is
nearly ready.
### 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**
before the adapter is enabled.
## 3. Forecast — out of scope
### Explicitly excluded
**Open-Meteo free tier** — non-commercial only, and this app carries ads.
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
scope boundary.
## 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
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
"Codici statistici delle unità amministrative territoriali" dataset is the intended
origin but the stable download URL has not been confirmed yet.
OpenStreetMap vector tiles served by **OpenFreeMap** (<https://openfreemap.org>).
## 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
self-hosted PMTiles. **OSM/ODbL attribution must stay visible on the map at all times.**
**No API key, no registration, no request limits, commercial use permitted.** That is
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
development and tests run fully offline.
Positron rather than Liberty or Bright: a radar overlay has to be the loudest thing on
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
layer is ever added it will come from the DPC `LTG` product instead.
| Credit | Required? |
|---|---|
| `© 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 |
|---|---|---|---|
| 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 |
| 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 radar | **none stated** | — | Adapter disabled; see below |
| 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
@@ -25,23 +25,45 @@ consequences:
- 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.
## 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
string with a real contact address:
## ARPA Piemonte radar: free of charge is not a licence
```
Nuvolari/<version> (<contact address>)
```
The ARPA open-data page describes the radar volumes as *gratuiti* and says nothing about
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.
The `Expires` header must be honoured — re-requesting before expiry risks a block.
So the adapter stays disabled until ARPA states terms, and the email that requests the
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
commercial. Never integrate it.
- **Open-Meteo free tier** — non-commercial only. Same reasoning.
## Base map credits are not automatic
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
+27 -28
View File
@@ -1,7 +1,7 @@
# Privacy design
This is the engineering note. The user-facing privacy policy is drafted in M9 and must
stay consistent with what is written here.
This is the engineering note. The user-facing privacy policy is drafted before release
and must stay consistent with what is written here.
## 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,
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
The device may ask for location permission to centre the map and to pick a forecast
point. When it does:
The device may ask for location permission to centre the map. When it does:
- **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 and the forecast point is chosen manually.
- The coordinates stay on the device. They are used to render the map and to build the
MET Norway request, and are never sent to our backend.
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.
MET Norway does receive coordinates — it cannot return a forecast otherwise. This is
disclosed on the Sources screen, and the coordinates are rounded before being sent.
There is no forecast provider, so no coordinates leave the device for one.
## 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
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
Radar frames and forecast responses are cached on disk under the app's private
directory. The cache holds published weather data only — no personal data — and is
cleared with the app.
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.
## Third parties that receive data
## What each third party receives
| Party | What it receives | Why |
|---|---|---|
| MET Norway | rounded coordinates, User-Agent | to return a forecast |
| Google AdMob | ad request data per the consent choice | monetisation |
| Firebase Cloud Messaging | topic subscriptions (no coordinates) | rain notifications |
| OpenFreeMap | tile requests for the area being viewed | to draw the base map |
| 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.
## 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
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),
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
`C:\src\flutter` (telemetry disabled), Android SDK at `C:\Android\Sdk` with
platform 36, build-tools 36.0.0, platform-tools and all licences accepted, and
`JAVA_HOME` pointing at the JDK 21 a previous Visual Studio install had already left
on the machine.
`C:\src\flutter` (telemetry disabled), Android SDK at `C:\Android\Sdk` with platform 36,
build-tools 36.0.0, platform-tools and all licences accepted, and `JAVA_HOME` pointing at
the JDK 21 a previous Visual Studio install had already left on the machine.
## 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`,
`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`,
minSdk 24, targetSdk 36. 50 tests, including assertions against the shipped Piemonte
asset and a captured copy of the live ARPA CAP feed.
minSdk 24, targetSdk 36. Tests assert against the shipped Piemonte asset and a captured
copy of the live ARPA CAP feed.
> 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.
## M2 — Map, attribution, Sources screen ✅
MapLibre with the style from `MAP_STYLE_URL`, falling back to a local minimal style.
Permanent OSM/ODbL attribution. Sources / Licenses / Disclaimer screen covering
Radar-DPC (CC BY-SA), MET Norway, ARPA, OSM, with the explicit "not an official app"
MapLibre on OpenStreetMap tiles, permanent OSM/OpenMapTiles attribution, and a
Sources / Licenses / Disclaimer screen with the explicit "not an official app"
disclaimer.
**Accepts when:** the map opens centred on Piedmont, attribution is visible at all
times, and the Sources screen is reachable and complete.
**Done and verified on the emulator.** The attribution bar lists only the sources
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
rather than loaded from an asset, so it needs no third-party boundary dataset and no
network. The attribution bar lists only the sources actually rendered — crediting
OpenStreetMap while showing the fallback would be a false attribution — and sits below
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.
Running it caught two defects 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 gesture pill. The camera now fits the region bounds at
runtime, which works at any screen size.
## M3 — Animation and timeline ✅
`RadarSource` with `MockRadarSource` (synthetic frames in assets) and `DpcRadarSource`.
Timeline scrubber, play/pause, 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.
`RadarSource` with `MockRadarSource` (synthetic frames in assets), `DpcRadarSource`
(reads our CDN) and `ArpaRadarSource` (disabled stub). Timeline scrubber, play/pause,
adjacent-frame prefetch, `FrameCache` LRU, animation suspended in background, graceful
degradation with a data-age banner.
**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
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
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
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`
and `If-Modified-Since`. `IconIt2Source` as a flagged fallback. Hourly and daily views.
## M4 — Backend worker ⛔
**Accepts when:** a forecast renders for a Piedmont location, caching respects the
response headers, and fixture-based tests pass.
The critical path. Until this exists, `DpcRadarSource` has nothing to read and the app
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.
Anchored adaptive banner — never over the map, never an interstitial during animation.
**Test ad unit IDs only** until real ones are supplied.
WebSocket trigger on `wss://radar-wss.protezionecivile.it`, with a 5-minute cron as
fallback.
**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`
with levels shown verbatim and a link to the official bulletin. Rain notifications via
FCM topics per geographic cell, subscribed from the device.
## M5 — 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.
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`.
## 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.
## 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.
Data safety is unusually simple here: no advertising, no analytics, no accounts, and no
location leaving the device.
**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 |
|---|---|
| M2 (quality) | MapTiler API key — until then the local fallback style is used |
| M5 | AdMob App ID and ad unit IDs |
| M4 | VPS / object storage endpoint and credentials |
| M6 | Firebase project and `google-services.json` |
| M8 | VPS / object storage endpoint and credentials |
| M9 | Play Console account and upload keystore |
| M7 | 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
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
# Boot it
"$ANDROID_HOME/emulator/emulator" -avd nuvolari -no-audio -no-boot-anim -gpu host
# Boot it (idempotent — returns at once if a device is attached)
powershell -File tool/start_emulator.ps1
# Build, install, launch
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
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.
Toolchain pinned during setup: **Flutter 3.47.3 stable / Dart 3.13.3**,
compileSdk/targetSdk **36**, minSdk **21** (the floor imposed by `maplibre_gl`),
JDK **21**.
compileSdk/targetSdk **36**, minSdk **24**, 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
`flutter_riverpod` with hand-written `Notifier` / `AsyncNotifier` classes.
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
screens), Riverpod *with* `riverpod_generator`.
(the radar timeline, the frame cache and the alert state all cross screen
boundaries), and Riverpod *with* `riverpod_generator`.
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
@@ -26,12 +29,10 @@ size, and its cost is paid on every build. Reconsider if provider count passes ~
## Networking — Dio
`dio` with interceptors for: the mandatory MET Norway User-Agent, retry with
exponential backoff, and timeouts.
`dio` with interceptors for retry with exponential backoff and timeouts.
Chosen over `http`, which has no interceptor model — the User-Agent obligation is a
licensing requirement, so it belongs in one enforced place rather than at each call
site where it can be forgotten.
Chosen over `http` for the interceptor model: cross-cutting request policy belongs in
one enforced place rather than at each call site where it can be forgotten.
The DPC `origin` header lives **only in the Python worker**. The app never talks to
DPC directly.
@@ -39,7 +40,7 @@ DPC directly.
## Models — hand-written `fromJson`
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`.
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
frame rate where a Dart canvas does not.
The base map style URL comes from `MAP_STYLE_URL`. With no key configured the app
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.
## Base map — OpenFreeMap Positron
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
@@ -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.
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