Add monorepo scaffolding and project documentation
Sets up the Nuvolari monorepo layout (app/, backend/, docs/, tool/) with the groundwork that does not depend on the Flutter toolchain: gitignore covering Flutter, Python and every secret file shape; env.example.json as the template for --dart-define-from-file; a verification script that skips stages whose target does not exist yet so it is runnable from day one; and a CI workflow in GitHub Actions syntax so it runs unchanged on Gitea or GitHub. The documentation records facts verified against the live services rather than restated from the brief. Two of them change the design: - DPC VMI rasters are 1200x1400 Float32 on a 1 km grid in a custom projection centred on Italy, not EPSG:4326 or EPSG:3857, and their GeoKeys are internally inconsistent. Reprojection is mandatory and the source CRS must be read from each file rather than hardcoded. - The ARPA CAP feed carries six level values, not four: BIANCO for the avalanche scale out of season and "-" for no data. Collapsing either into VERDE would report "no alert" where the bulletin reports "not assessed". Also documents why the frames we publish inherit CC BY-SA from the DPC source, and why rain notifications subscribe to cell topics from the device so no user location ever reaches a server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# Architecture
|
||||
|
||||
## Shape of the system
|
||||
|
||||
```
|
||||
DPC radar API ──┐
|
||||
│ (worker only: origin header, presigned S3, 5-min cadence)
|
||||
ARPA CAP feed ──┤
|
||||
▼
|
||||
Python worker ──► object storage / CDN
|
||||
(crop, reproject, manifest.json
|
||||
colourise, render) frames/*.png
|
||||
alerts.json
|
||||
cells.json
|
||||
│
|
||||
▼
|
||||
Flutter app ◄── api.met.no (direct, identifying User-Agent)
|
||||
```
|
||||
|
||||
**The app never talks to DPC or ARPA directly.** Both would be rate-limited by
|
||||
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.
|
||||
|
||||
## Monorepo
|
||||
|
||||
```
|
||||
Nuvolari/
|
||||
├─ app/ Flutter application (Dart package "nuvolari")
|
||||
├─ backend/ Python worker
|
||||
├─ docs/ this documentation
|
||||
└─ tool/ verification scripts
|
||||
```
|
||||
|
||||
## App layers
|
||||
|
||||
```
|
||||
lib/
|
||||
├─ core/ cross-cutting, no feature knowledge
|
||||
│ ├─ config/ Env (dart-define), feature flags
|
||||
│ ├─ region/ RegionConfig + asset loader
|
||||
│ ├─ net/ Dio client, User-Agent and retry interceptors
|
||||
│ ├─ cache/ FrameCache (disk + memory LRU)
|
||||
│ └─ 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/
|
||||
└─ l10n/ app_it.arb (template)
|
||||
```
|
||||
|
||||
Dependencies point inwards: `features` depends on `data`, `data` depends on `core`,
|
||||
`core` depends on nothing in the app. A feature never imports another feature.
|
||||
|
||||
## The adapter seam
|
||||
|
||||
```dart
|
||||
abstract interface class RadarSource {
|
||||
Future<RadarManifest> getLatestManifest();
|
||||
Future<List<RadarFrame>> getFrames();
|
||||
}
|
||||
```
|
||||
|
||||
Three implementations:
|
||||
|
||||
| Implementation | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `MockRadarSource` | active | Synthetic frames from assets. Runs with no network and no credentials — the default in tests and in demo mode. |
|
||||
| `DpcRadarSource` | active | Reads `manifest.json` and PNG frames from our CDN. |
|
||||
| `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.
|
||||
|
||||
Because `MockRadarSource` is a first-class implementation rather than test scaffolding,
|
||||
the whole UI — timeline, scrubbing, prefetch, cache eviction, degraded states — is
|
||||
exercisable offline.
|
||||
|
||||
## Data contract
|
||||
|
||||
`manifest.json`, published by the worker and consumed by the app:
|
||||
|
||||
```json
|
||||
{
|
||||
"region": "piemonte",
|
||||
"product": "VMI",
|
||||
"generatedAt": 1758706260000,
|
||||
"bbox": [6.55, 43.95, 9.30, 46.55],
|
||||
"crs": "EPSG:3857",
|
||||
"frames": [
|
||||
{ "ts": 1758706200000, "url": "frames/VMI/1758706200000.png" }
|
||||
],
|
||||
"legend": {
|
||||
"unit": "dBZ",
|
||||
"stops": [{ "value": 5, "color": "#4FA3D1" }]
|
||||
},
|
||||
"attribution": "Radar-DPC — CC BY-SA"
|
||||
}
|
||||
```
|
||||
|
||||
Frame URLs are relative to the manifest so the whole tree can be moved between hosts.
|
||||
The legend travels with the data: the app draws whatever the worker produced rather
|
||||
than hardcoding a palette that could drift from the rendering.
|
||||
|
||||
## Degradation
|
||||
|
||||
Failure is normal here — the worker can be behind, a frame can be missing, the phone
|
||||
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.
|
||||
- Animation stops when the app leaves the foreground (`AppLifecycleState`) so a
|
||||
backgrounded app never burns battery prefetching.
|
||||
|
||||
The banner always states **when** the data is from. Stale radar shown as if current is
|
||||
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.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Data sources
|
||||
|
||||
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)
|
||||
|
||||
Public platform of the Italian Dipartimento della Protezione Civile.
|
||||
|
||||
- Base URL: `https://radar-api.protezionecivile.it/`
|
||||
- Transitional test base URL: `https://wagiqofvnk.execute-api.eu-south-1.amazonaws.com/prod`
|
||||
|
||||
### `GET /findLastProductByType?type=<PRODUCT>`
|
||||
|
||||
Returns the timestamp of the most recent sample of a product.
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"lastProducts": [
|
||||
{ "productType": "VMI", "time": 1758706200000, "period": "PT5M" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `time` is epoch milliseconds, UTC.
|
||||
- `period` is the ISO-8601 cadence of the product.
|
||||
- `400` when `type` is missing, `404` when no product is available.
|
||||
|
||||
### `POST /downloadProduct`
|
||||
|
||||
Returns a pre-signed S3 URL for the raw raster file.
|
||||
|
||||
```json
|
||||
{ "productType": "VMI", "productDate": 1758706200000 }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"bucket": "dpc-radar",
|
||||
"key": "VMI/22-09-2025-14-20.tif",
|
||||
"url": "https://dpc-radar.s3.eu-south-1.amazonaws.com/...",
|
||||
"expiresSeconds": 900
|
||||
}
|
||||
```
|
||||
|
||||
- `productDate` must be **rounded down to the product step**, otherwise `404`.
|
||||
- Pre-signed URLs expire after 300–900 s: download immediately, never cache the URL.
|
||||
- `400` when `productDate` is missing or non-numeric.
|
||||
|
||||
### Mandatory header
|
||||
|
||||
```
|
||||
origin: https://radar.protezionecivile.it
|
||||
```
|
||||
|
||||
Required on **every** request to the platform for security reasons; requests without it
|
||||
are rejected. `Content-Type: application/json` is also required on `downloadProduct`.
|
||||
|
||||
### Products and cadence
|
||||
|
||||
| Cadence | Products |
|
||||
|---|---|
|
||||
| 5 min | `VMI`, `SRI`, `SRT1`, `IR_108` |
|
||||
| 30–60 min | `TEMP`, `CUM3`, `CUM6`, `CUM12`, `CUM24`, `CAPPI_1`…`CAPPI_10`, `VIL`, `ETM`, `POH` |
|
||||
| — | `SITES` (GeoJSON, fetched directly from S3 with `GET`) |
|
||||
|
||||
The MVP uses **`VMI`** (Vertical Maximum Intensity) as the precipitation layer.
|
||||
History retention is 14 days. Files are GeoTIFF; CRS is read from the file metadata
|
||||
rather than assumed — the worker reprojects to EPSG:3857 in all cases.
|
||||
|
||||
### Raster format (read from a live VMI file, 2026-09-10)
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Size | 1200 × 1400 px |
|
||||
| Pixel size | 1000 × 1000 m — a **1 km grid** |
|
||||
| Bands / type | 1 band, **Float32**, LZW compressed |
|
||||
| Tie point | raster (0,0) → model (-600000, 650000) |
|
||||
| Model type | **projected**, datum WGS84 (GeographicTypeGeoKey 4326) |
|
||||
| Projection centre | 12.5° E, 42.0° N (`ProjCenterLong`/`ProjCenterLat`) |
|
||||
| Linear units | metre |
|
||||
| Nodata tag | absent — expect NaN |
|
||||
|
||||
**The rasters are not in EPSG:4326 and not in EPSG:3857.** They are on a custom
|
||||
projection centred on Italy, so reprojection to Web Mercator is mandatory, not an
|
||||
optimisation. The extent works out to 1200 km east-west and 1400 km north-south around
|
||||
that centre, which covers the whole country — the worker crops to the region bounding
|
||||
box before doing anything else, so it never carries the full mosaic through the
|
||||
pipeline.
|
||||
|
||||
The GeoKeys as written are internally inconsistent (`ProjCoordTransGeoKey` = 1, which
|
||||
is transverse Mercator, combined with the centre keys an azimuthal projection uses).
|
||||
**Do not hardcode a source CRS.** The worker reads the CRS from the file with rasterio
|
||||
and warps from whatever it finds, and logs the detected CRS on every run so a change at
|
||||
the source is visible instead of silently shifting the imagery.
|
||||
|
||||
Values are physical units (dBZ for VMI), not palette indices — the colour mapping is
|
||||
ours, and the legend that goes with it is published in the manifest.
|
||||
|
||||
### Push channel
|
||||
|
||||
`wss://radar-wss.protezionecivile.it` pushes a notification whenever a new sample is
|
||||
published. The worker uses this as its **primary trigger**, with a 5-minute cron as a
|
||||
fallback, so we never poll DPC aggressively.
|
||||
|
||||
### License
|
||||
|
||||
**CC BY-SA.** Attribution to "Radar-DPC" is mandatory and derived products must be
|
||||
released under the same license. The rendered PNG frames we publish are a derived
|
||||
product and inherit CC BY-SA.
|
||||
|
||||
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`.
|
||||
|
||||
License CC BY 4.0, attribution "Fonte: Arpa Piemonte - www.arpa.piemonte.it".
|
||||
|
||||
`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.
|
||||
|
||||
## 3. Forecast — MET Norway
|
||||
|
||||
- `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**.
|
||||
|
||||
### Fallback — ItaliaMeteo ICON-2I via MeteoHub
|
||||
|
||||
License CC BY 4.0, attribution "ItaliaMeteo-ARPAE". Endpoint details **TO VERIFY**
|
||||
before the adapter is enabled.
|
||||
|
||||
### Explicitly excluded
|
||||
|
||||
**Open-Meteo free tier** — non-commercial only, and this app carries ads.
|
||||
|
||||
## 4. Alerts — ARPA Piemonte XML-CAP
|
||||
|
||||
- Feed: `https://www.arpa.piemonte.it/export/xmlcap/allerta.xml`
|
||||
- Landing page: <https://www.arpa.piemonte.it/rischi_naturali/snippets_arpa/allerta/index.html>
|
||||
- Official PDF bulletin: `https://www.arpa.piemonte.it/rischi_naturali/boll/bollettino_allerta.pdf`
|
||||
|
||||
Issued daily by 13:00 by the Centro Funzionale Regionale, 36 hours of validity.
|
||||
|
||||
### Alert zones (11)
|
||||
|
||||
`Piem-A` … `Piem-M` (A, B, C, D, E, F, G, H, I, L, M), covering: Toce; Val Sesia/Cervo/
|
||||
Chiusella; Valli Orco/Lanzo/bassa val Susa/Sangone; Alta val Susa/Chisone/Pellice/Po;
|
||||
Valli Varaita/Maira/Stura; Valle Tanaro; Belbo e Bormida; Scrivia; Pianura
|
||||
settentrionale; Pianura e colline torinesi; Pianura cuneese.
|
||||
The exact code-to-name mapping is stored in `app/assets/regions/piemonte.json`.
|
||||
|
||||
### CAP document structure (verified against the live feed, 2026-09-10)
|
||||
|
||||
One `<alert>` contains one `<info>` block **per zone**; the zone is in
|
||||
`info/area/areaDesc` and holds the bare code (`Piem-A`), not a name.
|
||||
|
||||
```xml
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>261/2026</identifier> <!-- bulletin number / year -->
|
||||
<sender>centro.funzionale@arpa.piemonte.it</sender>
|
||||
<sent>2026-09-09T10:00:02+00:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<note>AVVISO DI CONDIZIONI METEOROLOGICHE AVVERSE ...</note>
|
||||
<info>
|
||||
<event>GIALLO</event> <!-- overall level for this zone -->
|
||||
<onset>...</onset><expires>...</expires>
|
||||
<parameter><valueName>EFFETTI SUL TERRITORIO</valueName><value>...</value></parameter>
|
||||
<parameter><valueName>TEMPORALI_1224</valueName><value>GIALLO</value></parameter>
|
||||
<parameter><valueName>TEMPORALI_2436</valueName><value>VERDE</value></parameter>
|
||||
...
|
||||
<area><areaDesc>Piem-A</areaDesc></area>
|
||||
</info>
|
||||
<!-- 10 more info blocks, one per zone -->
|
||||
</alert>
|
||||
```
|
||||
|
||||
Parameters are named `<CATEGORY>_<WINDOW>`:
|
||||
|
||||
- **Categories** — `IDRAULICO`, `IDROGEOLOGICO`, `TEMPORALI`, `NEVE`, `VALANGHE`.
|
||||
- **Windows** — `1224` (hours 12–24) and `2436` (hours 24–36), matching the 36 hours
|
||||
of validity.
|
||||
|
||||
So each zone carries 10 level values plus one overall `<event>` level and a free-text
|
||||
`EFFETTI SUL TERRITORIO`.
|
||||
|
||||
### Levels — six values, not four
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `VERDE` | no significant phenomena |
|
||||
| `GIALLO` | localised phenomena |
|
||||
| `ARANCIONE` | widespread phenomena |
|
||||
| `ROSSO` | numerous or extensive phenomena |
|
||||
| `BIANCO` | **avalanche scale only** — outside the season / not assessed |
|
||||
| `-` | no value published for that category and window |
|
||||
|
||||
`BIANCO` and `-` are not "green with a different name" and must not be collapsed into
|
||||
it. The parser models the level as an enum with explicit `bianco` and `notAvailable`
|
||||
members, and the UI renders them as their own state rather than as "no alert".
|
||||
|
||||
Levels are republished **verbatim, never reinterpreted**, always alongside a link to
|
||||
the official bulletin. No license is stated on the ARPA page, so we credit ARPA
|
||||
Piemonte and link the source rather than claiming any reuse right.
|
||||
|
||||
Official bulletin PDF, linked from every alert view:
|
||||
`https://www.arpa.piemonte.it/rischi_naturali/boll/bollettino_allerta.pdf`
|
||||
|
||||
### Zone codes
|
||||
|
||||
`Piem-A` … `Piem-M`, eleven zones: **the Italian alphabet is used, so J and K do not
|
||||
exist** — the sequence is A B C D E F G H I L M. The code-to-name mapping lives in
|
||||
`app/assets/regions/piemonte.json`; the human-readable names are not in the feed and
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 6. Base map — OpenStreetMap
|
||||
|
||||
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.**
|
||||
|
||||
Until a key is configured the app falls back to a minimal local style so that
|
||||
development and tests run fully offline.
|
||||
|
||||
## 7. Lightning — excluded
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Licenses and attribution obligations
|
||||
|
||||
Nuvolari redistributes third-party data. Every obligation below is binding: the
|
||||
Sources screen in the app must satisfy all of them, and none of them may be dropped
|
||||
to save screen space.
|
||||
|
||||
## Summary
|
||||
|
||||
| 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 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 |
|
||||
|
||||
## Share-alike is the constraint that shapes the backend
|
||||
|
||||
The Radar-DPC rasters are **CC BY-SA**. The PNG frames the worker produces — cropped,
|
||||
reprojected, colourised — are a derived product, so they inherit CC BY-SA. Practical
|
||||
consequences:
|
||||
|
||||
- The published frames and `manifest.json` are CC BY-SA and must be labelled as such.
|
||||
- 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
|
||||
|
||||
MET Norway blocks generic User-Agent strings. Requests must carry an identifying
|
||||
string with a real contact address:
|
||||
|
||||
```
|
||||
Nuvolari/<version> (<contact address>)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Not usable
|
||||
|
||||
- **Blitzortung** — commercial use prohibited. This app carries ads, so it is
|
||||
commercial. Never integrate it.
|
||||
- **Open-Meteo free tier** — non-commercial only. Same reasoning.
|
||||
|
||||
## Naming and independence
|
||||
|
||||
The app must never appear to be an official ARPA or Protezione Civile product:
|
||||
|
||||
- no ARPA/DPC logos, coats of arms or institutional branding;
|
||||
- never the word "ufficiale" applied to the app itself (the *bulletin* is official —
|
||||
the *app* is not);
|
||||
- the Sources screen carries an explicit disclaimer that Nuvolari is an independent
|
||||
app, not affiliated with, endorsed by, or operated by ARPA Piemonte or the
|
||||
Dipartimento della Protezione Civile;
|
||||
- for civil-protection purposes the official channels always prevail, and the app
|
||||
says so next to every alert.
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
|
||||
## Principle
|
||||
|
||||
The backend holds no user data of any kind. There is no account, no device registry,
|
||||
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.
|
||||
|
||||
## Location
|
||||
|
||||
The device may ask for location permission to centre the map and to pick a forecast
|
||||
point. 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.
|
||||
|
||||
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.
|
||||
|
||||
## Rain notifications without a server-side location
|
||||
|
||||
The worker computes rain per geographic cell and publishes per-cell state. The device
|
||||
decides which cells it cares about and subscribes to the matching FCM topics **itself**.
|
||||
|
||||
The consequence is that the server never learns which cell any user is in — it
|
||||
publishes to topics, not to devices. There is nothing to correlate, nothing to
|
||||
subpoena, and nothing to breach. Cell size is coarse enough that a cell identifies an
|
||||
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.
|
||||
|
||||
## Third parties that receive data
|
||||
|
||||
| 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 |
|
||||
| Our CDN | frame and manifest requests | radar imagery |
|
||||
|
||||
Our own backend receives nothing that identifies a user, by construction.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Roadmap
|
||||
|
||||
Each milestone ends with `tool/verify.ps1` green and one commit. A milestone is not
|
||||
done until its acceptance criteria hold.
|
||||
|
||||
Legend: ✅ done · 🔨 in progress · ⛔ blocked on credentials from the project owner
|
||||
|
||||
---
|
||||
|
||||
## M0 — Toolchain and repository hygiene 🔨
|
||||
|
||||
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.
|
||||
|
||||
## M1 — Scaffold, Italian l10n, region config, CI
|
||||
|
||||
`flutter create` with applicationId `it.nuvolari.app`; `flutter_localizations` + `intl`
|
||||
with `app_it.arb` as template and no hardcoded UI strings; `RegionConfig` loaded from
|
||||
`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.
|
||||
|
||||
> 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"
|
||||
disclaimer.
|
||||
|
||||
**Accepts when:** the map opens centred on Piedmont, attribution is visible at all
|
||||
times, and the Sources screen is reachable and complete.
|
||||
|
||||
## 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.
|
||||
|
||||
## M4 — Forecast
|
||||
|
||||
`MetNoForecastSource` with the mandatory identifying User-Agent, honouring `Expires`
|
||||
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
|
||||
response headers, and fixture-based tests pass.
|
||||
|
||||
## M5 — UMP consent and AdMob ⛔
|
||||
|
||||
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.
|
||||
|
||||
**Blocked on:** AdMob App ID and ad unit IDs.
|
||||
|
||||
## M6 — Rain notifications and alerts ⛔
|
||||
|
||||
`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.
|
||||
|
||||
**Blocked on:** Firebase project and `google-services.json`.
|
||||
|
||||
## M7 — Android home widget
|
||||
|
||||
Glance widget showing the latest frame and the next rain, refreshed by WorkManager.
|
||||
|
||||
**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.
|
||||
|
||||
**Blocked on:** upload keystore and Play Console account.
|
||||
|
||||
---
|
||||
|
||||
## Pending inputs from the project owner
|
||||
|
||||
| Needed for | Item |
|
||||
|---|---|
|
||||
| M2 (quality) | MapTiler API key — until then the local fallback style is used |
|
||||
| M5 | AdMob App ID and ad unit IDs |
|
||||
| M6 | Firebase project and `google-services.json` |
|
||||
| M8 | VPS / object storage endpoint and credentials |
|
||||
| M9 | Play Console account and upload keystore |
|
||||
@@ -0,0 +1,112 @@
|
||||
# Stack decisions
|
||||
|
||||
Each entry records what was chosen, what it was chosen over, and why. Revisit an entry
|
||||
only with a reason that invalidates its rationale.
|
||||
|
||||
## Flutter, single codebase
|
||||
|
||||
Android ships first, iOS follows on the same code. Platform-specific work stays behind
|
||||
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**.
|
||||
|
||||
## 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`.
|
||||
|
||||
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
|
||||
size, and its cost is paid on every build. Reconsider if provider count passes ~40.
|
||||
|
||||
## Networking — Dio
|
||||
|
||||
`dio` with interceptors for: the mandatory MET Norway User-Agent, 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.
|
||||
|
||||
The DPC `origin` header lives **only in the Python worker**. The app never talks to
|
||||
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
|
||||
tests against real fixtures. Same rationale as Riverpod: no `build_runner`.
|
||||
|
||||
The tests, not the generator, are what guarantee the parsing is right — fixtures
|
||||
captured from the real endpoints catch schema drift that codegen would not.
|
||||
|
||||
## Map — MapLibre GL
|
||||
|
||||
`maplibre_gl` 0.27.0 (Flutter 3.29+, Android API 21+, iOS 13+).
|
||||
|
||||
Chosen over `flutter_map`, which renders tiles in Dart. Radar animation redraws a
|
||||
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.
|
||||
|
||||
## Radar frame rendering — image source, double buffered
|
||||
|
||||
The worker publishes each frame already cropped to the region bounding box and
|
||||
reprojected to EPSG:3857, so the four corners of a MapLibre `LatLngQuad` are exact and
|
||||
no client-side warping is needed.
|
||||
|
||||
Animation uses **two image sources, A and B**: while one is visible the next frame is
|
||||
decoded into the other, then visibility swaps.
|
||||
|
||||
Chosen over: adding all ~20 frames as layers with opacity 0 (constant GPU memory
|
||||
matters more than the saved swap — 20 frames at 1024×1024 RGBA is ~80 MB resident),
|
||||
and over updating a single source in place (visible flicker during decode).
|
||||
|
||||
## Frame cache — custom LRU
|
||||
|
||||
`FrameCache` over `path_provider`: an on-disk LRU with a configurable byte cap, plus a
|
||||
small in-memory LRU of PNG bytes.
|
||||
|
||||
Chosen over `flutter_cache_manager`, which does not expose the eviction control the
|
||||
scrubber needs. Prefetch must prioritise frames adjacent to the playhead and evict by
|
||||
distance from it, not by age.
|
||||
|
||||
## Region configuration
|
||||
|
||||
Everything region-specific lives in `app/assets/regions/<id>.json`: bounding box, map
|
||||
centre and zoom limits, alert zones, active data sources, attribution strings. Adding
|
||||
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.
|
||||
|
||||
## Backend — Python worker on a VPS
|
||||
|
||||
GDAL and HDF5 cannot run on Supabase Edge Functions (Deno), so the worker runs as a
|
||||
scheduled process on a VPS or Cloud Run.
|
||||
|
||||
`rasterio` 1.5.1 publishes Windows wheels for Python 3.14, so the worker also runs
|
||||
natively on the development machine — no Docker or WSL needed to iterate.
|
||||
|
||||
Trigger: the DPC WebSocket push channel `wss://radar-wss.protezionecivile.it`, with a
|
||||
5-minute cron as fallback. Polling DPC on a timer is the fallback, never the norm.
|
||||
|
||||
Output goes through a `Publisher` interface: `LocalPublisher` writes to `backend/out/`
|
||||
for development, `S3Publisher` targets R2 or Supabase Storage once credentials exist.
|
||||
|
||||
## Rain notifications — client-side topic subscription
|
||||
|
||||
The worker computes rain per **geographic cell** and publishes per-cell state. The app
|
||||
subscribes to FCM topics named after cells, **from the device**.
|
||||
|
||||
The server therefore never learns any user's position — not precisely, not even by
|
||||
cell. There is no user table to leak, and Data safety can honestly declare that no
|
||||
location is transmitted or stored.
|
||||
Reference in New Issue
Block a user