The app shipped the Flutter template's blue F, which said nothing about what it does and did not match the interface. The launcher icon is now a white cloud on the same seed blue the app themes itself with, so the two read as one product. The icon is drawn rather than hand-edited: tool/generate_icon.py renders it from signed distance fields — a union of three circles and a rounded box — using only the standard library, so regenerating it needs no image toolchain. The shape's framing is derived from its own bounding box, which is what keeps it centred; maintaining the bounds separately from the shape had it drifting off-centre. Adds the adaptive icon and the Android 13 monochrome layer, which were missing entirely. The generator frames the cloud against the 72dp the launcher mask always keeps and adaptive_icon_foreground_inset is 0, so the layer is not shrunk twice and the icon reads at the same size as its neighbours on a home screen. Verified on the emulator: the cloud appears in the app drawer and holds its shape down to 48px. docs/architecture.md records how to regenerate it, including the flutter_launcher_icons 0.14.4 bug that writes a string into a boolean Xcode setting and needs reverting by hand afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
6.6 KiB
Markdown
169 lines
6.6 KiB
Markdown
# 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 ──► OpenFreeMap (base map tiles)
|
|
```
|
|
|
|
**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.
|
|
|
|
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
|
|
|
|
```
|
|
Nuvolari/
|
|
├─ app/ Flutter application (Dart package "nuvolari")
|
|
├─ backend/ Python worker
|
|
├─ docs/ this documentation
|
|
└─ tool/ verification and asset-generation 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
|
|
│ └─ alerts/ AlertSource + ArpaCap implementation
|
|
├─ features/ one folder per screen or coherent UI area
|
|
│ ├─ map/ timeline/ places/ settings/ alerts/ sources/
|
|
└─ 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. `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
|
|
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 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.
|
|
|
|
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. 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.
|
|
|
|
## Launcher icon
|
|
|
|
The icon is a white cloud on the app's seed blue `#1F6FB2`, so the launcher and the
|
|
interface are recognisably the same product. It is drawn, not hand-edited:
|
|
`tool/generate_icon.py` renders it from signed distance fields — a union of three
|
|
circles and one rounded box — and writes two 1024px sources into `app/assets/icon/`.
|
|
Those sources are not listed in the pubspec `assets:` block: they feed the build and are
|
|
never bundled into the app.
|
|
|
|
`flutter_launcher_icons` fans them out to the Android densities, the adaptive icon, the
|
|
Android 13 monochrome layer and the iOS set, all of which are committed.
|
|
|
|
To change it:
|
|
|
|
```
|
|
python tool/generate_icon.py
|
|
cd app && dart run flutter_launcher_icons
|
|
```
|
|
|
|
Two things to know before doing that.
|
|
|
|
**The generator owns the framing.** An adaptive layer is 108dp of which only the central
|
|
72dp survives the launcher's mask, so `ADAPTIVE_WIDTH` is measured against that safe zone
|
|
and `adaptive_icon_foreground_inset` is set to `0`. Left at its default of 16 the tool
|
|
would inset the layer a second time and the icon would read visibly smaller than every
|
|
other one on the home screen.
|
|
|
|
**flutter_launcher_icons 0.14.4 corrupts an unrelated Xcode setting.** `ios.dart:340`
|
|
rewrites the value of any line containing `ASSETCATALOG`, so it writes `AppIcon` into
|
|
`ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS`, which is a boolean.
|
|
Check `git diff app/ios/Runner.xcodeproj/project.pbxproj` after running it and restore
|
|
those lines to `YES`. It also minifies `AppIcon.appiconset/Contents.json` onto one line;
|
|
re-indenting it keeps the file reviewable.
|
|
|