Add the region map, permanent attribution and the Sources screen

Puts a MapLibre map on screen framed on the region, with the credit
obligations that come with showing third-party data satisfied structurally
rather than by remembering to add a label.

The base map style comes from MAP_STYLE_URL, and with no key configured the
app generates a fallback style from the region's own bounding box: a flat
background and the extent outline, no network sources at all. That keeps a
fresh clone runnable offline without pulling in a boundary dataset that would
carry its own licence, and it deliberately looks like a placeholder so it is
not mistaken for a finished map.

Attribution is driven by what is actually rendered. The bar lists the credits
for the active sources only, because crediting OpenStreetMap while showing the
fallback style would be a false attribution, and it says plainly when no base
map is configured. It sits below the map rather than floating over it so no
map control or gesture overlay can occlude a credit that the ODbL and CC BY-SA
terms require to be visible.

The Sources screen leads with the independence disclaimer, before the sources
it qualifies, so a reader who stops after the first screenful has still seen
it. Sources with no stated licence — ARPA publishes none for the alert
bulletin — say so explicitly rather than being shown bare or given an invented
one. A separate note explains that the rendered radar frames inherit CC BY-SA
from the DPC source data.

Also declares the INTERNET permission in the main manifest: Flutter injects it
into the debug and profile manifests only, so a release build would otherwise
fail every request on device.

