# 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 in-memory LRU `FrameCache`: an in-memory LRU of PNG bytes with a byte budget, plus `retainOnly` so the timeline can drop everything outside the window around the playhead. The budget is in bytes rather than entries because frame size tracks how much precipitation is on screen — a clear sky compresses to almost nothing, a storm does not — so an entry count would let a stormy loop use several times the memory of a calm one. Chosen over `flutter_cache_manager`, which does not expose the eviction control the scrubber needs. Plain LRU keeps frames the prefetcher touched a moment ago even after the playhead has moved to the far end of the timeline, so eviction is driven by distance from the playhead, not by access time. **Deliberately not persistent.** Mock frames already live in the asset bundle, and network frames are re-fetched from the CDN. A disk layer belongs with the network adapter, where it would actually save a request, and is worth adding once real CDN frames are flowing. ## 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.