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:
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user