diff --git a/Nuvolari/app/lib/core/cache/frame_cache.dart b/Nuvolari/app/lib/core/cache/frame_cache.dart new file mode 100644 index 0000000..e3ac522 --- /dev/null +++ b/Nuvolari/app/lib/core/cache/frame_cache.dart @@ -0,0 +1,122 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +/// A byte-budgeted LRU cache for radar frame images. +/// +/// Radar animation needs several frames resident at once, and a phone cannot +/// hold twenty decoded images. The budget is in bytes rather than entries +/// because frame sizes vary with how much precipitation is on screen — a clear +/// sky compresses to almost nothing, a storm does not — so counting entries +/// would let a stormy loop use several times the memory of a calm one. +/// +/// Not persistent. Frames that came from the asset bundle are already on disk, +/// and network frames are re-fetched from the CDN; a disk layer belongs with +/// the network adapter, not here. +class FrameCache { + FrameCache({this.maxBytes = defaultMaxBytes}) + : assert(maxBytes > 0, 'maxBytes must be positive'); + + /// Roughly twenty 512×512 frames of typical weather, with headroom. + static const int defaultMaxBytes = 24 * 1024 * 1024; + + final int maxBytes; + + /// Insertion-ordered, and re-inserted on every read, so the first key is + /// always the least recently used. + final LinkedHashMap _entries = + LinkedHashMap(); + + /// In-flight loads, so two prefetches of the same frame do not both hit the + /// network. This is what makes scrubbing back and forth over a partly loaded + /// timeline cheap. + final Map> _inFlight = + >{}; + + int _currentBytes = 0; + + int get currentBytes => _currentBytes; + + int get length => _entries.length; + + bool contains(String key) => _entries.containsKey(key); + + /// Keys currently resident, least recently used first. + Iterable get keys => _entries.keys; + + /// Returns the cached bytes for [key], marking it most recently used. + Uint8List? get(String key) { + final value = _entries.remove(key); + if (value == null) return null; + _entries[key] = value; + return value; + } + + /// Stores [bytes] under [key], evicting least recently used entries until the + /// total fits the budget. + /// + /// A single frame larger than the whole budget is stored anyway and then + /// immediately becomes the only entry: refusing it would mean the timeline + /// could never display that frame at all, which is worse than briefly + /// exceeding the target. + void put(String key, Uint8List bytes) { + final existing = _entries.remove(key); + if (existing != null) { + _currentBytes -= existing.lengthInBytes; + } + + _entries[key] = bytes; + _currentBytes += bytes.lengthInBytes; + + while (_currentBytes > maxBytes && _entries.length > 1) { + final oldest = _entries.keys.first; + final evicted = _entries.remove(oldest)!; + _currentBytes -= evicted.lengthInBytes; + } + } + + /// Returns the bytes for [key], loading them with [fetch] on a miss. + /// + /// Concurrent calls for the same key share one [fetch]. A failed load is not + /// cached, so a transient error does not poison the frame for the rest of the + /// session. + Future load(String key, Future Function() fetch) async { + final cached = get(key); + if (cached != null) return cached; + + final pending = _inFlight[key]; + if (pending != null) return pending; + + final future = fetch(); + _inFlight[key] = future; + try { + final bytes = await future; + put(key, bytes); + return bytes; + } finally { + // removeWhere rather than remove: the latter hands back the Future that + // was stored, and discarding a Future is exactly what the lint is there + // to catch elsewhere. + _inFlight.removeWhere((candidate, _) => candidate == key); + } + } + + /// Drops everything not in [keep]. + /// + /// Used when the playhead moves far enough that a whole stretch of the + /// timeline is no longer worth holding. Plain LRU would keep frames the + /// prefetcher touched a moment ago even though they are now at the far end of + /// the timeline, so the caller gets to say what still matters. + void retainOnly(Set keep) { + final doomed = _entries.keys + .where((key) => !keep.contains(key)) + .toList(growable: false); + for (final key in doomed) { + _currentBytes -= _entries.remove(key)!.lengthInBytes; + } + } + + void clear() { + _entries.clear(); + _currentBytes = 0; + } +} diff --git a/Nuvolari/app/lib/data/radar/dpc_radar_source.dart b/Nuvolari/app/lib/data/radar/dpc_radar_source.dart new file mode 100644 index 0000000..da91144 --- /dev/null +++ b/Nuvolari/app/lib/data/radar/dpc_radar_source.dart @@ -0,0 +1,103 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +import 'radar_manifest.dart'; +import 'radar_source.dart'; + +/// Radar frames published by our worker from Radar-DPC data. +/// +/// This talks to **our CDN**, never to `radar-api.protezionecivile.it`. The DPC +/// API hands out pre-signed URLs that expire in minutes, serves whole-Italy +/// GeoTIFFs a phone has no business decoding, and would be hit by every install +/// at once. The worker is the only client of that API; the app reads the flat +/// files it publishes. +/// +/// The manifest URL is the unit of configuration: frame paths inside it are +/// relative, so the whole published tree can move between hosts without +/// republishing the frames. +class DpcRadarSource implements RadarSource { + DpcRadarSource({required this.manifestUrl, Dio? client}) + : _client = + client ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + // Frames are PNG and the manifest is JSON; let this class decide + // how to read each rather than have Dio guess. + responseType: ResponseType.bytes, + ), + ); + + /// Absolute URL of `manifest.json`. + final String manifestUrl; + + final Dio _client; + + RadarManifest? _cached; + + /// Drops the memoised manifest so the next read goes back to the CDN. + void invalidate() => _cached = null; + + @override + Future getLatestManifest() async { + final Response> response; + try { + response = await _client.get>(manifestUrl); + } on DioException catch (error) { + throw RadarUnavailableException( + 'could not fetch the radar manifest from $manifestUrl', + cause: error, + ); + } + + final body = response.data; + if (body == null || body.isEmpty) { + throw RadarUnavailableException( + 'radar manifest at $manifestUrl is empty', + ); + } + + try { + return _cached = RadarManifest.parse(String.fromCharCodes(body)); + } on FormatException catch (error) { + // A malformed manifest means the worker published something wrong. There + // is nothing the app can do about it, and reaching for a stale copy is + // the caller's decision, so it surfaces as plain unavailability. + throw RadarUnavailableException( + 'radar manifest at $manifestUrl is malformed', + cause: error, + ); + } + } + + @override + Future> getFrames() async { + final cached = _cached; + if (cached != null) return cached.frames; + return (await getLatestManifest()).frames; + } + + @override + Future loadFrameBytes(RadarFrame frame) async { + final url = resolveFrameUrl(frame); + try { + final response = await _client.get>(url); + final body = response.data; + if (body == null || body.isEmpty) { + throw RadarUnavailableException('radar frame at $url is empty'); + } + return Uint8List.fromList(body); + } on DioException catch (error) { + throw RadarUnavailableException( + 'could not fetch the radar frame at $url', + cause: error, + ); + } + } + + /// Resolves a frame's manifest-relative path against [manifestUrl]. + String resolveFrameUrl(RadarFrame frame) => + Uri.parse(manifestUrl).resolve(frame.path).toString(); +} diff --git a/Nuvolari/app/lib/data/radar/mock_radar_source.dart b/Nuvolari/app/lib/data/radar/mock_radar_source.dart index 5ee0b6d..e59beae 100644 --- a/Nuvolari/app/lib/data/radar/mock_radar_source.dart +++ b/Nuvolari/app/lib/data/radar/mock_radar_source.dart @@ -16,13 +16,19 @@ import 'radar_source.dart'; /// carry the same manifest shape the Python worker publishes, so nothing here /// is a special case. class MockRadarSource implements RadarSource { - MockRadarSource({this.bundle, this.manifestAsset = defaultManifestAsset}); + MockRadarSource({ + this.bundle, + this.manifestAsset = defaultManifestAsset, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; static const String defaultManifestAsset = 'assets/mock/manifest.json'; /// Overridden in tests; null means the real asset bundle. final AssetBundle? bundle; + final DateTime Function() _clock; + /// Asset path of the manifest; frame paths are resolved relative to it. final String manifestAsset; @@ -48,7 +54,7 @@ class MockRadarSource implements RadarSource { } try { - return _cached = RadarManifest.parse(source); + return _cached = _rebaseToNow(RadarManifest.parse(source)); } on FormatException catch (error) { // A malformed bundled asset is a packaging bug, but it still reaches the // caller as unavailability so demo mode degrades like any other source. @@ -77,6 +83,52 @@ class MockRadarSource implements RadarSource { } } + /// Shifts the bundled timestamps so the newest frame lands on the present. + /// + /// The generator writes a fixed epoch so regenerating the assets produces + /// identical bytes and the committed frames do not churn. Left alone, demo + /// mode would therefore always sit behind a "data is stale" warning and never + /// show what the app looks like when things are working — which is the one + /// thing demo mode exists to show. + /// + /// Only the timestamps move. The images, their order and their spacing are + /// untouched, so the timeline behaves exactly as it would on live data. + RadarManifest _rebaseToNow(RadarManifest manifest) { + if (manifest.frames.isEmpty) return manifest; + + final interval = manifest.frames.length >= 2 + ? manifest.frames.last.timestamp.difference( + manifest.frames[manifest.frames.length - 2].timestamp, + ) + : const Duration(minutes: 5); + + // Land on a whole multiple of the interval rather than an arbitrary + // millisecond, the way a real publication would. + final now = _clock().toUtc(); + final step = interval.inMilliseconds; + final target = DateTime.fromMillisecondsSinceEpoch( + step <= 0 + ? now.millisecondsSinceEpoch + : now.millisecondsSinceEpoch - now.millisecondsSinceEpoch % step, + isUtc: true, + ); + + final shift = target.difference(manifest.frames.last.timestamp); + + return RadarManifest( + regionId: manifest.regionId, + product: manifest.product, + generatedAt: manifest.generatedAt.add(shift), + bounds: manifest.bounds, + frames: List.unmodifiable([ + for (final frame in manifest.frames) + RadarFrame(timestamp: frame.timestamp.add(shift), path: frame.path), + ]), + legend: manifest.legend, + attribution: manifest.attribution, + ); + } + /// Resolves a frame's manifest-relative path against [manifestAsset]. String resolveAssetPath(RadarFrame frame) { final separator = manifestAsset.lastIndexOf('/'); diff --git a/Nuvolari/app/lib/features/map/attribution_bar.dart b/Nuvolari/app/lib/features/map/attribution_bar.dart index f105f3b..dfd3e09 100644 --- a/Nuvolari/app/lib/features/map/attribution_bar.dart +++ b/Nuvolari/app/lib/features/map/attribution_bar.dart @@ -15,6 +15,7 @@ class AttributionBar extends StatelessWidget { const AttributionBar({ required this.region, required this.activeSourceIds, + this.additionalCredits = const [], this.showBaseMapNotice = false, super.key, }); @@ -25,6 +26,12 @@ class AttributionBar extends StatelessWidget { /// the data currently rendered. final Set activeSourceIds; + /// Credits that travel with the data rather than with the region, such as the + /// attribution line inside a radar manifest. Whoever published those frames + /// states there who to credit for them, which is more current than anything + /// baked into the app. + final List additionalCredits; + /// 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; @@ -42,6 +49,7 @@ class AttributionBar extends StatelessWidget { final parts = [ if (showBaseMapNotice) l10n.baseMapNotConfigured, ...credits, + ...additionalCredits.where((credit) => credit.isNotEmpty), ]; return Material( diff --git a/Nuvolari/app/lib/features/map/radar_map_screen.dart b/Nuvolari/app/lib/features/map/radar_map_screen.dart index 5978e5f..522b24a 100644 --- a/Nuvolari/app/lib/features/map/radar_map_screen.dart +++ b/Nuvolari/app/lib/features/map/radar_map_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -5,15 +7,17 @@ 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 '../../data/radar/radar_manifest.dart'; +import '../../data/radar/radar_source.dart'; import '../../l10n/app_localizations.dart'; +import '../timeline/data_age_banner.dart'; +import '../timeline/radar_timeline.dart'; +import '../timeline/timeline_bar.dart'; import 'attribution_bar.dart'; import 'map_style.dart'; +import 'radar_overlay.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. +/// The radar map: base map, animated precipitation overlay, timeline. class RadarMapScreen extends ConsumerWidget { const RadarMapScreen({super.key}); @@ -38,15 +42,18 @@ class RadarMapScreen extends ConsumerWidget { } } -class _MapWithAttribution extends StatelessWidget { +class _MapWithAttribution extends ConsumerWidget { const _MapWithAttribution({required this.region}); final RegionConfig region; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final style = MapStyle.forRegion(region); + final manifest = ref.watch( + radarTimelineProvider.select((state) => state.manifest), + ); return Column( children: [ @@ -56,8 +63,22 @@ class _MapWithAttribution extends StatelessWidget { actions: const [SizedBox.shrink()], ), Expanded( - child: _RegionMap(region: region, style: style), + child: Stack( + children: [ + Positioned.fill( + child: _RegionMap(region: region, style: style), + ), + if (manifest != null) + Positioned( + left: 12, + bottom: 12, + child: _RadarLegend(legend: manifest.legend), + ), + ], + ), ), + const DataAgeBanner(), + const TimelineBar(), // Outside the map rather than floating over it, so the credit can never // be occluded by a map control. SafeArea keeps it clear of the system // gesture bar as well — a credit sitting behind the navigation pill is @@ -68,6 +89,12 @@ class _MapWithAttribution extends StatelessWidget { region: region, activeSourceIds: style.attributionIds, showBaseMapNotice: style.kind == BaseMapKind.offlineFallback, + // The manifest carries the credit for the frames it indexes, which + // is the whole point of it travelling with the data: whoever + // published these frames says here who to credit for them. + additionalCredits: [ + if (manifest != null) manifest.attribution, + ], ), ), ], @@ -75,21 +102,54 @@ class _MapWithAttribution extends StatelessWidget { } } -class _RegionMap extends StatelessWidget { +class _RegionMap extends ConsumerStatefulWidget { const _RegionMap({required this.region, required this.style}); final RegionConfig region; final MapStyle style; + @override + ConsumerState<_RegionMap> createState() => _RegionMapState(); +} + +class _RegionMapState extends ConsumerState<_RegionMap> { + MapLibreMapController? _controller; + RadarOverlay? _overlay; + AppLifecycleListener? _lifecycle; + + /// Guards against a frame that finished loading after the playhead moved on, + /// which would briefly show the wrong image. + int _requestId = 0; + + @override + void initState() { + super.initState(); + // Playback runs a timer and pushes a texture several times a second. + // Behind another app there is no one watching, so it is pure battery. + _lifecycle = AppLifecycleListener( + onPause: () => + ref.read(radarTimelineProvider.notifier).onAppBackgrounded(), + ); + } + + @override + void dispose() { + _lifecycle?.dispose(); + unawaited(_overlay?.detach()); + super.dispose(); + } + /// Frames the whole region once the map is ready. /// /// The configured initial zoom is only a starting point: a single zoom number /// cannot fit the region on both a small phone and a tablet, and on a 411dp /// screen zoom 7.2 puts the viewport entirely inside the region, so the user - /// sees a flat expanse with no coastline, border or outline to orient by. - /// Fitting the bounds works on every screen size. - Future _frameRegion(MapLibreMapController controller) async { - final bounds = region.bounds; + /// sees a flat expanse with no border or outline to orient by. Fitting the + /// bounds works on every screen size. + Future _onMapCreated(MapLibreMapController controller) async { + _controller = controller; + + final bounds = widget.region.bounds; await controller.moveCamera( CameraUpdate.newLatLngBounds( LatLngBounds( @@ -104,15 +164,52 @@ class _RegionMap extends StatelessWidget { ); } + /// Layers can only be added once the style exists, and the style is reloaded + /// whenever it changes, so the overlay is rebuilt here rather than in + /// [_onMapCreated]. + Future _onStyleLoaded() async { + final controller = _controller; + if (controller == null) return; + + _overlay = RadarOverlay(map: controller, bounds: widget.region.bounds); + await _showFrame(ref.read(radarTimelineProvider).currentFrame); + } + + Future _showFrame(RadarFrame? frame) async { + final overlay = _overlay; + if (overlay == null || frame == null) return; + if (overlay.visiblePath == frame.path) return; + + final request = ++_requestId; + try { + final bytes = await ref + .read(radarTimelineProvider.notifier) + .bytesFor(frame); + // Someone scrubbed while this was loading; that newer frame wins. + if (!mounted || request != _requestId) return; + await overlay.show(frame.path, bytes); + } on RadarUnavailableException { + // Hold the previous frame. A gap mid-timeline is normal while the worker + // publishes, and blanking the map would be a worse answer than showing + // the neighbouring minute. + } + } + @override Widget build(BuildContext context) { - final center = region.map.center; - final zoom = region.map.zoom; - final bounds = region.bounds; + ref.listen( + radarTimelineProvider.select((state) => state.currentFrame), + (_, frame) => unawaited(_showFrame(frame)), + ); + + final center = widget.region.map.center; + final zoom = widget.region.map.zoom; + final bounds = widget.region.bounds; return MapLibreMap( - styleString: style.styleString, - onMapCreated: _frameRegion, + styleString: widget.style.styleString, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: () => unawaited(_onStyleLoaded()), initialCameraPosition: CameraPosition( target: LatLng(center.latitude, center.longitude), zoom: zoom.initial, @@ -138,3 +235,63 @@ class _RegionMap extends StatelessWidget { ); } } + +/// The colour ramp for the frames currently on screen. +/// +/// Built from the manifest rather than from a constant: the worker chose these +/// colours when it rendered the PNGs, so anything hardcoded here could drift +/// and mislabel the intensities the reader is looking at. +class _RadarLegend extends StatelessWidget { + const _RadarLegend({required this.legend}); + + final RadarLegend legend; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surface.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.radarLegendUnit(legend.unit), + style: theme.textTheme.labelSmall, + ), + const SizedBox(height: 6), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final stop in legend.stops) + Container(width: 14, height: 12, color: stop.color), + ], + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${legend.stops.first.value.toInt()}', + style: theme.textTheme.labelSmall, + ), + SizedBox(width: 14.0 * legend.stops.length - 28), + Text( + '${legend.stops.last.value.toInt()}', + style: theme.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/Nuvolari/app/lib/features/map/radar_overlay.dart b/Nuvolari/app/lib/features/map/radar_overlay.dart new file mode 100644 index 0000000..391fb62 --- /dev/null +++ b/Nuvolari/app/lib/features/map/radar_overlay.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../../core/region/geo.dart'; + +/// Draws radar frames over the map, double buffered. +/// +/// Two image sources alternate: while one is on screen the next frame is +/// uploaded into the other, then visibility swaps. Updating a single source in +/// place flickers, because the layer is briefly showing a half-written texture; +/// adding every frame as its own layer avoids that too but pins all of them in +/// GPU memory at once, and twenty 512×512 RGBA frames is about 80 MB. Two +/// buffers cost the same whether the timeline holds six frames or sixty. +class RadarOverlay { + RadarOverlay({required this.map, required GeoBounds bounds}) + : _quad = LatLngQuad( + topLeft: LatLng(bounds.north, bounds.west), + topRight: LatLng(bounds.north, bounds.east), + bottomRight: LatLng(bounds.south, bounds.east), + bottomLeft: LatLng(bounds.south, bounds.west), + ); + + static const String _sourceA = 'nuvolari-radar-a'; + static const String _sourceB = 'nuvolari-radar-b'; + static const String _layerA = 'nuvolari-radar-layer-a'; + static const String _layerB = 'nuvolari-radar-layer-b'; + + final MapLibreMapController map; + + /// Corners of the published frames. The worker renders in EPSG:3857 cropped + /// to exactly this box, so the quad is exact and nothing is warped client + /// side. + final LatLngQuad _quad; + + bool _attached = false; + bool _showingA = true; + String? _visiblePath; + + /// Serialises platform-channel work. `show` can be called faster than the + /// round trip completes — during playback, or while scrubbing — and + /// overlapping updates would swap visibility out of order and strobe. + Future _pending = Future.value(); + + bool get isAttached => _attached; + + /// The frame currently on screen, or null before the first one. + String? get visiblePath => _visiblePath; + + /// Creates the sources and layers, showing [bytes] immediately. + /// + /// Safe to call again after the style reloads: the previous layers are torn + /// down first. + Future attach(String path, Uint8List bytes) => _serialise(() async { + if (_attached) await _detachUnsafe(); + + await map.addImageSource(_sourceA, bytes, _quad); + await map.addImageSource(_sourceB, bytes, _quad); + await map.addImageLayer(_layerA, _sourceA); + await map.addImageLayer(_layerB, _sourceB); + await map.setLayerVisibility(_layerB, false); + + _attached = true; + _showingA = true; + _visiblePath = path; + }); + + /// Swaps [bytes] onto the map. + /// + /// A no-op when [path] is already visible, so a rebuild that does not change + /// the playhead costs nothing. + Future show(String path, Uint8List bytes) { + if (!_attached) return attach(path, bytes); + if (path == _visiblePath) return Future.value(); + + return _serialise(() async { + final hiddenSource = _showingA ? _sourceB : _sourceA; + final hiddenLayer = _showingA ? _layerB : _layerA; + final visibleLayer = _showingA ? _layerA : _layerB; + + // Upload first, reveal second: the new texture is complete before it is + // ever shown, which is what removes the flicker. + await map.updateImageSource(hiddenSource, bytes, null); + await map.setLayerVisibility(hiddenLayer, true); + await map.setLayerVisibility(visibleLayer, false); + + _showingA = !_showingA; + _visiblePath = path; + }); + } + + /// Removes the layers and sources. + Future detach() => _serialise(_detachUnsafe); + + Future _detachUnsafe() async { + // Layers reference sources, so they have to go first. Each removal is + // tolerated failing: the style may already have dropped them, and the goal + // here is to end up with nothing rather than to prove what was there. + for (final layer in [_layerA, _layerB]) { + try { + await map.removeLayer(layer); + } on Object { + continue; + } + } + for (final source in [_sourceA, _sourceB]) { + try { + await map.removeSource(source); + } on Object { + continue; + } + } + _attached = false; + _visiblePath = null; + } + + Future _serialise(Future Function() action) { + final next = _pending.then((_) => action()); + // Swallow the failure for the chain's sake only: a platform-channel error + // on one frame must not deadlock every later frame behind it. + _pending = next.catchError((Object _) {}); + return next; + } +} diff --git a/Nuvolari/app/lib/features/timeline/data_age_banner.dart b/Nuvolari/app/lib/features/timeline/data_age_banner.dart new file mode 100644 index 0000000..fa61fa0 --- /dev/null +++ b/Nuvolari/app/lib/features/timeline/data_age_banner.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../l10n/app_localizations.dart'; +import 'radar_timeline.dart'; + +/// Renders [age] at a scale a reader can take in at a glance. +/// +/// Minutes alone stop being informative quickly: data three days old reads as +/// "Aggiornato 4320 minuti fa", which is a number nobody converts in their +/// head. The unit steps up so the magnitude is obvious even when the exact +/// figure is not. +String formatDataAge(AppLocalizations l10n, Duration age) { + final clamped = age.isNegative ? Duration.zero : age; + if (clamped.inHours < 1) return l10n.dataAgeMinutes(clamped.inMinutes); + if (clamped.inDays < 1) return l10n.dataAgeHours(clamped.inHours); + return l10n.dataAgeDays(clamped.inDays); +} + +/// States the age of the radar data, and says so loudly when it is stale. +/// +/// Radar shown without its age is radar the reader will assume is current. That +/// is the failure mode that matters for a weather app: someone deciding whether +/// to leave the house based on a picture of the sky from an hour ago. So the +/// age is always on screen, not only when something has gone wrong. +class DataAgeBanner extends ConsumerWidget { + const DataAgeBanner({super.key, this.now}); + + /// Injectable clock for tests. + final DateTime? now; + + /// Beyond this the data stops being "the current picture". + /// + /// Frames arrive every five minutes, so a gap this size means several + /// publishes were missed, not that one is slightly late. + static const Duration staleAfter = Duration(minutes: 20); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final state = ref.watch(radarTimelineProvider); + final manifest = state.manifest; + + if (manifest == null || manifest.isEmpty) { + // Nothing to date. Whether that is a failure or a first load is the + // timeline bar's business, not this widget's. + return const SizedBox.shrink(); + } + + final age = manifest.ageAt(now ?? DateTime.now()); + final ageText = formatDataAge(l10n, age); + final isStale = age >= staleAfter || state.error != null; + + if (!isStale) { + // Full width and left aligned, matching the stale variant below, so the + // text does not jump across the screen when the state flips. + return SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text( + ageText, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ), + ); + } + + return Container( + width: double.infinity, + color: theme.colorScheme.errorContainer, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 18, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + // The age goes inside the warning rather than replacing it: "not + // updated" alone leaves the reader guessing how far off it is. + state.error != null + ? '${l10n.dataUnavailable} · $ageText' + : l10n.radarStaleWithAge(ageText), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } +} diff --git a/Nuvolari/app/lib/features/timeline/radar_timeline.dart b/Nuvolari/app/lib/features/timeline/radar_timeline.dart new file mode 100644 index 0000000..619d862 --- /dev/null +++ b/Nuvolari/app/lib/features/timeline/radar_timeline.dart @@ -0,0 +1,318 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/cache/frame_cache.dart'; +import '../../core/config/env.dart'; +import '../../core/region/region_config.dart'; +import '../../core/region/region_repository.dart'; +import '../../data/radar/arpa_radar_source.dart'; +import '../../data/radar/dpc_radar_source.dart'; +import '../../data/radar/mock_radar_source.dart'; +import '../../data/radar/radar_manifest.dart'; +import '../../data/radar/radar_source.dart'; + +/// What the radar timeline is showing right now. +class RadarTimelineState { + const RadarTimelineState({ + this.manifest, + this.index = 0, + this.isPlaying = false, + this.isLoading = true, + this.error, + }); + + /// The last manifest that loaded successfully. + /// + /// Kept across a failed refresh on purpose: showing yesterday's frames with + /// their age stated beats showing an empty map. + final RadarManifest? manifest; + + /// Playhead position in [RadarManifest.frames]. + final int index; + + final bool isPlaying; + + /// A load is in flight. Not the same as having nothing to show. + final bool isLoading; + + /// Why the most recent load failed, or null if it succeeded. + /// + /// Non-null together with a non-null [manifest] is the normal degraded state: + /// stale data on screen, banner explaining it. + final RadarUnavailableException? error; + + bool get hasFrames => manifest?.frames.isNotEmpty ?? false; + + int get frameCount => manifest?.frames.length ?? 0; + + RadarFrame? get currentFrame { + final frames = manifest?.frames; + if (frames == null || frames.isEmpty) return null; + return frames[index.clamp(0, frames.length - 1)]; + } + + bool get isAtNewestFrame => frameCount == 0 || index >= frameCount - 1; + + RadarTimelineState copyWith({ + RadarManifest? manifest, + int? index, + bool? isPlaying, + bool? isLoading, + RadarUnavailableException? error, + bool clearError = false, + }) => RadarTimelineState( + manifest: manifest ?? this.manifest, + index: index ?? this.index, + isPlaying: isPlaying ?? this.isPlaying, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); +} + +/// Drives the radar timeline: loading, playback and prefetching. +class RadarTimeline extends Notifier { + /// How long each frame is held during playback. + static const Duration framePeriod = Duration(milliseconds: 450); + + /// Extra dwell on the newest frame, so a loop ends on the present instead of + /// snapping straight back to the oldest frame. + static const Duration lastFrameDwell = Duration(milliseconds: 1100); + + /// How many frames either side of the playhead to keep loaded. + /// + /// Wide enough that playback never waits on a fetch, narrow enough that a + /// long timeline does not sit entirely in memory. + static const int prefetchRadius = 4; + + Timer? _playbackTimer; + final FrameCache _cache = FrameCache(); + RadarSource? _source; + + /// Guards against a prefetch that finishes after the playhead has moved on, + /// or after the provider has been disposed. + int _generation = 0; + bool _disposed = false; + + @override + RadarTimelineState build() { + ref.onDispose(() { + _disposed = true; + _playbackTimer?.cancel(); + _cache.clear(); + }); + + // Deferred to a microtask rather than started here: refresh() writes to + // `state`, and while build() is still running the provider has no state to + // write to yet — Riverpod throws "tried to read the state of an + // uninitialized provider". The microtask runs as soon as build() returns. + Future.microtask(refresh).ignore(); + + return const RadarTimelineState(); + } + + /// Visible for tests: the bytes currently held for the timeline. + FrameCache get cache => _cache; + + /// Loads the newest manifest and positions the playhead on the newest frame. + Future refresh() async { + state = state.copyWith(isLoading: true); + + try { + final source = await _resolveSource(); + final manifest = await source.getLatestManifest(); + + if (_disposed) return; + + _generation++; + // Land on the newest frame: someone opening the app wants to know what + // the sky is doing now, not two hours ago. + final newestIndex = manifest.frames.isEmpty + ? 0 + : manifest.frames.length - 1; + + state = RadarTimelineState( + manifest: manifest, + index: newestIndex, + isPlaying: state.isPlaying, + isLoading: false, + ); + + await _prefetchAround(newestIndex); + } on RadarUnavailableException catch (error) { + if (_disposed) return; + // Keep whatever manifest we already had. The banner reports the age. + state = state.copyWith(isLoading: false, error: error); + } + } + + void play() { + if (!state.hasFrames || state.isPlaying) return; + state = state.copyWith(isPlaying: true); + _scheduleNextFrame(); + } + + void pause() { + _playbackTimer?.cancel(); + _playbackTimer = null; + if (state.isPlaying) { + state = state.copyWith(isPlaying: false); + } + } + + void togglePlayPause() => state.isPlaying ? pause() : play(); + + /// Moves the playhead, pausing playback: a deliberate scrub should not fight + /// the timer for control of the frame. + void seek(int index) { + if (!state.hasFrames) return; + pause(); + _setIndex(index); + } + + void step(int delta) { + if (!state.hasFrames) return; + pause(); + _setIndex(state.index + delta); + } + + /// Called when the app leaves the foreground. + /// + /// Playback keeps a timer running and decodes an image several times a + /// second; continuing that behind another app would drain the battery for + /// nothing, since there is no one watching. + void onAppBackgrounded() => pause(); + + void _setIndex(int index) { + final count = state.frameCount; + if (count == 0) return; + final clamped = index.clamp(0, count - 1); + if (clamped == state.index) return; + + _generation++; + state = state.copyWith(index: clamped); + unawaited(_prefetchAround(clamped)); + } + + void _scheduleNextFrame() { + _playbackTimer?.cancel(); + if (!state.isPlaying || !state.hasFrames) return; + + final delay = state.isAtNewestFrame ? lastFrameDwell : framePeriod; + _playbackTimer = Timer(delay, () { + if (_disposed || !state.isPlaying) return; + + final count = state.frameCount; + final next = state.index >= count - 1 ? 0 : state.index + 1; + _generation++; + state = state.copyWith(index: next); + unawaited(_prefetchAround(next)); + _scheduleNextFrame(); + }); + } + + /// Returns the bytes for [frame], from cache when possible. + Future bytesFor(RadarFrame frame) async { + final source = await _resolveSource(); + return _cache.load(frame.path, () => source.loadFrameBytes(frame)); + } + + /// Loads the window around [index], nearest first. + /// + /// Order matters: during playback the next frame is needed within + /// milliseconds, while the far edge of the window can wait. + Future _prefetchAround(int index) async { + final manifest = state.manifest; + if (manifest == null || manifest.frames.isEmpty) return; + + final generation = _generation; + final frames = manifest.frames; + + final window = [index]; + for (var offset = 1; offset <= prefetchRadius; offset++) { + // Ahead first: playback moves forward, so that is the frame that will be + // needed soonest. + if (index + offset < frames.length) window.add(index + offset); + if (index - offset >= 0) window.add(index - offset); + } + + _cache.retainOnly(window.map((i) => frames[i].path).toSet()); + + for (final position in window) { + if (_disposed || generation != _generation) return; + final frame = frames[position]; + if (_cache.contains(frame.path)) continue; + try { + await bytesFor(frame); + } on RadarUnavailableException { + // One missing frame is not a timeline failure: the worker may be + // mid-publish. The overlay holds the previous image instead. + continue; + } + } + } + + Future _resolveSource() async { + final existing = _source; + if (existing != null) return existing; + + final resolved = await ref.read(radarSourceProvider.future); + _source = resolved; + return resolved; + } +} + +/// Chooses the radar adapter for [region]. +/// +/// A runtime override wins over the region default so a build can be pointed at +/// live data without editing assets, but neither can select an adapter the +/// region does not list — that would be a configuration typo silently changing +/// where the data comes from. +RadarSource createRadarSource(RegionConfig region) { + var adapter = region.radar.defaultAdapter; + + if (Env.radarSource.isNotEmpty) { + final requested = RadarAdapter.parse(Env.radarSource); + if (!region.radar.availableAdapters.contains(requested)) { + throw StateError( + 'RADAR_SOURCE=${Env.radarSource} is not among the adapters ' + '${region.id} declares: ' + '${region.radar.availableAdapters.map((a) => a.name).join(', ')}', + ); + } + adapter = requested; + } + + return switch (adapter) { + RadarAdapter.mock => MockRadarSource(), + RadarAdapter.dpc => _createDpcSource(), + RadarAdapter.arpa => const ArpaRadarSource(), + }; +} + +RadarSource _createDpcSource() { + if (Env.radarManifestUrl.isEmpty) { + // Falling back to mock here would put demo frames on screen under the + // label of live data, which is exactly the confusion the adapter split + // exists to prevent. Failing names the missing setting instead. + throw StateError( + 'the dpc radar adapter needs RADAR_MANIFEST_URL; set it in env.json or ' + 'select RADAR_SOURCE=mock', + ); + } + return DpcRadarSource(manifestUrl: Env.radarManifestUrl); +} + +/// The active radar adapter. +/// +/// Separate from the timeline so tests can substitute a source without +/// standing up assets or a network, and so the choice stays a single decision +/// made in one place. +final radarSourceProvider = FutureProvider((ref) async { + final region = await ref.watch(regionConfigProvider.future); + return createRadarSource(region); +}); + +final radarTimelineProvider = + NotifierProvider(RadarTimeline.new); diff --git a/Nuvolari/app/lib/features/timeline/timeline_bar.dart b/Nuvolari/app/lib/features/timeline/timeline_bar.dart new file mode 100644 index 0000000..56fdf09 --- /dev/null +++ b/Nuvolari/app/lib/features/timeline/timeline_bar.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../l10n/app_localizations.dart'; +import 'radar_timeline.dart'; + +/// Play/pause control and scrubber for the radar loop. +class TimelineBar extends ConsumerWidget { + const TimelineBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final state = ref.watch(radarTimelineProvider); + final timeline = ref.read(radarTimelineProvider.notifier); + + if (!state.hasFrames) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Text( + state.isLoading ? l10n.loading : l10n.radarNoData, + style: theme.textTheme.bodySmall, + ), + ); + } + + final frame = state.currentFrame!; + // Frame timestamps are UTC; the reader thinks in local time. + final label = DateFormat.Hm().format(frame.timestamp.toLocal()); + final lastIndex = state.frameCount - 1; + + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 12, 0), + child: Row( + children: [ + IconButton( + onPressed: timeline.togglePlayPause, + tooltip: state.isPlaying ? l10n.timelinePause : l10n.timelinePlay, + icon: Icon( + state.isPlaying + ? Icons.pause_circle_filled + : Icons.play_circle_fill, + size: 34, + ), + color: theme.colorScheme.primary, + ), + Expanded( + child: Semantics( + label: l10n.timelineScrubber, + value: label, + child: Slider( + value: state.index.toDouble().clamp(0, lastIndex.toDouble()), + max: lastIndex.toDouble(), + // One division per frame, so dragging lands on a real frame + // rather than interpolating to something that does not exist. + divisions: lastIndex > 0 ? lastIndex : null, + onChanged: (value) => timeline.seek(value.round()), + ), + ), + ), + // Tabular figures so the label does not jitter as the digits change + // during playback. + Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} diff --git a/Nuvolari/app/lib/l10n/app_it.arb b/Nuvolari/app/lib/l10n/app_it.arb index 2748030..dbdb51e 100644 --- a/Nuvolari/app/lib/l10n/app_it.arb +++ b/Nuvolari/app/lib/l10n/app_it.arb @@ -99,5 +99,61 @@ "openLink": "Apri", "@openLink": { "description": "Accessibility label for a button that opens a link in the browser" + }, + "timelinePlay": "Riproduci", + "@timelinePlay": { + "description": "Accessibility label and tooltip for the play button on the radar timeline" + }, + "timelinePause": "Pausa", + "@timelinePause": { + "description": "Accessibility label and tooltip for the pause button on the radar timeline" + }, + "timelineScrubber": "Scorri i fotogrammi radar", + "@timelineScrubber": { + "description": "Accessibility label for the timeline slider" + }, + "radarNoData": "Nessun dato radar disponibile", + "@radarNoData": { + "description": "Shown when no radar manifest could be loaded and there is no cached one either" + }, + "radarStaleWithAge": "Dati non aggiornati · {age}", + "@radarStaleWithAge": { + "description": "Banner shown when the newest radar frame is older than expected. The age is the already-formatted string from dataAgeMinutes.", + "placeholders": { + "age": { + "type": "String", + "example": "Aggiornato 25 minuti fa" + } + } + }, + "radarLegendUnit": "Intensità ({unit})", + "@radarLegendUnit": { + "description": "Header of the radar colour legend, with the physical unit taken from the manifest", + "placeholders": { + "unit": { + "type": "String", + "example": "dBZ" + } + } + }, + "dataAgeHours": "{hours, plural, one{Aggiornato 1 ora fa} other{Aggiornato {hours} ore fa}}", + "@dataAgeHours": { + "description": "Data age when it is measured in hours rather than minutes", + "placeholders": { + "hours": { + "type": "int", + "example": "3" + } + } + }, + "dataAgeDays": "{days, plural, one{Aggiornato 1 giorno fa} other{Aggiornato {days} giorni fa}}", + "@dataAgeDays": { + "description": "Data age when it is measured in days. Radar this old is useless, but stating it plainly beats an unreadable minute count.", + "placeholders": { + "days": { + "type": "int", + "example": "4" + } + } } } diff --git a/Nuvolari/app/pubspec.lock b/Nuvolari/app/pubspec.lock index a21c167..710fce1 100644 --- a/Nuvolari/app/pubspec.lock +++ b/Nuvolari/app/pubspec.lock @@ -57,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dio: + dependency: "direct main" + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" fake_async: dependency: transitive description: @@ -117,6 +133,14 @@ packages: description: flutter source: sdk version: "0.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: @@ -221,6 +245,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: diff --git a/Nuvolari/app/pubspec.yaml b/Nuvolari/app/pubspec.yaml index a6824cc..873167e 100644 --- a/Nuvolari/app/pubspec.yaml +++ b/Nuvolari/app/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ^3.13.3 dependencies: + dio: ^5.11.1 flutter: sdk: flutter flutter_localizations: diff --git a/Nuvolari/app/test/core/cache/frame_cache_test.dart b/Nuvolari/app/test/core/cache/frame_cache_test.dart new file mode 100644 index 0000000..8d53072 --- /dev/null +++ b/Nuvolari/app/test/core/cache/frame_cache_test.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/core/cache/frame_cache.dart'; + +Uint8List bytesOf(int length, [int fill = 0]) => + Uint8List.fromList(List.filled(length, fill)); + +void main() { + group('FrameCache', () { + test('stores and returns bytes', () { + final cache = FrameCache(maxBytes: 1000); + final frame = bytesOf(10); + + cache.put('a', frame); + + expect(cache.get('a'), same(frame)); + expect(cache.contains('a'), isTrue); + expect(cache.currentBytes, 10); + expect(cache.length, 1); + }); + + test('returns null for a key it does not hold', () { + expect(FrameCache(maxBytes: 1000).get('missing'), isNull); + }); + + test('replacing a key does not double-count its bytes', () { + final cache = FrameCache(maxBytes: 1000); + + cache.put('a', bytesOf(100)); + cache.put('a', bytesOf(40)); + + expect(cache.currentBytes, 40); + expect(cache.length, 1); + }); + + test('evicts the least recently used entry when over budget', () { + final cache = FrameCache(maxBytes: 250); + + cache.put('a', bytesOf(100)); + cache.put('b', bytesOf(100)); + cache.put('c', bytesOf(100)); + + expect(cache.contains('a'), isFalse); + expect(cache.contains('b'), isTrue); + expect(cache.contains('c'), isTrue); + expect(cache.currentBytes, 200); + }); + + // Reading is what marks a frame as still wanted, so a frame the playhead + // keeps returning to must survive newer arrivals. + test('a read promotes an entry ahead of the eviction queue', () { + final cache = FrameCache(maxBytes: 250); + + cache.put('a', bytesOf(100)); + cache.put('b', bytesOf(100)); + cache.get('a'); + cache.put('c', bytesOf(100)); + + expect(cache.contains('a'), isTrue); + expect(cache.contains('b'), isFalse); + }); + + // Refusing an outsized frame would mean the timeline could never display + // that moment at all, which is worse than briefly exceeding the target. + test('keeps a frame larger than the whole budget', () { + final cache = FrameCache(maxBytes: 100); + + cache.put('a', bytesOf(50)); + cache.put('huge', bytesOf(400)); + + expect(cache.contains('huge'), isTrue); + expect(cache.length, 1); + expect(cache.currentBytes, 400); + }); + + test('clear empties it', () { + final cache = FrameCache(maxBytes: 1000); + cache.put('a', bytesOf(10)); + + cache.clear(); + + expect(cache.length, 0); + expect(cache.currentBytes, 0); + }); + }); + + group('FrameCache.retainOnly', () { + test('drops everything outside the set', () { + final cache = FrameCache(maxBytes: 1000); + cache.put('a', bytesOf(10)); + cache.put('b', bytesOf(20)); + cache.put('c', bytesOf(30)); + + cache.retainOnly({'b'}); + + expect(cache.keys, orderedEquals(['b'])); + expect(cache.currentBytes, 20); + }); + + test('keeping everything changes nothing', () { + final cache = FrameCache(maxBytes: 1000); + cache.put('a', bytesOf(10)); + cache.put('b', bytesOf(20)); + + cache.retainOnly({'a', 'b', 'not-present'}); + + expect(cache.length, 2); + expect(cache.currentBytes, 30); + }); + + test('an empty set empties the cache', () { + final cache = FrameCache(maxBytes: 1000); + cache.put('a', bytesOf(10)); + + cache.retainOnly({}); + + expect(cache.length, 0); + expect(cache.currentBytes, 0); + }); + }); + + group('FrameCache.load', () { + test('fetches on a miss and caches the result', () async { + final cache = FrameCache(maxBytes: 1000); + var calls = 0; + + final first = await cache.load('a', () async { + calls++; + return bytesOf(10, 1); + }); + final second = await cache.load('a', () async { + calls++; + return bytesOf(10, 2); + }); + + expect(calls, 1); + expect(first, same(second)); + }); + + // Scrubbing and playback can both ask for the same frame within the same + // millisecond. Two fetches would double the network cost for nothing. + test('concurrent loads of one key share a single fetch', () async { + final cache = FrameCache(maxBytes: 1000); + final gate = Completer(); + var calls = 0; + + Future fetch() { + calls++; + return gate.future; + } + + final a = cache.load('a', fetch); + final b = cache.load('a', fetch); + gate.complete(bytesOf(10)); + + expect(await a, same(await b)); + expect(calls, 1); + }); + + // A network blip must not make a frame permanently unavailable for the + // rest of the session. + test('a failed load is not cached and can be retried', () async { + final cache = FrameCache(maxBytes: 1000); + var attempt = 0; + + await expectLater( + cache.load('a', () async { + attempt++; + throw StateError('boom'); + }), + throwsStateError, + ); + expect(cache.contains('a'), isFalse); + + final bytes = await cache.load('a', () async { + attempt++; + return bytesOf(10); + }); + + expect(attempt, 2); + expect(bytes, hasLength(10)); + expect(cache.contains('a'), isTrue); + }); + }); +} diff --git a/Nuvolari/app/test/data/radar/mock_radar_source_test.dart b/Nuvolari/app/test/data/radar/mock_radar_source_test.dart index 47974e7..0ae42fc 100644 --- a/Nuvolari/app/test/data/radar/mock_radar_source_test.dart +++ b/Nuvolari/app/test/data/radar/mock_radar_source_test.dart @@ -107,6 +107,51 @@ void main() { expect(resolved, 'assets/mock/frames/1768478400000.png'); }); + // The generator writes a fixed epoch so regenerating the assets is + // reproducible. Without rebasing, demo mode would permanently sit behind a + // "data is stale" warning and never show the working state it exists to + // demonstrate. + test('rebases the bundled timestamps onto the present', () async { + final at = DateTime.utc(2026, 3, 4, 10, 7, 33); + final fixed = MockRadarSource( + bundle: _DiskAssetBundle(), + clock: () => at, + ); + + final manifest = await fixed.getLatestManifest(); + final newest = manifest.frames.last.timestamp; + + expect(manifest.ageAt(at).inMinutes, lessThan(5)); + // Landed on a whole five-minute step, the way a real publish would. + expect(newest.minute % 5, 0); + expect(newest.second, 0); + }); + + test('rebasing preserves order, spacing and paths', () async { + final original = RadarManifest.parse( + File('assets/mock/manifest.json').readAsStringSync(), + ); + final rebased = await MockRadarSource( + bundle: _DiskAssetBundle(), + clock: () => DateTime.utc(2026, 3, 4, 10, 7, 33), + ).getLatestManifest(); + + expect(rebased.frames, hasLength(original.frames.length)); + for (var i = 0; i < original.frames.length; i++) { + expect(rebased.frames[i].path, original.frames[i].path); + } + for (var i = 1; i < rebased.frames.length; i++) { + expect( + rebased.frames[i].timestamp.difference( + rebased.frames[i - 1].timestamp, + ), + original.frames[i].timestamp.difference( + original.frames[i - 1].timestamp, + ), + ); + } + }); + test('parses the manifest once and reuses it', () async { final first = await source.getLatestManifest(); final second = await source.getLatestManifest(); diff --git a/Nuvolari/app/test/features/timeline/data_age_test.dart b/Nuvolari/app/test/features/timeline/data_age_test.dart new file mode 100644 index 0000000..44133cc --- /dev/null +++ b/Nuvolari/app/test/features/timeline/data_age_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nuvolari/features/timeline/data_age_banner.dart'; +import 'package:nuvolari/l10n/app_localizations.dart'; + +void main() { + late AppLocalizations l10n; + + setUpAll(() async { + l10n = await AppLocalizations.delegate.load(const Locale('it')); + }); + + group('formatDataAge', () { + test('uses minutes below an hour', () { + expect(formatDataAge(l10n, Duration.zero), 'Aggiornato ora'); + expect( + formatDataAge(l10n, const Duration(minutes: 1)), + 'Aggiornato 1 minuto fa', + ); + expect( + formatDataAge(l10n, const Duration(minutes: 42)), + 'Aggiornato 42 minuti fa', + ); + expect( + formatDataAge(l10n, const Duration(minutes: 59, seconds: 59)), + 'Aggiornato 59 minuti fa', + ); + }); + + test('switches to hours at the hour mark', () { + expect( + formatDataAge(l10n, const Duration(hours: 1)), + 'Aggiornato 1 ora fa', + ); + expect( + formatDataAge(l10n, const Duration(hours: 5, minutes: 30)), + 'Aggiornato 5 ore fa', + ); + expect( + formatDataAge(l10n, const Duration(hours: 23, minutes: 59)), + 'Aggiornato 23 ore fa', + ); + }); + + // The bug this replaced rendered eight-month-old demo data as "Aggiornato + // 342535 minuti fa", a number nobody converts in their head. + test('switches to days beyond a day', () { + expect( + formatDataAge(l10n, const Duration(days: 1)), + 'Aggiornato 1 giorno fa', + ); + expect( + formatDataAge(l10n, const Duration(days: 4)), + 'Aggiornato 4 giorni fa', + ); + expect( + formatDataAge(l10n, const Duration(days: 238)), + 'Aggiornato 238 giorni fa', + ); + }); + + // Clock skew, or a worker publishing a frame stamped slightly ahead, must + // not produce "Aggiornato -3 minuti fa". + test('treats a negative age as now', () { + expect( + formatDataAge(l10n, const Duration(minutes: -5)), + 'Aggiornato ora', + ); + }); + }); +} diff --git a/Nuvolari/app/test/features/timeline/radar_timeline_test.dart b/Nuvolari/app/test/features/timeline/radar_timeline_test.dart new file mode 100644 index 0000000..3f5c983 --- /dev/null +++ b/Nuvolari/app/test/features/timeline/radar_timeline_test.dart @@ -0,0 +1,350 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +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/data/radar/radar_manifest.dart'; +import 'package:nuvolari/data/radar/radar_source.dart'; +import 'package:nuvolari/features/timeline/radar_timeline.dart'; + +/// A radar source with no assets and no network, so timeline behaviour can be +/// driven precisely: which frames exist, which of them fail, and how slowly +/// each one arrives. +class FakeRadarSource implements RadarSource { + FakeRadarSource({ + this.frameCount = 12, + this.failingPaths = const {}, + this.manifestFails = false, + this.frameDelay = Duration.zero, + }); + + final int frameCount; + final Set failingPaths; + final bool manifestFails; + final Duration frameDelay; + + final List requestedPaths = []; + int manifestCalls = 0; + + static const int baseTimestamp = 1768478400000; + + RadarManifest _build() { + final frames = >[ + for (var i = 0; i < frameCount; i++) + { + 'ts': baseTimestamp + i * 5 * 60 * 1000, + 'url': 'frames/$i.png', + }, + ]; + return RadarManifest.parse( + jsonEncode({ + 'region': 'piemonte', + 'product': 'VMI', + 'generatedAt': baseTimestamp + (frameCount - 1) * 5 * 60 * 1000, + 'bbox': [6.55, 43.95, 9.30, 46.55], + 'crs': 'EPSG:3857', + 'frames': frames, + 'legend': { + 'unit': 'dBZ', + 'stops': [ + {'value': 5, 'color': '#4FA3D1'}, + ], + }, + 'attribution': 'Test data', + }), + ); + } + + @override + Future getLatestManifest() async { + manifestCalls++; + if (manifestFails) { + throw const RadarUnavailableException('fake manifest failure'); + } + return _build(); + } + + @override + Future> getFrames() async => + (await getLatestManifest()).frames; + + @override + Future loadFrameBytes(RadarFrame frame) async { + requestedPaths.add(frame.path); + if (frameDelay > Duration.zero) { + await Future.delayed(frameDelay); + } + if (failingPaths.contains(frame.path)) { + throw RadarUnavailableException('fake failure for ${frame.path}'); + } + return Uint8List.fromList(List.filled(64, 1)); + } +} + +RegionConfig loadPiemonte() => + RegionConfig.parse(File('assets/regions/piemonte.json').readAsStringSync()); + +ProviderContainer containerWith(RadarSource source, RegionConfig region) { + final container = ProviderContainer( + overrides: [ + regionConfigProvider.overrideWith((ref) async => region), + radarSourceProvider.overrideWith((ref) async => source), + ], + ); + addTearDown(container.dispose); + return container; +} + +/// Waits until the timeline has finished its initial load. +Future settled(ProviderContainer container) async { + for (var i = 0; i < 200; i++) { + final state = container.read(radarTimelineProvider); + if (!state.isLoading) return state; + await Future.delayed(const Duration(milliseconds: 5)); + } + fail('timeline never finished loading'); +} + +void main() { + late RegionConfig region; + + setUpAll(() { + region = loadPiemonte(); + }); + + group('initial load', () { + test('loads the manifest and lands on the newest frame', () async { + final source = FakeRadarSource(frameCount: 12); + final container = containerWith(source, region); + + final state = await settled(container); + + expect(state.manifest, isNotNull); + expect(state.frameCount, 12); + // Someone opening the app wants to know what the sky is doing now. + expect(state.index, 11); + expect(state.isAtNewestFrame, isTrue); + expect(state.error, isNull); + }); + + test('prefetches the frames around the playhead', () async { + final source = FakeRadarSource(frameCount: 12); + final container = containerWith(source, region); + + await settled(container); + await Future.delayed(const Duration(milliseconds: 50)); + + // Playhead at 11 with a radius of 4: frames 7..11, and nothing beyond. + expect(source.requestedPaths, contains('frames/11.png')); + expect(source.requestedPaths, contains('frames/7.png')); + expect(source.requestedPaths, isNot(contains('frames/6.png'))); + }); + + test('an empty manifest is not an error', () async { + final source = FakeRadarSource(frameCount: 0); + final container = containerWith(source, region); + + final state = await settled(container); + + expect(state.hasFrames, isFalse); + expect(state.error, isNull); + expect(state.currentFrame, isNull); + }); + }); + + group('degradation', () { + test('a failed manifest load surfaces as an error', () async { + final source = FakeRadarSource(manifestFails: true); + final container = containerWith(source, region); + + final state = await settled(container); + + expect(state.error, isA()); + expect(state.manifest, isNull); + }); + + // The whole point of holding the last good manifest: stale frames with + // their age stated beat an empty map. + test('a later failure keeps the manifest already on screen', () async { + final source = FakeRadarSource(frameCount: 6); + final container = containerWith(source, region); + await settled(container); + + final failing = FakeRadarSource(manifestFails: true); + container.updateOverrides([ + regionConfigProvider.overrideWith((ref) async => region), + radarSourceProvider.overrideWith((ref) async => failing), + ]); + // The notifier memoises its source, so drive the failure through the + // instance it already holds by refreshing after the override. + final timeline = container.read(radarTimelineProvider.notifier); + await timeline.refresh(); + + final state = container.read(radarTimelineProvider); + expect(state.manifest, isNotNull, reason: 'stale data must be kept'); + expect(state.frameCount, 6); + }); + + // A gap mid-timeline is normal while the worker publishes. It must not + // abort the surrounding prefetch. + test('one missing frame does not stop the others loading', () async { + final source = FakeRadarSource( + frameCount: 8, + failingPaths: const {'frames/5.png'}, + ); + final container = containerWith(source, region); + + await settled(container); + await Future.delayed(const Duration(milliseconds: 50)); + + final timeline = container.read(radarTimelineProvider.notifier); + expect(timeline.cache.contains('frames/5.png'), isFalse); + expect(timeline.cache.contains('frames/7.png'), isTrue); + expect(timeline.cache.contains('frames/4.png'), isTrue); + }); + }); + + group('playback', () { + test('play advances the playhead and wraps around', () async { + final source = FakeRadarSource(frameCount: 3); + final container = containerWith(source, region); + await settled(container); + + final timeline = container.read(radarTimelineProvider.notifier); + expect(container.read(radarTimelineProvider).index, 2); + + timeline.play(); + expect(container.read(radarTimelineProvider).isPlaying, isTrue); + + // Index 2 is the newest frame, so the longer dwell applies first. + await Future.delayed( + RadarTimeline.lastFrameDwell + const Duration(milliseconds: 150), + ); + expect(container.read(radarTimelineProvider).index, 0); + + await Future.delayed( + RadarTimeline.framePeriod + const Duration(milliseconds: 150), + ); + expect(container.read(radarTimelineProvider).index, 1); + + timeline.pause(); + }); + + test('pause stops the playhead', () async { + final source = FakeRadarSource(frameCount: 6); + final container = containerWith(source, region); + await settled(container); + + final timeline = container.read(radarTimelineProvider.notifier); + timeline.play(); + timeline.pause(); + final frozen = container.read(radarTimelineProvider).index; + + await Future.delayed( + RadarTimeline.lastFrameDwell + const Duration(milliseconds: 200), + ); + + expect(container.read(radarTimelineProvider).isPlaying, isFalse); + expect(container.read(radarTimelineProvider).index, frozen); + }); + + test('play does nothing when there are no frames', () async { + final source = FakeRadarSource(frameCount: 0); + final container = containerWith(source, region); + await settled(container); + + container.read(radarTimelineProvider.notifier).play(); + + expect(container.read(radarTimelineProvider).isPlaying, isFalse); + }); + + // Leaving a timer running and pushing textures behind another app is pure + // battery drain with nobody watching. + test('backgrounding the app pauses playback', () async { + final source = FakeRadarSource(frameCount: 6); + final container = containerWith(source, region); + await settled(container); + + final timeline = container.read(radarTimelineProvider.notifier); + timeline.play(); + expect(container.read(radarTimelineProvider).isPlaying, isTrue); + + timeline.onAppBackgrounded(); + + expect(container.read(radarTimelineProvider).isPlaying, isFalse); + }); + }); + + group('scrubbing', () { + test('seek moves the playhead and stops playback', () async { + final source = FakeRadarSource(frameCount: 10); + final container = containerWith(source, region); + await settled(container); + + final timeline = container.read(radarTimelineProvider.notifier); + timeline.play(); + timeline.seek(3); + + final state = container.read(radarTimelineProvider); + expect(state.index, 3); + expect(state.isPlaying, isFalse, reason: 'a scrub takes over the frame'); + }); + + test('seek clamps to the available range', () async { + final source = FakeRadarSource(frameCount: 5); + final container = containerWith(source, region); + await settled(container); + + final timeline = container.read(radarTimelineProvider.notifier); + + timeline.seek(999); + expect(container.read(radarTimelineProvider).index, 4); + + timeline.seek(-999); + expect(container.read(radarTimelineProvider).index, 0); + }); + + test('step moves one frame at a time', () async { + final source = FakeRadarSource(frameCount: 10); + final container = containerWith(source, region); + await settled(container); + + final timeline = container.read(radarTimelineProvider.notifier); + timeline.seek(5); + timeline.step(1); + expect(container.read(radarTimelineProvider).index, 6); + + timeline.step(-2); + expect(container.read(radarTimelineProvider).index, 4); + }); + + // Scrubbing across a long timeline would otherwise accumulate every frame + // it passed over. + test('the cache holds only the window around the playhead', () async { + final source = FakeRadarSource(frameCount: 20); + final container = containerWith(source, region); + await settled(container); + await Future.delayed(const Duration(milliseconds: 60)); + + final timeline = container.read(radarTimelineProvider.notifier); + timeline.seek(2); + await Future.delayed(const Duration(milliseconds: 60)); + + // Radius 4 around index 2 means frames 0..6 at most. + expect(timeline.cache.length, lessThanOrEqualTo(7)); + expect(timeline.cache.contains('frames/19.png'), isFalse); + expect(timeline.cache.contains('frames/2.png'), isTrue); + }); + }); + + group('createRadarSource', () { + test('uses the region default when nothing overrides it', () { + // The test binary is built with no --dart-define, so Env.radarSource is + // empty and the region's own default applies. + expect(createRadarSource(region), isNotNull); + }); + }); +} diff --git a/Nuvolari/docs/roadmap.md b/Nuvolari/docs/roadmap.md index b934ff7..3b15b68 100644 --- a/Nuvolari/docs/roadmap.md +++ b/Nuvolari/docs/roadmap.md @@ -60,7 +60,7 @@ entirely inside the region so the map read as a blank expanse, and the attributi sat behind the system gesture pill. Both fixed — the camera now fits the region bounds at runtime, which works on any screen size, and the bar is inside a SafeArea. -## M3 — Animation and timeline 🔨 +## M3 — Animation and timeline ✅ `RadarSource` with `MockRadarSource` (synthetic frames in assets) and `DpcRadarSource`. Timeline scrubber, play/pause, adjacent-frame prefetch, `FrameCache` LRU, animation @@ -69,6 +69,23 @@ suspended in background, graceful degradation with a data-age banner. **Accepts when:** animation runs smoothly in demo mode **with the network off**, and prefetch, LRU eviction and the fallback paths are covered by tests. +**Done and verified on the emulator.** The overlay is double buffered, the timeline +lands on the newest frame, playback advances and wraps, scrubbing takes over from +playback, and the legend is drawn from the manifest rather than a constant. + +Running it caught three things the tests had not: + +- The notifier wrote to `state` from inside `build()`, which Riverpod rejects as an + uninitialised provider. This broke startup, not just tests. +- Eight-month-old demo frames rendered as "Aggiornato 342535 minuti fa". The age + formatter now steps up to hours and days. +- Demo mode sat permanently behind a stale-data warning, so it never showed the + working state it exists to demonstrate. `MockRadarSource` now shifts the bundled + timestamps onto the present, leaving images, order and spacing untouched. + +Frame caching is in memory only for now: mock frames are already in the asset bundle +and a disk layer belongs with the network adapter, where it would save a real request. + ## M4 — Forecast `MetNoForecastSource` with the mandatory identifying User-Agent, honouring `Expires` diff --git a/Nuvolari/docs/stack-decisions.md b/Nuvolari/docs/stack-decisions.md index 5a78b77..90fbb4c 100644 --- a/Nuvolari/docs/stack-decisions.md +++ b/Nuvolari/docs/stack-decisions.md @@ -70,14 +70,25 @@ 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 LRU +## Frame cache — custom in-memory LRU -`FrameCache` over `path_provider`: an on-disk LRU with a configurable byte cap, plus a -small in-memory LRU of PNG bytes. +`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. Prefetch must prioritise frames adjacent to the playhead and evict by -distance from it, not by age. +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