Verified: analyze clean, 66 tests passing, appbundle builds with the native
MapLibre plugin. Not verified visually — this machine has no Android device or
emulator image, so nobody has watched the map render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 11:35:14 +02:00
co-authored by Claude Opus 5
parent 85b2c99949
commit d84095b5a8
13 changed files with 998 additions and 57 deletions
@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Flutter adds this to the debug and profile manifests only, so a
release build needs it declared here or every request fails. -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Nuvolari"
android:name="${applicationName}"
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import '../../core/region/region_config.dart';
import '../../l10n/app_localizations.dart';
import '../sources/sources_screen.dart';
/// Always-visible credits for whatever is currently drawn on the map.
///
/// This is a licence obligation, not decoration: OpenStreetMap's ODbL and
/// Radar-DPC's CC BY-SA both require the credit to be shown wherever the data
/// is. It is therefore never collapsed, hidden behind a gesture, or covered by
/// another control, and it lists only the sources actually on screen — crediting
/// OpenStreetMap while showing the offline fallback would be a false claim.
class AttributionBar extends StatelessWidget {
const AttributionBar({
required this.region,
required this.activeSourceIds,
this.showBaseMapNotice = false,
super.key,
});
final RegionConfig region;
/// Attribution ids, matching `attributions[].id` in the region config, for
/// the data currently rendered.
final Set<String> activeSourceIds;
/// Whether to say that no base map is configured. True when the offline
/// fallback style is in use, so the placeholder is never mistaken for a map.
final bool showBaseMapNotice;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final credits = region.attributions
.where((attribution) => activeSourceIds.contains(attribution.id))
.map((attribution) => attribution.text)
.toList(growable: false);
final parts = <String>[
if (showBaseMapNotice) l10n.baseMapNotConfigured,
...credits,
];
return Material(
color: theme.colorScheme.surface.withValues(alpha: 0.85),
child: InkWell(
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const SourcesScreen())),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
parts.join(' · '),
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
l10n.sourcesLink,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.primary,
),
],
),
),
),
);
}
}
@@ -0,0 +1,114 @@
import 'dart:convert';
import '../../core/config/env.dart';
import '../../core/region/region_config.dart';
/// Which base map the app is showing, and therefore which credits are owed.
enum BaseMapKind {
/// A real vector base map from [Env.mapStyleUrl]. Carries OpenStreetMap data,
/// so the ODbL credit is mandatory.
configured,
/// The bundled fallback: a flat background and the region outline, drawn from
/// the region config. Contains no third-party map data, so crediting
/// OpenStreetMap here would be a false attribution.
offlineFallback,
}
/// Resolves the MapLibre style the app should load.
///
/// With no `MAP_STYLE_URL` configured the app must still run — offline, in
/// tests, and on a fresh clone with no credentials — so it falls back to a
/// style generated from the region's own bounding box.
class MapStyle {
const MapStyle({required this.kind, required this.styleString});
factory MapStyle.forRegion(RegionConfig region) {
if (Env.hasMapStyle) {
return MapStyle(
kind: BaseMapKind.configured,
styleString: Env.mapStyleUrl,
);
}
return MapStyle(
kind: BaseMapKind.offlineFallback,
styleString: buildFallbackStyle(region),
);
}
final BaseMapKind kind;
/// Either a style URL or an inline MapLibre style document; the map widget
/// accepts both.
final String styleString;
/// Attribution ids owed by the base map itself, as they appear in the region
/// config. The radar and forecast layers add their own on top of these.
Set<String> get attributionIds => switch (kind) {
BaseMapKind.configured => const <String>{'osm'},
BaseMapKind.offlineFallback => const <String>{},
};
/// Builds a self-contained MapLibre style: a flat background plus the region
/// bounding box, with no network sources at all.
///
/// The outline is deliberately the *bounding box*, not the administrative
/// boundary. A real boundary would need a third-party dataset with its own
/// licence, and would make the fallback look like a finished map when it is
/// a placeholder.
static String buildFallbackStyle(RegionConfig region) {
final b = region.bounds;
final ring = <List<double>>[
<double>[b.west, b.south],
<double>[b.east, b.south],
<double>[b.east, b.north],
<double>[b.west, b.north],
<double>[b.west, b.south],
];
final style = <String, Object?>{
'version': 8,
'name': 'Nuvolari offline fallback',
'sources': <String, Object?>{
'region-extent': <String, Object?>{
'type': 'geojson',
'data': <String, Object?>{
'type': 'Feature',
'properties': <String, Object?>{'id': region.id},
'geometry': <String, Object?>{
'type': 'Polygon',
'coordinates': <Object?>[ring],
},
},
},
},
'layers': <Object?>[
<String, Object?>{
'id': 'background',
'type': 'background',
'paint': <String, Object?>{'background-color': '#0B1720'},
},
<String, Object?>{
'id': 'region-extent-fill',
'type': 'fill',
'source': 'region-extent',
'paint': <String, Object?>{
'fill-color': '#13303F',
'fill-opacity': 0.7,
},
},
<String, Object?>{
'id': 'region-extent-outline',
'type': 'line',
'source': 'region-extent',
'paint': <String, Object?>{
'line-color': '#4FA3D1',
'line-width': 1.5,
},
},
],
};
return jsonEncode(style);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import '../../core/config/env.dart';
import '../../core/region/region_config.dart';
import '../../core/region/region_repository.dart';
import '../../l10n/app_localizations.dart';
import 'attribution_bar.dart';
import 'map_style.dart';
/// The radar map.
///
/// Milestone 2 renders the base map, frames it on the region and keeps the
/// attribution visible. The radar image layers and the timeline arrive in
/// milestone 3.
class RadarMapScreen extends ConsumerWidget {
const RadarMapScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final region = ref.watch(regionConfigProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: switch (region) {
AsyncData(:final value) => _MapWithAttribution(region: value),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${l10n.dataUnavailable}\n$error'),
),
),
_ => Center(child: Text(l10n.loading)),
},
);
}
}
class _MapWithAttribution extends StatelessWidget {
const _MapWithAttribution({required this.region});
final RegionConfig region;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final style = MapStyle.forRegion(region);
return Column(
children: [
if (Env.isDemoMode)
MaterialBanner(
content: Text(l10n.demoModeBanner),
actions: const [SizedBox.shrink()],
),
Expanded(
child: _RegionMap(region: region, style: style),
),
// Outside the map rather than floating over it, so the credit can never
// be occluded by a map control or a gesture overlay.
AttributionBar(
region: region,
activeSourceIds: style.attributionIds,
showBaseMapNotice: style.kind == BaseMapKind.offlineFallback,
),
],
);
}
}
class _RegionMap extends StatelessWidget {
const _RegionMap({required this.region, required this.style});
final RegionConfig region;
final MapStyle style;
@override
Widget build(BuildContext context) {
final center = region.map.center;
final zoom = region.map.zoom;
final bounds = region.bounds;
return MapLibreMap(
styleString: style.styleString,
initialCameraPosition: CameraPosition(
target: LatLng(center.latitude, center.longitude),
zoom: zoom.initial,
),
minMaxZoomPreference: MinMaxZoomPreference(zoom.min, zoom.max),
// Panning is confined to the region: this app has data for Piedmont and
// nowhere else, so letting the user drift away would only ever show an
// empty map.
cameraTargetBounds: CameraTargetBounds(
LatLngBounds(
southwest: LatLng(bounds.south, bounds.west),
northeast: LatLng(bounds.north, bounds.east),
),
),
// Kept enabled on top of our own attribution bar: some tile providers
// require the plugin's own attribution control, and a duplicated credit
// is harmless where a missing one is a licence breach.
attributionButtonPosition: AttributionButtonPosition.bottomRight,
compassEnabled: false,
rotateGesturesEnabled: false,
tiltGesturesEnabled: false,
myLocationEnabled: false,
);
}
}
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/region/region_config.dart';
import '../../core/region/region_repository.dart';
import '../../l10n/app_localizations.dart';
/// Sources, licences and the independence disclaimer.
///
/// Reachable from the attribution bar on every screen that shows data. The
/// content is driven by the region config so a new region cannot ship without
/// its credits.
class SourcesScreen extends ConsumerWidget {
const SourcesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final region = ref.watch(regionConfigProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.sourcesTitle)),
body: switch (region) {
AsyncData(:final value) => _SourcesBody(region: value),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${l10n.dataUnavailable}\n$error'),
),
),
_ => Center(child: Text(l10n.loading)),
},
);
}
}
class _SourcesBody extends StatelessWidget {
const _SourcesBody({required this.region});
final RegionConfig region;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// The disclaimer comes first, before the sources it qualifies: a reader
// who stops after the first screenful must still have seen it.
_Card(
icon: Icons.info_outline,
title: l10n.sourcesDisclaimerTitle,
body: l10n.sourcesDisclaimerBody,
),
_SectionHeader(l10n.sourcesDataSection),
for (final attribution in region.attributions)
_AttributionTile(attribution: attribution),
_Card(
icon: Icons.copyright_outlined,
title: l10n.sourcesShareAlikeTitle,
body: l10n.sourcesShareAlikeBody,
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: OutlinedButton.icon(
onPressed: () => _open(region.alerts.officialBulletinUrl),
icon: const Icon(Icons.open_in_new),
label: Text(l10n.sourcesOfficialBulletin),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Text(
region.displayName,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.outline,
),
),
),
],
);
}
}
class _AttributionTile extends StatelessWidget {
const _AttributionTile({required this.attribution});
final Attribution attribution;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final license = attribution.license;
return ListTile(
title: Text(attribution.text),
subtitle: Text(
license == null
? l10n.sourcesNoLicenseStated
: l10n.sourcesLicenseLabel(license),
),
trailing: IconButton(
icon: const Icon(Icons.open_in_new),
tooltip: l10n.openLink,
onPressed: () => _open(attribution.url),
),
onTap: () => _open(attribution.url),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.icon, required this.title, required this.body});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(body, style: theme.textTheme.bodyMedium),
],
),
),
],
),
),
);
}
}
/// Opens [url] in the browser, ignoring a failure to launch.
///
/// A dead link is a poor experience but not a reason to crash the screen the
/// disclaimer lives on.
Future<void> _open(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
+42 -6
View File
@@ -1,11 +1,9 @@
{
"@@locale": "it",
"appTitle": "Nuvolari",
"@appTitle": {
"description": "Application name, shown in the task switcher and the app bar"
},
"navRadar": "Radar",
"@navRadar": {
"description": "Bottom navigation label for the radar map"
@@ -18,7 +16,6 @@
"@navAlerts": {
"description": "Bottom navigation label for the official weather alerts"
},
"sourcesTitle": "Fonti e licenze",
"@sourcesTitle": {
"description": "Title of the screen listing data sources, licenses and the disclaimer"
@@ -31,12 +28,10 @@
"@sourcesDisclaimerBody": {
"description": "Disclaimer text. Must never be shortened in a way that drops the independence statement or the primacy of official channels."
},
"regionPiemonte": "Piemonte",
"@regionPiemonte": {
"description": "Display name of the Piedmont region"
},
"demoModeBanner": "Modalità dimostrativa: i dati radar sono di esempio",
"@demoModeBanner": {
"description": "Banner shown when the app runs on the mock radar source instead of live data"
@@ -55,7 +50,6 @@
}
}
},
"loading": "Caricamento…",
"@loading": {
"description": "Generic loading indicator label"
@@ -63,5 +57,47 @@
"retry": "Riprova",
"@retry": {
"description": "Button label to retry a failed operation"
},
"sourcesLink": "Fonti",
"@sourcesLink": {
"description": "Short label on the map attribution bar that opens the Sources screen"
},
"baseMapNotConfigured": "Mappa base non configurata",
"@baseMapNotConfigured": {
"description": "Shown on the attribution bar when no MAP_STYLE_URL is set and the offline fallback style is in use"
},
"sourcesDataSection": "Sorgenti dati",
"@sourcesDataSection": {
"description": "Section heading listing the data sources and their licenses"
},
"sourcesLicenseLabel": "Licenza: {license}",
"@sourcesLicenseLabel": {
"description": "License line under a data source",
"placeholders": {
"license": {
"type": "String",
"example": "CC BY-SA"
}
}
},
"sourcesNoLicenseStated": "Nessuna licenza dichiarata dalla fonte",
"@sourcesNoLicenseStated": {
"description": "Shown for a source that publishes no explicit license; we credit and link it without claiming any reuse right"
},
"sourcesShareAlikeTitle": "Dati derivati",
"@sourcesShareAlikeTitle": {
"description": "Heading of the note explaining that rendered radar frames inherit CC BY-SA"
},
"sourcesShareAlikeBody": "Le immagini radar mostrate sono un'elaborazione dei dati Radar-DPC, distribuiti con licenza CC BY-SA. Anche le immagini elaborate sono quindi CC BY-SA.",
"@sourcesShareAlikeBody": {
"description": "Explains the share-alike obligation inherited from the Radar-DPC source data"
},
"sourcesOfficialBulletin": "Bollettino ufficiale Arpa Piemonte",
"@sourcesOfficialBulletin": {
"description": "Link to the official alert bulletin, shown next to any alert level we republish"
},
"openLink": "Apri",
"@openLink": {
"description": "Accessibility label for a button that opens a link in the browser"
}
}
+2 -46
View File
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/config/env.dart';
import 'core/region/region_repository.dart';
import 'features/map/radar_map_screen.dart';
import 'l10n/app_localizations.dart';
void main() {
@@ -21,50 +20,7 @@ class NuvolariApp extends StatelessWidget {
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FB2)),
),
home: const HomeScreen(),
);
}
}
/// Placeholder shell for milestone 1.
///
/// It exists to prove the pieces are wired: localisation resolves, the region
/// config parses out of the asset bundle, and demo mode is visible. The radar
/// map replaces it in milestone 2.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final region = ref.watch(regionConfigProvider);
return Scaffold(
appBar: AppBar(title: Text(l10n.appTitle)),
body: Column(
children: [
if (Env.isDemoMode)
MaterialBanner(
content: Text(l10n.demoModeBanner),
actions: const [SizedBox.shrink()],
),
Expanded(
child: Center(
child: switch (region) {
AsyncData(:final value) => Text(
value.displayName,
style: Theme.of(context).textTheme.headlineMedium,
),
AsyncError(:final error) => Padding(
padding: const EdgeInsets.all(24),
child: Text('${l10n.dataUnavailable}\n$error'),
),
_ => Text(l10n.loading),
},
),
),
],
),
home: const RadarMapScreen(),
);
}
}
+142 -1
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.dev"
source: hosted
version: "4.2.0"
async:
dependency: transitive
description:
@@ -57,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
fixnum:
dependency: transitive
description:
@@ -96,6 +112,19 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
image:
dependency: transitive
description:
name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
intl:
dependency: "direct main"
description:
@@ -144,6 +173,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
maplibre_gl:
dependency: "direct main"
description:
name: maplibre_gl
sha256: "52e5c873137f8bcb2646802cf1d811575cb4b5bab52d1373d8d23a48f656f166"
url: "https://pub.dev"
source: hosted
version: "0.27.1"
maplibre_gl_platform_interface:
dependency: transitive
description:
name: maplibre_gl_platform_interface
sha256: "476b1a8bd62465d7891218845118cb106df29c17f9034ff1a0cce7dd153179e3"
url: "https://pub.dev"
source: hosted
version: "0.27.1"
maplibre_gl_web:
dependency: transitive
description:
name: maplibre_gl_web
sha256: "3c0e9084edbe7d860f2366f8d6658722c79f3722a4e1234f3e49251a57f8297a"
url: "https://pub.dev"
source: hosted
version: "0.27.1"
matcher:
dependency: transitive
description:
@@ -176,6 +229,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
riverpod:
dependency: transitive
description:
@@ -253,6 +322,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid:
dependency: transitive
description:
@@ -277,6 +410,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.13.3 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.44.0"
+6
View File
@@ -18,6 +18,12 @@ dependencies:
# Date and number formatting for the Italian locale.
intl: ^0.20.3
# Native GPU-composited map. Radar animation redraws a full-viewport image
# several times a second, which a Dart-side tile renderer cannot keep up with.
maplibre_gl: ^0.27.1
# Opens attribution and official-bulletin links in the browser.
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
+18 -1
View File
@@ -1,15 +1,32 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuvolari/core/region/region_config.dart';
import 'package:nuvolari/core/region/region_repository.dart';
import 'package:nuvolari/l10n/app_localizations.dart';
import 'package:nuvolari/main.dart';
void main() {
// The region stays unresolved so the map screen holds its loading state. A
// resolved config would instantiate the native map, which has no
// implementation in the test harness.
testWidgets('starts up and shows the Italian UI', (tester) async {
await tester.pumpWidget(const ProviderScope(child: NuvolariApp()));
await tester.pumpWidget(
ProviderScope(
overrides: [
regionConfigProvider.overrideWith(
(ref) => Completer<RegionConfig>().future,
),
],
child: const NuvolariApp(),
),
);
await tester.pump();
expect(find.text('Nuvolari'), findsWidgets);
expect(find.text('Caricamento…'), findsOneWidget);
});
group('AppLocalizations', () {
@@ -0,0 +1,122 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuvolari/core/region/region_config.dart';
import 'package:nuvolari/features/map/map_style.dart';
void main() {
late RegionConfig region;
setUpAll(() {
region = RegionConfig.parse(
File('assets/regions/piemonte.json').readAsStringSync(),
);
});
group('MapStyle.buildFallbackStyle', () {
late Map<String, Object?> style;
setUp(() {
style = jsonDecode(
MapStyle.buildFallbackStyle(region),
) as Map<String, Object?>;
});
test('is a valid MapLibre style document', () {
expect(style['version'], 8);
expect(style['sources'], isA<Map<String, Object?>>());
expect(style['layers'], isA<List<Object?>>());
});
// The whole point of the fallback is that it works with no network at all,
// so a style that referenced a tile server would defeat it.
test('references no network sources', () {
final encoded = jsonEncode(style);
expect(encoded, isNot(contains('http://')));
expect(encoded, isNot(contains('https://')));
final sources = style['sources']! as Map<String, Object?>;
for (final source in sources.values) {
expect((source! as Map<String, Object?>)['type'], 'geojson');
}
});
test('draws the region extent as a closed ring', () {
final source =
(style['sources']! as Map<String, Object?>)['region-extent']!
as Map<String, Object?>;
final geometry =
(source['data']! as Map<String, Object?>)['geometry']!
as Map<String, Object?>;
final ring =
(geometry['coordinates']! as List<Object?>).first! as List<Object?>;
expect(geometry['type'], 'Polygon');
// Five points: four corners plus the repeated first point that closes it.
expect(ring, hasLength(5));
expect(ring.first, equals(ring.last));
});
test('the ring matches the region bounding box', () {
final source =
(style['sources']! as Map<String, Object?>)['region-extent']!
as Map<String, Object?>;
final geometry =
(source['data']! as Map<String, Object?>)['geometry']!
as Map<String, Object?>;
final ring =
(geometry['coordinates']! as List<Object?>).first! as List<Object?>;
final longitudes = <double>[];
final latitudes = <double>[];
for (final point in ring) {
final pair = point! as List<Object?>;
longitudes.add((pair[0]! as num).toDouble());
latitudes.add((pair[1]! as num).toDouble());
}
expect(longitudes.reduce((a, b) => a < b ? a : b), region.bounds.west);
expect(longitudes.reduce((a, b) => a > b ? a : b), region.bounds.east);
expect(latitudes.reduce((a, b) => a < b ? a : b), region.bounds.south);
expect(latitudes.reduce((a, b) => a > b ? a : b), region.bounds.north);
});
test('has a background layer beneath the region layers', () {
final layers = (style['layers']! as List<Object?>)
.map((layer) => (layer! as Map<String, Object?>)['id'])
.toList();
expect(layers.first, 'background');
expect(layers, contains('region-extent-fill'));
expect(layers, contains('region-extent-outline'));
});
});
group('MapStyle.forRegion', () {
// The test process is built without --dart-define, so MAP_STYLE_URL is
// empty and the fallback is what must come back.
test('falls back when no style URL is configured', () {
final style = MapStyle.forRegion(region);
expect(style.kind, BaseMapKind.offlineFallback);
expect(style.styleString, startsWith('{'));
});
// Crediting OpenStreetMap while showing a style that contains no OSM data
// would be a false attribution, so the fallback owes nothing.
test('the fallback claims no base map attribution', () {
expect(MapStyle.forRegion(region).attributionIds, isEmpty);
});
test('a configured base map owes the OpenStreetMap credit', () {
const configured = MapStyle(
kind: BaseMapKind.configured,
styleString: 'https://example.invalid/style.json',
);
expect(configured.attributionIds, contains('osm'));
});
});
}
@@ -0,0 +1,150 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuvolari/core/region/region_config.dart';
import 'package:nuvolari/core/region/region_repository.dart';
import 'package:nuvolari/features/map/attribution_bar.dart';
import 'package:nuvolari/features/sources/sources_screen.dart';
import 'package:nuvolari/l10n/app_localizations.dart';
RegionConfig loadPiemonte() =>
RegionConfig.parse(File('assets/regions/piemonte.json').readAsStringSync());
Widget wrap(Widget child, RegionConfig region) => ProviderScope(
overrides: [regionConfigProvider.overrideWith((ref) async => region)],
child: MaterialApp(
locale: const Locale('it'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
void main() {
late RegionConfig region;
setUpAll(() {
region = loadPiemonte();
});
group('SourcesScreen', () {
testWidgets('lists every configured source with its licence', (
tester,
) async {
await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle();
for (final attribution in region.attributions) {
expect(
find.text(attribution.text),
findsOneWidget,
reason: '${attribution.id} is missing from the Sources screen',
);
}
expect(find.textContaining('CC BY-SA'), findsWidgets);
expect(find.textContaining('ODbL'), findsWidgets);
});
// ARPA publishes no licence for the alert bulletin. Showing an invented one
// would be worse than showing none, so the screen must say so explicitly.
testWidgets('says when a source states no licence', (tester) async {
await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle();
expect(find.text('Nessuna licenza dichiarata dalla fonte'), findsWidgets);
});
testWidgets('shows the independence disclaimer', (tester) async {
await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle();
expect(find.text('App non ufficiale'), findsOneWidget);
expect(find.textContaining('indipendente'), findsOneWidget);
});
testWidgets('explains the share-alike obligation', (tester) async {
await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle();
expect(find.text('Dati derivati'), findsOneWidget);
expect(find.textContaining('CC BY-SA'), findsWidgets);
});
testWidgets('links the official bulletin', (tester) async {
await tester.pumpWidget(wrap(const SourcesScreen(), region));
await tester.pumpAndSettle();
// The button sits below the fold in the test viewport.
await tester.scrollUntilVisible(
find.text('Bollettino ufficiale Arpa Piemonte'),
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('Bollettino ufficiale Arpa Piemonte'), findsOneWidget);
});
});
group('AttributionBar', () {
testWidgets('shows only the sources currently on screen', (tester) async {
await tester.pumpWidget(
wrap(
Scaffold(
body: AttributionBar(
region: region,
activeSourceIds: const {'osm'},
),
),
region,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('OpenStreetMap'), findsOneWidget);
expect(find.textContaining('Radar-DPC'), findsNothing);
});
testWidgets('says when no base map is configured', (tester) async {
await tester.pumpWidget(
wrap(
Scaffold(
body: AttributionBar(
region: region,
activeSourceIds: const {},
showBaseMapNotice: true,
),
),
region,
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Mappa base non configurata'), findsOneWidget);
});
testWidgets('opens the Sources screen when tapped', (tester) async {
await tester.pumpWidget(
wrap(
Scaffold(
body: AttributionBar(
region: region,
activeSourceIds: const {'osm', 'dpc'},
),
),
region,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(AttributionBar));
await tester.pumpAndSettle();
expect(find.byType(SourcesScreen), findsOneWidget);
expect(find.text('App non ufficiale'), findsOneWidget);
});
});
}
+18 -3
View File
@@ -21,7 +21,7 @@ platform 36, build-tools 36.0.0, platform-tools and all licences accepted, and
`JAVA_HOME` pointing at the JDK 21 a previous Visual Studio install had already left
on the machine.
## M1 — Scaffold, Italian l10n, region config, CI 🔨
## M1 — Scaffold, Italian l10n, region config, CI
`flutter create` with applicationId `it.nuvolari.app`; `flutter_localizations` + `intl`
with `app_it.arb` as template and no hardcoded UI strings; `RegionConfig` loaded from
@@ -31,10 +31,14 @@ with `app_it.arb` as template and no hardcoded UI strings; `RegionConfig` loaded
**Accepts when:** `flutter analyze` reports 0 issues, `flutter test` passes,
`flutter build appbundle --debug` succeeds.
**Done.** All four stages green; the AAB carries applicationId `it.nuvolari.app`,
minSdk 24, targetSdk 36. 50 tests, including assertions against the shipped Piemonte
asset and a captured copy of the live ARPA CAP feed.
> The Gitea instance may have no Actions runner. The workflow file is written to be
> GitHub-Actions compatible, but `tool/verify.ps1` is the verification that must pass.
## M2 — Map, attribution, Sources screen
## M2 — Map, attribution, Sources screen
MapLibre with the style from `MAP_STYLE_URL`, falling back to a local minimal style.
Permanent OSM/ODbL attribution. Sources / Licenses / Disclaimer screen covering
@@ -44,7 +48,18 @@ disclaimer.
**Accepts when:** the map opens centred on Piedmont, attribution is visible at all
times, and the Sources screen is reachable and complete.
## M3 — Animation and timeline
**Done**, with one gap. The fallback style is generated from the region bounding box
rather than loaded from an asset, so it needs no third-party boundary dataset and no
network. The attribution bar lists only the sources actually rendered — crediting
OpenStreetMap while showing the fallback would be a false attribution — and sits below
the map rather than floating over it so no map control can occlude it.
**Not visually verified.** This machine has no Android device and no emulator image, so
the map has been proven to build and its logic unit-tested, but nobody has watched it
render. Connect a phone over USB or install an emulator system image before trusting
the visual result.
## M3 — Animation and timeline 🔨
`RadarSource` with `MockRadarSource` (synthetic frames in assets) and `DpcRadarSource`.
Timeline scrubber, play/pause, adjacent-frame prefetch, `FrameCache` LRU, animation