Scaffold the Flutter app with Italian l10n and region configuration

Creates the app skeleton and the seam everything else in the project hangs
off: region-specific data lives in an asset file, not in code, so adding a
region later is a new JSON file rather than a refactor.

Notable choices:

- applicationId and namespace are it.nuvolari.app rather than the doubled
  it.nuvolari.nuvolari that `flutter create` produces, with the Kotlin package
  and the iOS bundle identifiers moved to match.
- SDK levels are pinned instead of inherited from `flutter.*`. Google Play
  requires API 36, and that is a release blocker rather than something to let a
  Flutter upgrade change silently. minSdk 24 is the highest floor the planned
  dependencies impose.
- Riverpod and Dio-free for now, no code generation: see docs/stack-decisions.md.
- Italian is the l10n source language, so app_it.arb is the template rather
  than a translation of an English original.

The region parser rejects rather than repairs. An inverted bounding box, a map
centre outside its own bounds, an unknown adapter name, a duplicate zone code
or an empty attribution list all throw with the offending field named. Each of
those would otherwise fail silently and visibly wrong: a swapped latitude and
longitude renders the radar in the wrong place, an unknown adapter falling back
to mock would show demo frames where live data was expected, and a missing
attribution is a licence violation rather than a cosmetic gap.

Tests run against the asset that actually ships and against a captured copy of
the live ARPA CAP feed, so the eleven zone codes in the config are checked
against the eleven the feed really emits rather than against a list retyped
from documentation.

