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