import 'dart:typed_data'; import 'package:flutter/services.dart' show AssetBundle, rootBundle; import 'radar_manifest.dart'; import 'radar_source.dart'; /// Radar frames bundled in the app, for demo mode and for tests. /// /// This is a real adapter, not test scaffolding. It is what lets the app run on /// a fresh clone with no network and no credentials, which in turn means the /// timeline, the prefetching, the cache eviction and the degraded states are /// all exercisable offline. /// /// The assets are generated by `tool/generate_mock_frames.py` and deliberately /// 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, 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; AssetBundle get _assets => bundle ?? rootBundle; /// Parsed once and reused: the assets cannot change while the app runs, and /// re-parsing on every timeline tick would be pure waste. RadarManifest? _cached; @override Future getLatestManifest() async { final cached = _cached; if (cached != null) return cached; final String source; try { source = await _assets.loadString(manifestAsset); } on Object catch (error) { throw RadarUnavailableException( 'mock manifest asset $manifestAsset could not be read', cause: error, ); } try { 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. throw RadarUnavailableException( 'mock manifest asset $manifestAsset is malformed', cause: error, ); } } @override Future> getFrames() async => (await getLatestManifest()).frames; @override Future loadFrameBytes(RadarFrame frame) async { final key = resolveAssetPath(frame); try { final data = await _assets.load(key); return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); } on Object catch (error) { throw RadarUnavailableException( 'mock frame asset $key could not be read', cause: error, ); } } /// 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('/'); if (separator < 0) return frame.path; return '${manifestAsset.substring(0, separator + 1)}${frame.path}'; } }