Verified: dart format clean, flutter analyze 0 issues, 50 tests passing,
flutter build appbundle --debug produces an AAB with applicationId
it.nuvolari.app, minSdk 24, targetSdk 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:24:26 +02:00
co-authored by Claude Opus 5
parent 3fcbb9f1c4
commit 85b2c99949
78 changed files with 4110 additions and 2 deletions
+65
View File
@@ -0,0 +1,65 @@
/// Build-time configuration, supplied with `--dart-define-from-file=env.json`.
///
/// Every value has a working default, so the app builds and runs with no
/// `env.json` at all: it starts in demo mode against bundled mock frames, with
/// the offline map style and Google's test ad units. That keeps the repository
/// free of secrets and keeps a fresh clone runnable.
///
/// Values are `const` reads of `String.fromEnvironment` so they are inlined and
/// tree-shaken; they cannot be read from a file at run time.
library;
class Env {
const Env._();
/// Which region config to load from `assets/regions/<id>.json`.
static const String regionId = String.fromEnvironment(
'REGION_ID',
defaultValue: 'piemonte',
);
/// MapLibre style URL for the base map.
///
/// Empty means no key is configured, and [hasMapStyle] is false: the app
/// falls back to a minimal style bundled in assets so development and tests
/// work offline.
static const String mapStyleUrl = String.fromEnvironment('MAP_STYLE_URL');
static bool get hasMapStyle => mapStyleUrl.isNotEmpty;
/// Base URL of the radar `manifest.json` published by our worker.
///
/// Empty forces the mock radar source regardless of [radarSource].
static const String radarManifestUrl = String.fromEnvironment(
'RADAR_MANIFEST_URL',
);
/// Radar adapter override: `mock`, `dpc` or `arpa`.
///
/// Empty means "use whatever the region config says".
static const String radarSource = String.fromEnvironment('RADAR_SOURCE');
/// Contact address embedded in the MET Norway User-Agent.
///
/// MET Norway blocks generic User-Agent strings, so forecasts are disabled
/// rather than attempted when this is empty — see [hasForecastContact].
static const String metnoUserAgentContact = String.fromEnvironment(
'METNO_USER_AGENT_CONTACT',
);
static bool get hasForecastContact => metnoUserAgentContact.isNotEmpty;
/// AdMob identifiers. Empty means Google's test units are used.
static const String admobAppId = String.fromEnvironment('ADMOB_APP_ID');
static const String admobBannerUnitId = String.fromEnvironment(
'ADMOB_BANNER_UNIT_ID',
);
static bool get hasAdMobConfig =>
admobAppId.isNotEmpty && admobBannerUnitId.isNotEmpty;
/// True when nothing is configured and the app is running entirely on
/// bundled data. Surfaced in the UI so demo frames are never mistaken for a
/// real forecast.
static bool get isDemoMode => radarManifestUrl.isEmpty;
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:math' as math;
/// A point on the globe in degrees.
class GeoPoint {
const GeoPoint(this.longitude, this.latitude);
final double longitude;
final double latitude;
@override
String toString() => 'GeoPoint($longitude, $latitude)';
@override
bool operator ==(Object other) =>
other is GeoPoint &&
other.longitude == longitude &&
other.latitude == latitude;
@override
int get hashCode => Object.hash(longitude, latitude);
}
/// An axis-aligned geographic bounding box in degrees.
///
/// Serialised as `[west, south, east, north]`, the ordering GeoJSON and GDAL
/// both use, so a bbox can be copied between the region config, the worker and
/// the published manifest without being reordered on the way.
class GeoBounds {
const GeoBounds({
required this.west,
required this.south,
required this.east,
required this.north,
});
/// Parses the `[west, south, east, north]` form.
///
/// Throws [FormatException] if the list is the wrong length, holds
/// non-numbers, or describes an empty or out-of-range box. An inverted box is
/// rejected rather than silently normalised: the likely cause is a swapped
/// latitude and longitude, and normalising would hide it.
factory GeoBounds.fromJson(Object? json) {
if (json is! List || json.length != 4) {
throw const FormatException(
'bbox must be a list of four numbers [west, south, east, north]',
);
}
final values = <double>[];
for (final entry in json) {
if (entry is! num) {
throw FormatException('bbox contains a non-numeric value: $entry');
}
values.add(entry.toDouble());
}
final bounds = GeoBounds(
west: values[0],
south: values[1],
east: values[2],
north: values[3],
);
bounds._validate();
return bounds;
}
final double west;
final double south;
final double east;
final double north;
double get widthDegrees => east - west;
double get heightDegrees => north - south;
GeoPoint get center =>
GeoPoint(west + widthDegrees / 2, south + heightDegrees / 2);
bool contains(GeoPoint point) =>
point.longitude >= west &&
point.longitude <= east &&
point.latitude >= south &&
point.latitude <= north;
void _validate() {
if (west < -180 || east > 180) {
throw FormatException('bbox longitude out of range: $west..$east');
}
if (south < -90 || north > 90) {
throw FormatException('bbox latitude out of range: $south..$north');
}
if (east <= west) {
throw FormatException(
'bbox east ($east) must be greater than west ($west)',
);
}
if (north <= south) {
throw FormatException(
'bbox north ($north) must be greater than south ($south)',
);
}
}
List<double> toJson() => [west, south, east, north];
@override
String toString() => 'GeoBounds($west, $south, $east, $north)';
@override
bool operator ==(Object other) =>
other is GeoBounds &&
other.west == west &&
other.south == south &&
other.east == east &&
other.north == north;
@override
int get hashCode => Object.hash(west, south, east, north);
}
/// Web Mercator helpers.
///
/// The worker publishes frames already reprojected to EPSG:3857, so the app
/// only needs the forward transform to reason about frame geometry — never the
/// warp itself.
class WebMercator {
const WebMercator._();
/// Latitude beyond which Web Mercator is undefined in practice.
static const double maxLatitude = 85.05112878;
static double xFromLongitude(double longitude) => longitude / 360 + 0.5;
static double yFromLatitude(double latitude) {
final clamped = latitude.clamp(-maxLatitude, maxLatitude);
final sin = math.sin(clamped * math.pi / 180);
return 0.5 - math.log((1 + sin) / (1 - sin)) / (4 * math.pi);
}
}
@@ -0,0 +1,433 @@
import 'dart:convert';
import 'geo.dart';
/// Which radar adapter a region uses.
///
/// The set is closed on purpose: an unknown adapter name in a config file is an
/// error, not something to fall back from, because falling back would quietly
/// show mock data where live data was expected.
enum RadarAdapter {
/// Synthetic frames bundled in assets. Works offline, no credentials.
mock,
/// Frames published by our worker from Radar-DPC data.
dpc,
/// Direct ARPA Piemonte radar. Disabled until authorization exists.
arpa;
static RadarAdapter parse(String value) => RadarAdapter.values.firstWhere(
(adapter) => adapter.name == value,
orElse: () => throw FormatException('unknown radar adapter: $value'),
);
}
enum ForecastAdapter {
/// MET Norway locationforecast.
metno,
/// ItaliaMeteo ICON-2I via MeteoHub.
iconIt2;
static ForecastAdapter parse(String value) =>
ForecastAdapter.values.firstWhere(
(adapter) => adapter.name == value,
orElse: () => throw FormatException('unknown forecast adapter: $value'),
);
}
enum AlertAdapter {
/// ARPA Piemonte XML-CAP bulletin, fetched through our backend.
arpaCap;
static AlertAdapter parse(String value) => AlertAdapter.values.firstWhere(
(adapter) => adapter.name == value,
orElse: () => throw FormatException('unknown alert adapter: $value'),
);
}
/// Zoom limits for the region's map view.
class ZoomRange {
const ZoomRange({
required this.min,
required this.max,
required this.initial,
});
factory ZoomRange.fromJson(Map<String, Object?> json) {
final min = _requireDouble(json, 'min');
final max = _requireDouble(json, 'max');
final initial = _requireDouble(json, 'initial');
if (max <= min) {
throw FormatException('zoom max ($max) must be greater than min ($min)');
}
if (initial < min || initial > max) {
throw FormatException('zoom initial ($initial) outside $min..$max');
}
return ZoomRange(min: min, max: max, initial: initial);
}
final double min;
final double max;
final double initial;
}
/// Map framing for the region.
class MapConfig {
const MapConfig({required this.center, required this.zoom});
factory MapConfig.fromJson(Map<String, Object?> json) {
final center = json['center'];
if (center is! List || center.length != 2) {
throw const FormatException(
'map.center must be a list of two numbers [longitude, latitude]',
);
}
final longitude = center[0];
final latitude = center[1];
if (longitude is! num || latitude is! num) {
throw const FormatException('map.center contains a non-numeric value');
}
return MapConfig(
center: GeoPoint(longitude.toDouble(), latitude.toDouble()),
zoom: ZoomRange.fromJson(_requireMap(json, 'zoom')),
);
}
final GeoPoint center;
final ZoomRange zoom;
}
/// Radar source settings for the region.
class RadarSourceConfig {
const RadarSourceConfig({
required this.defaultAdapter,
required this.availableAdapters,
required this.product,
required this.frameInterval,
});
factory RadarSourceConfig.fromJson(Map<String, Object?> json) {
final available = _requireStringList(
json,
'availableAdapters',
).map(RadarAdapter.parse).toList(growable: false);
final defaultAdapter = RadarAdapter.parse(
_requireString(json, 'defaultAdapter'),
);
if (!available.contains(defaultAdapter)) {
throw FormatException(
'radar defaultAdapter ${defaultAdapter.name} is not in availableAdapters',
);
}
final minutes = _requireInt(json, 'frameIntervalMinutes');
if (minutes <= 0) {
throw FormatException('frameIntervalMinutes must be positive: $minutes');
}
return RadarSourceConfig(
defaultAdapter: defaultAdapter,
availableAdapters: available,
product: _requireString(json, 'product'),
frameInterval: Duration(minutes: minutes),
);
}
final RadarAdapter defaultAdapter;
final List<RadarAdapter> availableAdapters;
/// Radar-DPC product code, for example `VMI`.
final String product;
/// Nominal spacing between consecutive frames.
final Duration frameInterval;
}
class ForecastSourceConfig {
const ForecastSourceConfig({
required this.defaultAdapter,
required this.availableAdapters,
});
factory ForecastSourceConfig.fromJson(Map<String, Object?> json) {
final available = _requireStringList(
json,
'availableAdapters',
).map(ForecastAdapter.parse).toList(growable: false);
final defaultAdapter = ForecastAdapter.parse(
_requireString(json, 'defaultAdapter'),
);
if (!available.contains(defaultAdapter)) {
throw FormatException(
'forecast defaultAdapter ${defaultAdapter.name} is not in '
'availableAdapters',
);
}
return ForecastSourceConfig(
defaultAdapter: defaultAdapter,
availableAdapters: available,
);
}
final ForecastAdapter defaultAdapter;
final List<ForecastAdapter> availableAdapters;
}
class AlertSourceConfig {
const AlertSourceConfig({
required this.defaultAdapter,
required this.availableAdapters,
required this.officialBulletinUrl,
});
factory AlertSourceConfig.fromJson(Map<String, Object?> json) {
final available = _requireStringList(
json,
'availableAdapters',
).map(AlertAdapter.parse).toList(growable: false);
final defaultAdapter = AlertAdapter.parse(
_requireString(json, 'defaultAdapter'),
);
if (!available.contains(defaultAdapter)) {
throw FormatException(
'alerts defaultAdapter ${defaultAdapter.name} is not in '
'availableAdapters',
);
}
return AlertSourceConfig(
defaultAdapter: defaultAdapter,
availableAdapters: available,
officialBulletinUrl: _requireString(json, 'officialBulletinUrl'),
);
}
final AlertAdapter defaultAdapter;
final List<AlertAdapter> availableAdapters;
/// The official bulletin, linked next to every alert we display.
///
/// Republishing alert levels without a route back to the source would leave
/// the reader unable to check us, so this is required, not optional.
final String officialBulletinUrl;
}
/// One civil-protection alert zone.
class AlertZone {
const AlertZone({
required this.code,
required this.name,
required this.provinces,
});
factory AlertZone.fromJson(Map<String, Object?> json) => AlertZone(
code: _requireString(json, 'code'),
name: _requireString(json, 'name'),
provinces: _requireStringList(json, 'provinces'),
);
/// The code as it appears in the CAP feed, for example `Piem-A`.
final String code;
/// Human-readable name. Not present in the CAP feed — it comes from the
/// authority's zone documentation and is carried here.
final String name;
/// Province codes the zone spans, for example `['NO', 'VB']`.
final List<String> provinces;
}
/// An attribution that must be displayed for the region's data.
class Attribution {
const Attribution({
required this.id,
required this.text,
required this.license,
required this.url,
});
factory Attribution.fromJson(Map<String, Object?> json) => Attribution(
id: _requireString(json, 'id'),
text: _requireString(json, 'text'),
license: json['license'] as String?,
url: _requireString(json, 'url'),
);
final String id;
final String text;
/// Null when the source publishes no explicit license — we still credit it
/// and link it, but we claim no reuse right.
final String? license;
final String url;
}
/// Everything region-specific, loaded from `assets/regions/<id>.json`.
///
/// Adding a region is a new asset file, not a code change. Nothing here may be
/// duplicated as a constant elsewhere in the app.
class RegionConfig {
const RegionConfig({
required this.id,
required this.displayName,
required this.timeZone,
required this.bounds,
required this.map,
required this.radar,
required this.forecast,
required this.alerts,
required this.alertZones,
required this.attributions,
});
/// Parses a region config document.
///
/// Throws [FormatException] naming the offending field. Every failure here is
/// a packaging error rather than a runtime condition, so it should surface
/// loudly in tests instead of degrading at run time.
factory RegionConfig.fromJson(Map<String, Object?> json) {
final bounds = GeoBounds.fromJson(json['bbox']);
final map = MapConfig.fromJson(_requireMap(json, 'map'));
if (!bounds.contains(map.center)) {
throw FormatException(
'map.center ${map.center} lies outside bbox $bounds',
);
}
final sources = _requireMap(json, 'sources');
final zones = _requireList(json, 'alertZones')
.map((entry) => AlertZone.fromJson(_asMap(entry, 'alertZones entry')))
.toList(growable: false);
if (zones.isEmpty) {
throw const FormatException('alertZones must not be empty');
}
final seenCodes = <String>{};
for (final zone in zones) {
if (!seenCodes.add(zone.code)) {
throw FormatException('duplicate alert zone code: ${zone.code}');
}
}
final attributions = _requireList(json, 'attributions')
.map(
(entry) => Attribution.fromJson(_asMap(entry, 'attributions entry')),
)
.toList(growable: false);
if (attributions.isEmpty) {
throw const FormatException(
'attributions must not be empty: every data source we display carries '
'a credit obligation',
);
}
return RegionConfig(
id: _requireString(json, 'id'),
displayName: _requireString(json, 'displayName'),
timeZone: _requireString(json, 'timeZone'),
bounds: bounds,
map: map,
radar: RadarSourceConfig.fromJson(_requireMap(sources, 'radar')),
forecast: ForecastSourceConfig.fromJson(_requireMap(sources, 'forecast')),
alerts: AlertSourceConfig.fromJson(_requireMap(sources, 'alerts')),
alertZones: zones,
attributions: attributions,
);
}
/// Parses a region config from its raw JSON text.
factory RegionConfig.parse(String source) {
final decoded = jsonDecode(source);
if (decoded is! Map<String, Object?>) {
throw const FormatException('region config must be a JSON object');
}
return RegionConfig.fromJson(decoded);
}
final String id;
final String displayName;
/// IANA time zone the region's forecasts and alerts are expressed in.
final String timeZone;
final GeoBounds bounds;
final MapConfig map;
final RadarSourceConfig radar;
final ForecastSourceConfig forecast;
final AlertSourceConfig alerts;
final List<AlertZone> alertZones;
final List<Attribution> attributions;
/// The zone with [code], or null when the feed names a zone this region does
/// not know about.
AlertZone? zoneByCode(String code) {
for (final zone in alertZones) {
if (zone.code == code) return zone;
}
return null;
}
}
// --- parsing helpers --------------------------------------------------------
Map<String, Object?> _asMap(Object? value, String what) {
if (value is! Map<String, Object?>) {
throw FormatException('$what must be an object, got ${value.runtimeType}');
}
return value;
}
Map<String, Object?> _requireMap(Map<String, Object?> json, String key) =>
_asMap(_require(json, key), key);
List<Object?> _requireList(Map<String, Object?> json, String key) {
final value = _require(json, key);
if (value is! List) {
throw FormatException('$key must be a list, got ${value.runtimeType}');
}
return value;
}
Object _require(Map<String, Object?> json, String key) {
final value = json[key];
if (value == null) {
throw FormatException('missing required field: $key');
}
return value;
}
String _requireString(Map<String, Object?> json, String key) {
final value = _require(json, key);
if (value is! String || value.isEmpty) {
throw FormatException('$key must be a non-empty string');
}
return value;
}
List<String> _requireStringList(Map<String, Object?> json, String key) {
final values = _requireList(json, key);
return values
.map((entry) {
if (entry is! String || entry.isEmpty) {
throw FormatException('$key must contain non-empty strings');
}
return entry;
})
.toList(growable: false);
}
double _requireDouble(Map<String, Object?> json, String key) {
final value = _require(json, key);
if (value is! num) {
throw FormatException('$key must be a number');
}
return value.toDouble();
}
int _requireInt(Map<String, Object?> json, String key) {
final value = _require(json, key);
if (value is! int) {
throw FormatException('$key must be an integer');
}
return value;
}
@@ -0,0 +1,47 @@
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/env.dart';
import 'region_config.dart';
/// Loads region configuration from the asset bundle.
///
/// Takes the bundle as a dependency so tests can supply their own fixtures
/// without touching the real assets.
class RegionRepository {
const RegionRepository({this.bundle});
/// Overridden in tests; null means the real asset bundle.
final AssetBundle? bundle;
AssetBundle get _assets => bundle ?? rootBundle;
static String assetPathFor(String regionId) =>
'assets/regions/$regionId.json';
/// Loads and validates the config for [regionId].
///
/// Throws [FormatException] if the document is malformed. That is a
/// packaging error — the asset ships with the app — so it must fail loudly
/// rather than fall back to a default region.
Future<RegionConfig> load(String regionId) async {
final source = await _assets.loadString(assetPathFor(regionId));
return RegionConfig.parse(source);
}
}
/// The region the app is currently configured for.
final regionIdProvider = Provider<String>((ref) => Env.regionId);
final regionRepositoryProvider = Provider<RegionRepository>(
(ref) => const RegionRepository(),
);
/// The active region's configuration.
///
/// Everything region-specific reads from here rather than from constants, so
/// adding a region stays a matter of shipping another asset file.
final regionConfigProvider = FutureProvider<RegionConfig>((ref) async {
final repository = ref.watch(regionRepositoryProvider);
return repository.load(ref.watch(regionIdProvider));
});