diff --git a/Nuvolari/.gitea/workflows/ci.yml b/Nuvolari/.gitea/workflows/ci.yml new file mode 100644 index 0000000..803960b --- /dev/null +++ b/Nuvolari/.gitea/workflows/ci.yml @@ -0,0 +1,78 @@ +# Gitea Actions workflow, written in GitHub Actions syntax so it runs unchanged on +# either host. If the Gitea instance has no runner configured this file is inert and +# tool/verify.ps1 is the verification that must pass locally. +name: CI + +on: + push: + branches: [main] + paths: ['Nuvolari/**'] + pull_request: + paths: ['Nuvolari/**'] + +defaults: + run: + working-directory: Nuvolari + +jobs: + app: + name: Flutter app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.3' + channel: stable + cache: true + + - name: Install dependencies + working-directory: Nuvolari/app + run: flutter pub get + + - name: Check formatting + working-directory: Nuvolari/app + run: dart format --set-exit-if-changed . + + - name: Analyze + working-directory: Nuvolari/app + run: flutter analyze + + - name: Test + working-directory: Nuvolari/app + run: flutter test + + backend: + name: Python worker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Skip when the worker does not exist yet + id: guard + run: | + if [ -f backend/pyproject.toml ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + if: steps.guard.outputs.exists == 'true' + working-directory: Nuvolari/backend + run: pip install -e ".[dev]" + + - name: Lint + if: steps.guard.outputs.exists == 'true' + working-directory: Nuvolari/backend + run: ruff check . + + - name: Test + if: steps.guard.outputs.exists == 'true' + working-directory: Nuvolari/backend + run: pytest -q diff --git a/Nuvolari/.gitignore b/Nuvolari/.gitignore new file mode 100644 index 0000000..57d2d60 --- /dev/null +++ b/Nuvolari/.gitignore @@ -0,0 +1,64 @@ +# --------------------------------------------------------------------------- +# Nuvolari — Flutter app + Python worker +# The repository root (Europa/.gitignore) only covers Visual Studio artifacts. +# --------------------------------------------------------------------------- + +# Secrets — never commit. Use env.example.json as the template and pass the +# real file with `flutter run --dart-define-from-file=env.json`. +env.json +.env +.env.* +!.env.example +key.properties +*.jks +*.keystore +google-services.json +GoogleService-Info.plist +service-account*.json + +# Dart / Flutter +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +**/doc/api/ +*.iml +migrate_working_dir/ +coverage/ + +# Android +app/android/local.properties +app/android/.gradle/ +app/android/captures/ +app/android/key.properties +*.apk +*.aab + +# iOS / macOS +app/ios/Pods/ +app/ios/.symlinks/ +app/ios/Flutter/Flutter.framework +app/ios/Flutter/Flutter.podspec +app/ios/Flutter/Generated.xcconfig +app/ios/Flutter/flutter_export_environment.sh +**/.DS_Store + +# Python worker +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +*.egg-info/ + +# Worker output — generated radar frames and manifests, never versioned +backend/out/ +backend/.cache/ + +# Editors +.idea/ diff --git a/Nuvolari/README.md b/Nuvolari/README.md new file mode 100644 index 0000000..c41b03c --- /dev/null +++ b/Nuvolari/README.md @@ -0,0 +1,74 @@ +# Nuvolari + +Precipitation radar for Piedmont, Italy. Android first, iOS later, one Flutter codebase. + +Radar imagery comes from the public **Radar-DPC** platform, forecasts from **MET +Norway**, weather alerts from the **ARPA Piemonte** XML-CAP bulletin. The app is free +and ad-supported, with GDPR consent through Google's UMP. + +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 +purposes the official channels always prevail. + +## Layout + +``` +app/ Flutter application (Dart package "nuvolari") +backend/ Python worker: fetches DPC rasters, crops, reprojects, renders PNG frames +docs/ architecture, data sources, licenses, stack decisions, roadmap, privacy +tool/ verification scripts +``` + +Start with [docs/architecture.md](docs/architecture.md), then +[docs/roadmap.md](docs/roadmap.md) for what is built and what is next. + +## Toolchain + +| Tool | Version | +|---|---| +| Flutter | 3.47.3 stable (Dart 3.13.3) | +| Android SDK | platform 36, build-tools 36.0.0, platform-tools | +| JDK | 21 | +| Python | 3.12+ (3.14 works; rasterio ships wheels for it) | + +## Configuration + +Secrets never enter the repository. Copy the template and fill it in: + +```bash +cp env.example.json env.json # env.json is git-ignored +``` + +| Key | Purpose | +|---|---| +| `MAP_STYLE_URL` | MapLibre style URL. Empty falls back to a local offline style. | +| `RADAR_MANIFEST_URL` | Base URL of the published `manifest.json`. | +| `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. | +| `ADMOB_APP_ID`, `ADMOB_BANNER_UNIT_ID` | Empty means Google's test ad units are used. | + +## Running + +```bash +cd app +flutter run --dart-define-from-file=../env.json +``` + +With no `env.json` the app starts in demo mode: mock radar frames from assets, offline +base map style, test ad units. No network and no credentials required. + +## Verifying + +```powershell +.\tool\verify.ps1 # format, analyze, test, build, lint, pytest +.\tool\verify.ps1 -SkipBuild # fast inner loop +``` + +Stages whose target does not exist yet are skipped, so this runs from day one. + +## Attribution + +Radar-DPC (CC BY-SA) · MET Norway (CC BY 4.0) · Arpa Piemonte · +© OpenStreetMap contributors (ODbL). 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. diff --git a/Nuvolari/docs/architecture.md b/Nuvolari/docs/architecture.md new file mode 100644 index 0000000..bdc1476 --- /dev/null +++ b/Nuvolari/docs/architecture.md @@ -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 getLatestManifest(); + Future> 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. diff --git a/Nuvolari/docs/data-sources.md b/Nuvolari/docs/data-sources.md new file mode 100644 index 0000000..4f1c8a4 --- /dev/null +++ b/Nuvolari/docs/data-sources.md @@ -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=` + +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: + +## 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=&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: +- 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 `` contains one `` block **per zone**; the zone is in +`info/area/areaDesc` and holds the bare code (`Piem-A`), not a name. + +```xml + + 261/2026 + centro.funzionale@arpa.piemonte.it + 2026-09-09T10:00:02+00:00 + ActualAlertPublic + AVVISO DI CONDIZIONI METEOROLOGICHE AVVERSE ... + + GIALLO + ...... + EFFETTI SUL TERRITORIO... + TEMPORALI_1224GIALLO + TEMPORALI_2436VERDE + ... + Piem-A + + + +``` + +Parameters are named `_`: + +- **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 `` 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. diff --git a/Nuvolari/docs/licenses.md b/Nuvolari/docs/licenses.md new file mode 100644 index 0000000..92194df --- /dev/null +++ b/Nuvolari/docs/licenses.md @@ -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/ () +``` + +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. diff --git a/Nuvolari/docs/privacy.md b/Nuvolari/docs/privacy.md new file mode 100644 index 0000000..e485875 --- /dev/null +++ b/Nuvolari/docs/privacy.md @@ -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. diff --git a/Nuvolari/docs/roadmap.md b/Nuvolari/docs/roadmap.md new file mode 100644 index 0000000..fac26fa --- /dev/null +++ b/Nuvolari/docs/roadmap.md @@ -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 | diff --git a/Nuvolari/docs/stack-decisions.md b/Nuvolari/docs/stack-decisions.md new file mode 100644 index 0000000..5a78b77 --- /dev/null +++ b/Nuvolari/docs/stack-decisions.md @@ -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/.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. diff --git a/Nuvolari/env.example.json b/Nuvolari/env.example.json new file mode 100644 index 0000000..b44a040 --- /dev/null +++ b/Nuvolari/env.example.json @@ -0,0 +1,8 @@ +{ + "MAP_STYLE_URL": "", + "RADAR_MANIFEST_URL": "", + "RADAR_SOURCE": "mock", + "METNO_USER_AGENT_CONTACT": "you@example.com", + "ADMOB_APP_ID": "", + "ADMOB_BANNER_UNIT_ID": "" +} diff --git a/Nuvolari/tool/verify.ps1 b/Nuvolari/tool/verify.ps1 new file mode 100644 index 0000000..7bd17ff --- /dev/null +++ b/Nuvolari/tool/verify.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Runs the full verification loop for the Nuvolari monorepo. + +.DESCRIPTION + Stages run in order and the script stops at the first failure, because a + formatting or analysis error makes the later stages' output noise. + + Stages whose target does not exist yet are skipped, so this script is + runnable from milestone 0 onwards. + +.PARAMETER SkipBuild + Skips the Android app bundle build. Useful for a fast inner loop; CI never + passes this. + +.EXAMPLE + .\tool\verify.ps1 + .\tool\verify.ps1 -SkipBuild +#> +[CmdletBinding()] +param( + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +$app = Join-Path $repo 'app' +$backend = Join-Path $repo 'backend' +$failures = @() + +function Invoke-Stage { + param( + [string]$Name, + [string]$WorkingDirectory, + [scriptblock]$Action + ) + + Write-Host "" + Write-Host "==> $Name" -ForegroundColor Cyan + + Push-Location $WorkingDirectory + try { + & $Action + if ($LASTEXITCODE -ne 0) { + throw "$Name failed with exit code $LASTEXITCODE" + } + Write-Host " ok" -ForegroundColor Green + } + finally { + Pop-Location + } +} + +function Test-Skip { + param([string]$Path, [string]$Name) + + if (-not (Test-Path $Path)) { + Write-Host "" + Write-Host "==> $Name" -ForegroundColor Cyan + Write-Host " skipped (not created yet: $Path)" -ForegroundColor DarkGray + return $true + } + return $false +} + +# --- Flutter app ------------------------------------------------------------ + +if (-not (Test-Skip -Path (Join-Path $app 'pubspec.yaml') -Name 'Flutter app')) { + + Invoke-Stage 'dart format' $app { + dart format --set-exit-if-changed . + } + + Invoke-Stage 'flutter analyze' $app { + flutter analyze + } + + Invoke-Stage 'flutter test' $app { + flutter test + } + + if ($SkipBuild) { + Write-Host "" + Write-Host "==> flutter build appbundle" -ForegroundColor Cyan + Write-Host " skipped (-SkipBuild)" -ForegroundColor DarkGray + } + else { + Invoke-Stage 'flutter build appbundle --debug' $app { + flutter build appbundle --debug + } + } +} + +# --- Python worker ---------------------------------------------------------- + +if (-not (Test-Skip -Path (Join-Path $backend 'pyproject.toml') -Name 'Python worker')) { + + Invoke-Stage 'ruff check' $backend { + python -m ruff check . + } + + Invoke-Stage 'pytest' $backend { + python -m pytest -q + } +} + +Write-Host "" +Write-Host "All verification stages passed." -ForegroundColor Green