From c3346d50d01e93b46671b2877de5f2ec61ccdc51 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 10 Sep 2026 10:12:43 +0800 Subject: [PATCH 1/4] feat(map): overlay same-frame lightning strikes on the radar echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 雷達回波右上角多了閃電選項,落雷和畫面上那一幀回波是同一時間 New(en-US): the radar echo can now overlay lightning, showing the strikes from the same time as the frame on screen --- lib/core/settings/setting_keys.dart | 7 + .../presentation/layers/lightning_layer.dart | 401 +--------------- .../layers/lightning_strike_overlay.dart | 445 ++++++++++++++++++ .../map/presentation/layers/radar_layer.dart | 213 ++++++++- .../map/presentation/pages/map_page.dart | 5 + .../widgets/radar_overlay_menu.dart | 42 +- .../widgets/scan_range_overlay_menu.dart | 17 +- lib/l10n/app_en.arb | 16 + lib/l10n/app_fil.arb | 6 +- lib/l10n/app_id.arb | 6 +- lib/l10n/app_ja.arb | 6 +- lib/l10n/app_ko.arb | 6 +- lib/l10n/app_th.arb | 6 +- lib/l10n/app_vi.arb | 6 +- lib/l10n/app_yue.arb | 6 +- lib/l10n/app_zh.arb | 6 +- lib/l10n/app_zh_Hans.arb | 6 +- lib/l10n/app_zh_Hant_HK.arb | 6 +- lib/l10n/app_zh_TW.arb | 6 +- lib/l10n/gen/app_localizations.dart | 24 + lib/l10n/gen/app_localizations_en.dart | 13 + lib/l10n/gen/app_localizations_fil.dart | 13 + lib/l10n/gen/app_localizations_id.dart | 14 + lib/l10n/gen/app_localizations_ja.dart | 12 + lib/l10n/gen/app_localizations_ko.dart | 13 + lib/l10n/gen/app_localizations_th.dart | 13 + lib/l10n/gen/app_localizations_vi.dart | 13 + lib/l10n/gen/app_localizations_yue.dart | 12 + lib/l10n/gen/app_localizations_zh.dart | 48 ++ test/features/map/layer_stacking_test.dart | 13 +- test/features/map/radar_layer_test.dart | 214 +++++++-- .../features/map/radar_overlay_menu_test.dart | 53 ++- .../map/raster_source_maxzoom_test.dart | 3 +- .../features/map/raster_timeline_harness.dart | 43 ++ 34 files changed, 1239 insertions(+), 474 deletions(-) create mode 100644 lib/features/map/presentation/layers/lightning_strike_overlay.dart diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index aea772505..c8b980f16 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -139,6 +139,13 @@ abstract final class SettingKeys { 'map.showScanRange', ); + /// Whether the radar echo also draws the lightning strikes of the frame it is + /// showing (absent = false — it is extra data over the echo, not chrome, so + /// it is opt-in). See `RadarMapLayer`. + static const SettingKey mapRadarShowLightning = SettingKey._( + 'map.radarShowLightning', + ); + /// Saved Home township codes (ordered list). See `RegionStore`. static const SettingKey> savedRegionCodes = SettingKey>._('home.savedRegionCodes'); diff --git a/lib/features/map/presentation/layers/lightning_layer.dart b/lib/features/map/presentation/layers/lightning_layer.dart index 3c9606e61..f358df704 100644 --- a/lib/features/map/presentation/layers/lightning_layer.dart +++ b/lib/features/map/presentation/layers/lightning_layer.dart @@ -1,62 +1,24 @@ /// The lightning (閃電) timeline [MapLayer] — scrubbable strike snapshots. /// -/// Each frame is a window of recent strikes at that snapshot time. Colour is -/// age vs the frame clock (5 / 10 / 30 / 60 min); shape is type (circle = -/// cloud-to-cloud, cross = cloud-to-ground) — legacy look without map sprites. +/// Identity and timeline plumbing only: every mark on the map is drawn by +/// [LightningStrikeOverlay], which the radar echo's lightning option mounts +/// too, so the two surfaces cannot drift into two different-looking keys. library; -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/error/result.dart'; -import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; +import 'package:dpip/features/map/presentation/layers/lightning_strike_overlay.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:dpip/shared/color_hex.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class LightningMapLayer with MapLayerDefaults implements MapLayer { - LightningMapLayer(this._repository); - - final MeteorLightningRepository _repository; - - static const String _sourceId = 'lightning-src'; - static const String _layerId = 'lightning-lyr'; - static const String _imagePrefix = 'lightning'; - - static const Map _empty = { - 'type': 'FeatureCollection', - 'features': [], - }; + LightningMapLayer(MeteorLightningRepository repository) + : _overlay = LightningStrikeOverlay(repository); - /// Age bucket → hex (legacy: red / yellow / green / blue). - /// - /// A getter rather than a `const` map: the strike marks are drawn by the - /// app (the PNGs are baked from these very colours), so the ramp follows - /// the colour-vision setting — and is re-read on every bake and every - /// legend build, which is what keeps the two agreeing after a change. - static Map get _ageHex => { - 5: '#FF0000'.vision, - 10: '#FFFF00'.vision, - 30: '#00FF00'.vision, - 60: '#0000FF'.vision, - }; - - final Map _cache = {}; - List _orderedIds = const []; - Map _indexById = const {}; - bool _mounted = false; - // Baked bitmaps carry the corrected colours painted into them, so they - // must be re-baked when the setting moves — see [VisionCache]. - bool _imagesReady = false; - ColorVision? _imagesVision; - String? _shownFrameId; + final LightningStrikeOverlay _overlay; @override String get id => 'lightning'; @@ -75,35 +37,13 @@ class LightningMapLayer with MapLayerDefaults implements MapLayer { double get bottomChromeFraction => 0; @override - Widget buildLegend(BuildContext context) { - final l10n = AppLocalizations.of(context); - return MapLegendCard( - child: SymbolLegend( - items: [ - for (final minutes in const [5, 10, 30, 60]) ...[ - SymbolLegendItem( - swatch: _LegendMark( - color: colorFromHexRgb(_ageHex[minutes]!)!, - cross: true, - ), - label: l10n.lightningLegendCg(minutes), - ), - SymbolLegendItem( - swatch: _LegendMark( - color: colorFromHexRgb(_ageHex[minutes]!)!, - cross: false, - ), - label: l10n.lightningLegendCc(minutes), - ), - ], - ], - ), - ); - } + Widget buildLegend(BuildContext context) => MapLegendCard( + child: SymbolLegend(items: LightningStrikeOverlay.legendItems(context)), + ); @override Future>> frames() async { - final result = await _repository.history(); + final result = await _overlay.history(); return result.map( (secs) => [ for (final sec in secs) @@ -119,328 +59,19 @@ class LightningMapLayer with MapLayerDefaults implements MapLayer { Future prepare( MapLibreMapController controller, List frames, - ) async { - _orderedIds = [for (final f in frames) f.id]; - _indexById = { - for (var i = 0; i < _orderedIds.length; i++) _orderedIds[i]: i, - }; - await _ensureImages(controller); - await _ensureSource(controller); - if (_orderedIds.isNotEmpty) { - await _fetchIntoCache(_orderedIds.last); - final start = _orderedIds.length > 3 ? _orderedIds.length - 3 : 0; - for (var i = start; i < _orderedIds.length - 1; i++) { - unawaited(_fetchIntoCache(_orderedIds[i])); - } - } - } + ) => _overlay.prepare(controller, [for (final frame in frames) frame.id]); @override Future show( MapLibreMapController controller, MapFrame frame, { bool scrubbing = false, - }) async { - // Same frame already on screen — a scrub settle re-shows the same frame. - // The cache check matters: a failed fetch leaves [_shownFrameId] set (with - // an empty payload on screen), and the data may land in the cache later — - // that frame must still be (re)shown. - if (_shownFrameId == frame.id && _cache.containsKey(frame.id)) return; - await _ensureImages(controller); - await _ensureSource(controller); - - var snapshot = _cache[frame.id]; - if (snapshot == null) { - if (scrubbing) return; - snapshot = await _fetchIntoCache(frame.id); - if (snapshot == null) { - try { - await controller.setGeoJsonSource(_sourceId, _empty); - } catch (_) {} - _shownFrameId = frame.id; - return; - } - } - - try { - await controller.setGeoJsonSource(_sourceId, _geoJson(snapshot)); - _shownFrameId = frame.id; - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'lightning show ${frame.id}'); - } - - if (!scrubbing) { - final i = _indexById[frame.id]; - if (i != null) { - for (final j in [i - 1, i + 1]) { - if (j >= 0 && j < _orderedIds.length) { - unawaited(_fetchIntoCache(_orderedIds[j])); - } - } - } - } - } - - @override - Future clear(MapLibreMapController controller) async { - await _removeFromMap(controller); - _mounted = false; - _shownFrameId = null; - } - - @override - void onStyleReset() { - _mounted = false; - _imagesReady = false; - _shownFrameId = null; - } - - Future _fetchIntoCache(String frameId) async { - final existing = _cache[frameId]; - if (existing != null) return existing; - final sec = int.tryParse(frameId); - if (sec == null) return null; - final result = await _repository.at(sec); - return result.when( - ok: (snapshot) { - _cache[frameId] = snapshot; - // Bound memory — keep ~40 frames: drop the oldest (ids are Unix - // seconds), never the frame that is on screen. - if (_cache.length > 40) { - final ids = _cache.keys.toList(growable: false) - ..sort((a, b) => int.parse(a).compareTo(int.parse(b))); - for (final id in ids.take(_cache.length - 40)) { - if (id != _shownFrameId) _cache.remove(id); - } - } - return snapshot; - }, - err: (failure) { - Log.warning('lightning frame $frameId: ${failure.message}'); - return null; - }, - ); - } - - Future _ensureImages(MapLibreMapController controller) async { - if (_imagesReady && _imagesVision == AppColorVision.current) return; - _imagesVision = AppColorVision.current; - try { - // One pre-coloured PNG per shape × age bucket, black outline baked in — - // the same trick as the wind arrows. MapLibre tints a plain (non-SDF) - // image by *replacing* its RGB, which would erase a baked outline, so the - // colour has to be baked too; the layer picks the image by feature. - for (final kind in const ['dot', 'cross']) { - for (final minutes in const [5, 10, 30, 60]) { - final fill = colorFromHexRgb(_ageHex[minutes]!)!; - final bytes = kind == 'dot' - ? await _renderDot(fill) - : await _renderCross(fill); - await controller.addImage(_imageId(kind, minutes), bytes, false); - } - } - _imagesReady = true; - } catch (error, stackTrace) { - // Style reload may leave images; retry next show. - Log.handle(error, stackTrace, 'lightning addImage'); - } - } - - static String _imageId(String kind, int minutes) => - '$_imagePrefix-$kind-$minutes'; - - Future _ensureSource(MapLibreMapController controller) async { - if (_mounted) return; - await _removeFromMap(controller); - await controller.addSource( - _sourceId, - GeojsonSourceProperties(data: _empty), - ); - await controller.addSymbolLayer( - _sourceId, - _layerId, - SymbolLayerProperties( - // The image is picked by the feature's `icon` property (kind + age), - // carrying the pre-baked colour + black outline — no `iconColor` tint, - // which would replace the baked-in outline on a non-SDF image. - iconImage: ['get', 'icon'], - iconOpacity: 0.85, - iconAllowOverlap: true, - iconIgnorePlacement: true, - // Same visual weight as the wind arrows (~35–110 px on screen at - // Taiwan overview zooms) — strikes have no speed dimension, so this is - // a flat zoom ramp rather than the wind layer's per-speed nested one. - iconSize: [ - 'interpolate', - ['linear'], - ['zoom'], - 5, - 0.6, - 15, - 1.8, - ], - ), - enableInteraction: false, - ); - _mounted = true; - } - - Map _geoJson(LightningSnapshot snapshot) { - final features = >[]; - for (final strike in snapshot.strikes) { - final age = _ageBucket(snapshot.time, strike.time); - final kind = strike.type == 1 ? 'cross' : 'dot'; - features.add({ - 'type': 'Feature', - 'geometry': { - 'type': 'Point', - 'coordinates': [strike.longitude, strike.latitude], - }, - 'properties': { - 'kind': strike.type == 1 ? 'cg' : 'cc', - 'age': age, - 'icon': _imageId(kind, age), - }, - }); - } - return {'type': 'FeatureCollection', 'features': features}; - } - - /// Age of [strikeSec] relative to [snapshotSec], bucketed like legacy. - static int _ageBucket(int snapshotSec, int strikeSec) { - final age = snapshotSec - strikeSec; - if (age < 5 * 60) return 5; - if (age < 10 * 60) return 10; - if (age < 30 * 60) return 30; - return 60; - } - - Future _renderDot(Color fill) async { - const size = 64.0; - // Same proportional outline as the wind arrows (5.5 on a 96 px canvas ≈ - // 5.7 %): 4.0 on the smaller 64 px mark keeps the ring visibly as thick - // after the layer scales both glyphs to the same on-screen size. - const halo = 4.0; - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - // Black offset-outline baked in (like the wind arrows): the PNG carries the - // bucket colour + black ring, and the layer shows it un-tinted so the ring - // survives. - canvas.drawCircle( - const Offset(size / 2, size / 2), - size * 0.28 + halo, - Paint()..color = const Color(0xFF000000).vision, - ); - canvas.drawCircle( - const Offset(size / 2, size / 2), - size * 0.28, - Paint()..color = fill, - ); - return _encodePng(recorder, size.toInt()); - } - - Future _renderCross(Color fill) async { - const size = 64.0; - const thickness = 10.0; - // Same proportional outline as the wind arrows — see [_renderDot]. - const halo = 4.0; - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - // Vertical + horizontal bars, black behind the colour (see [_renderDot]). - RRect bar(double w, double h) => RRect.fromRectAndRadius( - Rect.fromCenter( - center: const Offset(size / 2, size / 2), - width: w, - height: h, - ), - const Radius.circular(2), - ); - final black = Paint()..color = const Color(0xFF000000).vision; - canvas.drawRRect(bar(thickness + halo * 2, size * 0.7 + halo * 2), black); - canvas.drawRRect(bar(size * 0.7 + halo * 2, thickness + halo * 2), black); - canvas.drawRRect(bar(thickness, size * 0.7), Paint()..color = fill); - canvas.drawRRect(bar(size * 0.7, thickness), Paint()..color = fill); - return _encodePng(recorder, size.toInt()); - } - - /// Rasterises and encodes, disposing the picture and image on the way — - /// the ByteData is an independent copy, and the eight icon bakes used to - /// leak both native handles on every image (re)registration. - static Future _encodePng( - ui.PictureRecorder recorder, - int size, - ) async { - final picture = recorder.endRecording(); - final image = await picture.toImage(size, size); - picture.dispose(); - final data = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - return data!.buffer.asUint8List(); - } - - Future _removeFromMap(MapLibreMapController controller) async { - try { - await controller.removeLayer(_layerId); - } catch (_) {} - try { - await controller.removeSource(_sourceId); - } catch (_) {} - } -} - -class _LegendMark extends StatelessWidget { - const _LegendMark({required this.color, required this.cross}); - - final Color color; - final bool cross; + }) => _overlay.show(controller, frame.id, scrubbing: scrubbing); @override - Widget build(BuildContext context) { - if (!cross) { - return Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - // Same black outline the map icons bake in — pale strikes (yellow / - // blue on the frosted card) need it to read as a mark. - border: Border.all(color: Colors.black.vision, width: 1.2), - ), - ); - } - return SizedBox( - width: 12, - height: 12, - child: CustomPaint(painter: _CrossPainter(color)), - ); - } -} - -class _CrossPainter extends CustomPainter { - _CrossPainter(this.color); - - final Color color; - - @override - void paint(Canvas canvas, Size size) { - // Black bars first (thicker), the colour on top — the legend cross mirrors - // the map marker's baked black outline. - final paint = Paint() - ..color = const Color(0xFF000000).vision - ..strokeWidth = 5 - ..strokeCap = StrokeCap.round; - final c = Offset(size.width / 2, size.height / 2); - canvas.drawLine(Offset(c.dx, 1), Offset(c.dx, size.height - 1), paint); - canvas.drawLine(Offset(1, c.dy), Offset(size.width - 1, c.dy), paint); - paint - ..color = color - ..strokeWidth = 2.5; - canvas.drawLine(Offset(c.dx, 1), Offset(c.dx, size.height - 1), paint); - canvas.drawLine(Offset(1, c.dy), Offset(size.width - 1, c.dy), paint); - } + Future clear(MapLibreMapController controller) => + _overlay.clear(controller); @override - bool shouldRepaint(covariant _CrossPainter oldDelegate) => - oldDelegate.color != color; + void onStyleReset() => _overlay.onStyleReset(); } diff --git a/lib/features/map/presentation/layers/lightning_strike_overlay.dart b/lib/features/map/presentation/layers/lightning_strike_overlay.dart new file mode 100644 index 000000000..d912ec124 --- /dev/null +++ b/lib/features/map/presentation/layers/lightning_strike_overlay.dart @@ -0,0 +1,445 @@ +/// The lightning-strike marks themselves — the MapLibre source, symbol layer, +/// baked icons and snapshot cache that draw one 閃電 frame. +/// +/// Split out of [LightningMapLayer] because the strikes have two homes now: the +/// standalone 閃電 timeline layer, and the radar echo's own lightning overlay, +/// which draws the snapshot matching whichever radar frame is on screen. Both +/// need the identical marks — the same age ramp, the same baked PNGs, the same +/// legend — so the drawing lives here once and each host supplies only *which* +/// frame to show. +/// +/// Colour is age vs the frame clock (5 / 10 / 30 / 60 min); shape is type +/// (circle = cloud-to-cloud, cross = cloud-to-ground) — legacy look without map +/// sprites. +library; + +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; +import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Draws lightning strikes on one map surface. +/// +/// [namespace] scopes the MapLibre source/layer ids, so the 閃電 layer and the +/// radar overlay can each own a mount without colliding over one id. The baked +/// icons deliberately stay on shared ids: they are style-global, identical, and +/// registering one set twice would only cost memory. +class LightningStrikeOverlay { + LightningStrikeOverlay(this._repository, {String namespace = 'lightning'}) + : _sourceId = '$namespace-src', + _layerId = '$namespace-lyr'; + + final MeteorLightningRepository _repository; + + final String _sourceId; + final String _layerId; + + static const String _imagePrefix = 'lightning'; + + static const Map _empty = { + 'type': 'FeatureCollection', + 'features': [], + }; + + /// Age bucket → hex (legacy: red / yellow / green / blue). + /// + /// A getter rather than a `const` map: the strike marks are drawn by the + /// app (the PNGs are baked from these very colours), so the ramp follows + /// the colour-vision setting — and is re-read on every bake and every + /// legend build, which is what keeps the two agreeing after a change. + static Map get _ageHex => { + 5: '#FF0000'.vision, + 10: '#FFFF00'.vision, + 30: '#00FF00'.vision, + 60: '#0000FF'.vision, + }; + + final Map _cache = {}; + List _orderedIds = const []; + Map _indexById = const {}; + bool _mounted = false; + // Baked bitmaps carry the corrected colours painted into them, so they + // must be re-baked when the setting moves — see [VisionCache]. + bool _imagesReady = false; + ColorVision? _imagesVision; + String? _shownFrameId; + + /// Available snapshot times (Unix seconds, ascending). + Future>> history() => _repository.history(); + + /// The strike key, in the order the legend reads it. + static List legendItems(BuildContext context) { + final l10n = AppLocalizations.of(context); + return [ + for (final minutes in const [5, 10, 30, 60]) ...[ + SymbolLegendItem( + swatch: _LegendMark( + color: colorFromHexRgb(_ageHex[minutes]!)!, + cross: true, + ), + label: l10n.lightningLegendCg(minutes), + ), + SymbolLegendItem( + swatch: _LegendMark( + color: colorFromHexRgb(_ageHex[minutes]!)!, + cross: false, + ), + label: l10n.lightningLegendCc(minutes), + ), + ], + ]; + } + + /// Registers the frame set (Unix-second ids, chronological) and warms the + /// newest few so the first show is instant. + Future prepare( + MapLibreMapController controller, + List frameIds, + ) async { + _orderedIds = List.of(frameIds); + _indexById = { + for (var i = 0; i < _orderedIds.length; i++) _orderedIds[i]: i, + }; + await _ensureImages(controller); + await _ensureSource(controller); + if (_orderedIds.isNotEmpty) { + await _fetchIntoCache(_orderedIds.last); + final start = _orderedIds.length > 3 ? _orderedIds.length - 3 : 0; + for (var i = start; i < _orderedIds.length - 1; i++) { + unawaited(_fetchIntoCache(_orderedIds[i])); + } + } + } + + /// Draws [frameId]'s strikes. + Future show( + MapLibreMapController controller, + String frameId, { + bool scrubbing = false, + }) async { + // Same frame already on screen — a scrub settle re-shows the same frame. + // The cache check matters: a failed fetch leaves [_shownFrameId] set (with + // an empty payload on screen), and the data may land in the cache later — + // that frame must still be (re)shown. + if (_shownFrameId == frameId && _cache.containsKey(frameId)) return; + await _ensureImages(controller); + await _ensureSource(controller); + + var snapshot = _cache[frameId]; + if (snapshot == null) { + if (scrubbing) return; + snapshot = await _fetchIntoCache(frameId); + if (snapshot == null) { + try { + await controller.setGeoJsonSource(_sourceId, _empty); + } catch (_) {} + _shownFrameId = frameId; + return; + } + } + + try { + await controller.setGeoJsonSource(_sourceId, _geoJson(snapshot)); + _shownFrameId = frameId; + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'lightning show $frameId'); + } + + if (!scrubbing) { + final i = _indexById[frameId]; + if (i != null) { + for (final j in [i - 1, i + 1]) { + if (j >= 0 && j < _orderedIds.length) { + unawaited(_fetchIntoCache(_orderedIds[j])); + } + } + } + } + } + + /// Mounts the layer with no strikes on it — for a host whose current frame + /// has no lightning snapshot near enough to be honest about. Clearing the + /// features rather than removing the layer keeps "the overlay is on, there + /// is nothing to draw" distinct from "the overlay is off". + Future showEmpty(MapLibreMapController controller) async { + await _ensureImages(controller); + await _ensureSource(controller); + _shownFrameId = null; + try { + await controller.setGeoJsonSource(_sourceId, _empty); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'lightning clear features'); + } + } + + Future clear(MapLibreMapController controller) async { + await _removeFromMap(controller); + _mounted = false; + _shownFrameId = null; + } + + void onStyleReset() { + _mounted = false; + _imagesReady = false; + _shownFrameId = null; + } + + Future _fetchIntoCache(String frameId) async { + final existing = _cache[frameId]; + if (existing != null) return existing; + final sec = int.tryParse(frameId); + if (sec == null) return null; + final result = await _repository.at(sec); + return result.when( + ok: (snapshot) { + _cache[frameId] = snapshot; + // Bound memory — keep ~40 frames: drop the oldest (ids are Unix + // seconds), never the frame that is on screen. + if (_cache.length > 40) { + final ids = _cache.keys.toList(growable: false) + ..sort((a, b) => int.parse(a).compareTo(int.parse(b))); + for (final id in ids.take(_cache.length - 40)) { + if (id != _shownFrameId) _cache.remove(id); + } + } + return snapshot; + }, + err: (failure) { + Log.warning('lightning frame $frameId: ${failure.message}'); + return null; + }, + ); + } + + Future _ensureImages(MapLibreMapController controller) async { + if (_imagesReady && _imagesVision == AppColorVision.current) return; + _imagesVision = AppColorVision.current; + try { + // One pre-coloured PNG per shape × age bucket, black outline baked in — + // the same trick as the wind arrows. MapLibre tints a plain (non-SDF) + // image by *replacing* its RGB, which would erase a baked outline, so the + // colour has to be baked too; the layer picks the image by feature. + for (final kind in const ['dot', 'cross']) { + for (final minutes in const [5, 10, 30, 60]) { + final fill = colorFromHexRgb(_ageHex[minutes]!)!; + final bytes = kind == 'dot' + ? await _renderDot(fill) + : await _renderCross(fill); + await controller.addImage(_imageId(kind, minutes), bytes, false); + } + } + _imagesReady = true; + } catch (error, stackTrace) { + // Style reload may leave images; retry next show. + Log.handle(error, stackTrace, 'lightning addImage'); + } + } + + static String _imageId(String kind, int minutes) => + '$_imagePrefix-$kind-$minutes'; + + Future _ensureSource(MapLibreMapController controller) async { + if (_mounted) return; + await _removeFromMap(controller); + await controller.addSource( + _sourceId, + GeojsonSourceProperties(data: _empty), + ); + await controller.addSymbolLayer( + _sourceId, + _layerId, + SymbolLayerProperties( + // The image is picked by the feature's `icon` property (kind + age), + // carrying the pre-baked colour + black outline — no `iconColor` tint, + // which would replace the baked-in outline on a non-SDF image. + iconImage: ['get', 'icon'], + iconOpacity: 0.85, + iconAllowOverlap: true, + iconIgnorePlacement: true, + // Same visual weight as the wind arrows (~35–110 px on screen at + // Taiwan overview zooms) — strikes have no speed dimension, so this is + // a flat zoom ramp rather than the wind layer's per-speed nested one. + iconSize: [ + 'interpolate', + ['linear'], + ['zoom'], + 5, + 0.6, + 15, + 1.8, + ], + ), + enableInteraction: false, + ); + _mounted = true; + } + + Map _geoJson(LightningSnapshot snapshot) { + final features = >[]; + for (final strike in snapshot.strikes) { + final age = _ageBucket(snapshot.time, strike.time); + final kind = strike.type == 1 ? 'cross' : 'dot'; + features.add({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [strike.longitude, strike.latitude], + }, + 'properties': { + 'kind': strike.type == 1 ? 'cg' : 'cc', + 'age': age, + 'icon': _imageId(kind, age), + }, + }); + } + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Age of [strikeSec] relative to [snapshotSec], bucketed like legacy. + static int _ageBucket(int snapshotSec, int strikeSec) { + final age = snapshotSec - strikeSec; + if (age < 5 * 60) return 5; + if (age < 10 * 60) return 10; + if (age < 30 * 60) return 30; + return 60; + } + + Future _renderDot(Color fill) async { + const size = 64.0; + // Same proportional outline as the wind arrows (5.5 on a 96 px canvas ≈ + // 5.7 %): 4.0 on the smaller 64 px mark keeps the ring visibly as thick + // after the layer scales both glyphs to the same on-screen size. + const halo = 4.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Black offset-outline baked in (like the wind arrows): the PNG carries the + // bucket colour + black ring, and the layer shows it un-tinted so the ring + // survives. + canvas.drawCircle( + const Offset(size / 2, size / 2), + size * 0.28 + halo, + Paint()..color = const Color(0xFF000000).vision, + ); + canvas.drawCircle( + const Offset(size / 2, size / 2), + size * 0.28, + Paint()..color = fill, + ); + return _encodePng(recorder, size.toInt()); + } + + Future _renderCross(Color fill) async { + const size = 64.0; + const thickness = 10.0; + // Same proportional outline as the wind arrows — see [_renderDot]. + const halo = 4.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Vertical + horizontal bars, black behind the colour (see [_renderDot]). + RRect bar(double w, double h) => RRect.fromRectAndRadius( + Rect.fromCenter( + center: const Offset(size / 2, size / 2), + width: w, + height: h, + ), + const Radius.circular(2), + ); + final black = Paint()..color = const Color(0xFF000000).vision; + canvas.drawRRect(bar(thickness + halo * 2, size * 0.7 + halo * 2), black); + canvas.drawRRect(bar(size * 0.7 + halo * 2, thickness + halo * 2), black); + canvas.drawRRect(bar(thickness, size * 0.7), Paint()..color = fill); + canvas.drawRRect(bar(size * 0.7, thickness), Paint()..color = fill); + return _encodePng(recorder, size.toInt()); + } + + /// Rasterises and encodes, disposing the picture and image on the way — + /// the ByteData is an independent copy, and the eight icon bakes used to + /// leak both native handles on every image (re)registration. + static Future _encodePng( + ui.PictureRecorder recorder, + int size, + ) async { + final picture = recorder.endRecording(); + final image = await picture.toImage(size, size); + picture.dispose(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data!.buffer.asUint8List(); + } + + Future _removeFromMap(MapLibreMapController controller) async { + try { + await controller.removeLayer(_layerId); + } catch (_) {} + try { + await controller.removeSource(_sourceId); + } catch (_) {} + } +} + +class _LegendMark extends StatelessWidget { + const _LegendMark({required this.color, required this.cross}); + + final Color color; + final bool cross; + + @override + Widget build(BuildContext context) { + if (!cross) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + // Same black outline the map icons bake in — pale strikes (yellow / + // blue on the frosted card) need it to read as a mark. + border: Border.all(color: Colors.black.vision, width: 1.2), + ), + ); + } + return SizedBox( + width: 12, + height: 12, + child: CustomPaint(painter: _CrossPainter(color)), + ); + } +} + +class _CrossPainter extends CustomPainter { + _CrossPainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + // Black bars first (thicker), the colour on top — the legend cross mirrors + // the map marker's baked black outline. + final paint = Paint() + ..color = const Color(0xFF000000).vision + ..strokeWidth = 5 + ..strokeCap = StrokeCap.round; + final c = Offset(size.width / 2, size.height / 2); + canvas.drawLine(Offset(c.dx, 1), Offset(c.dx, size.height - 1), paint); + canvas.drawLine(Offset(1, c.dy), Offset(size.width - 1, c.dy), paint); + paint + ..color = color + ..strokeWidth = 2.5; + canvas.drawLine(Offset(c.dx, 1), Offset(c.dx, size.height - 1), paint); + canvas.drawLine(Offset(1, c.dy), Offset(size.width - 1, c.dy), paint); + } + + @override + bool shouldRepaint(covariant _CrossPainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 2a8f9467a..fffe03f09 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -1,35 +1,101 @@ +import 'dart:async'; + import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/map_reference_outline_controller.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; +import 'package:dpip/features/map/presentation/layers/lightning_strike_overlay.dart'; import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; import 'package:dpip/features/map/presentation/widgets/radar_overlay_menu.dart'; +import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart' show townLabelLayerId; import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; /// The radar echo (雷達回波) raster overlay. /// /// Everything about scrubbing — the preload ring, tile warming, scoped cancels — /// lives in [RasterTimelineLayer]; this supplies only radar's identity, its -/// opacity, the dBZ colour key, and the three overlays its options chip toggles. +/// opacity, the dBZ colour key, and the overlays its options chip toggles. /// /// The echo is mounted **above** the base style's borders (see /// [rasterBelowLayerId]) and the borders are put back on top as switchable /// layers ([ScanRangeOverlayChrome]). That is what makes them switchable at /// all: while they came through from underneath there was no way to get an /// uninterrupted raster. +/// +/// The options chip also carries the **lightning** overlay: the strikes of the +/// frame the echo is showing, drawn by the same [LightningStrikeOverlay] the +/// standalone 閃電 layer uses. It follows the timeline rather than the wall +/// clock — scrubbing back an hour moves the strikes back with the echo — which +/// is the whole point of putting it here instead of asking the user to compare +/// two layers by memory. class RadarMapLayer extends RasterTimelineLayer with AdminOutlineChrome, ScanRangeOverlayChrome { - RadarMapLayer(RadarRepository super.repository, this.referenceOutline); + RadarMapLayer( + RadarRepository super.repository, + this.referenceOutline, { + required MeteorLightningRepository lightning, + required SettingsStore settings, + }) : _settings = settings, + _lightning = LightningStrikeOverlay( + lightning, + namespace: 'radar-lightning', + ), + showLightning = ValueNotifier( + settings.getBool(SettingKeys.mapRadarShowLightning) ?? false, + ); @override final MapReferenceOutlineController referenceOutline; + final SettingsStore _settings; + + /// The strike marks, on this layer's own source/layer ids so mounting them + /// here can never collide with the standalone 閃電 layer's mount. + final LightningStrikeOverlay _lightning; + + /// Whether the echo also draws its frame's strikes. Persisted, and off by + /// default: the echo alone is what a reader came for, and every extra mark + /// on it is one the reader did not ask for. + final ValueNotifier showLightning; + + /// The lightning snapshot times (Unix seconds, ascending) the strike overlay + /// can be asked for — the radar timeline has its own, coarser steps, so the + /// two lists are matched by [_lightningIdFor] rather than assumed aligned. + List _lightningSeconds = const []; + + /// How far a lightning snapshot may sit from the radar frame and still be + /// drawn on it. + /// + /// The radar composite publishes every ten minutes and the strike snapshots + /// on their own cadence, so an exact match is not on offer and some slack is + /// required. Beyond this the overlay draws nothing rather than something: + /// strikes half an hour out of step with the echo under them are not a + /// slightly stale picture, they are a different storm. + static const Duration _lightningTolerance = Duration(minutes: 10); + + /// Serialises the overlay's map mutations. A scrub can deliver frames faster + /// than a fetch completes, and two interleaved `setGeoJsonSource` calls on + /// one source leave whichever finished last on screen — not whichever frame + /// the timeline is actually on. + Future _lightningChain = Future.value(); + + /// The controller this layer is mounted on, and the frame it was last asked + /// to show — what the lightning toggle needs to catch up to the echo the + /// moment it is switched on, rather than at the next timeline step. + MapLibreMapController? _controller; + MapFrame? _currentFrame; + /// The radar composite's own ids — the default geometry and layer naming. @override String get scanRangeSourceId => RadarScanRange.sourceId; @@ -112,6 +178,149 @@ class RadarMapLayer extends RasterTimelineLayer (65, ColorVisionFilter.rasterExemptHex('#9600FF')), ]; + /// The legend follows the lightning toggle too, so switching the strikes on + /// brings their key with them. + @override + Listenable get chromeListenable => + Listenable.merge([super.chromeListenable, showLightning]); + + /// The strike key is appended only while the strikes are actually drawn — a + /// legend naming marks that are not on the map is worse than no legend. + @override + List chromeLegendItems(BuildContext context) => [ + ...super.chromeLegendItems(context), + if (showLightning.value) ...LightningStrikeOverlay.legendItems(context), + ]; + + /// Turns the strike overlay on/off and remembers the choice. + void setShowLightning(bool value) { + if (showLightning.value == value) return; + showLightning.value = value; + unawaited(_settings.setBool(SettingKeys.mapRadarShowLightning, value)); + + final controller = _controller; + if (controller == null) return; + if (value) { + // Catch up to the frame already on screen — the reader switched this on + // to see *this* echo's strikes, not the next one's. + _enqueueLightning(() => _applyLightning()); + } else { + _enqueueLightning(() => _lightning.clear(controller)); + } + } + + @override + Future prepare( + MapLibreMapController controller, + List frames, + ) async { + _controller = controller; + await super.prepare(controller, frames); + if (showLightning.value) _enqueueLightning(_ensureLightningFrames); + } + + @override + Future show( + MapLibreMapController controller, + MapFrame frame, { + bool scrubbing = false, + }) { + _controller = controller; + _currentFrame = frame; + // Deliberately not awaited, and deliberately before the raster call: the + // strikes are an extra on top of the echo, and making the echo's reveal + // wait on a lightning fetch would put a network round-trip inside a scrub. + if (showLightning.value) { + _enqueueLightning(() => _applyLightning(scrubbing: scrubbing)); + } + return super.show(controller, frame, scrubbing: scrubbing); + } + + @override + Future clear(MapLibreMapController controller) async { + _currentFrame = null; + _controller = null; + await _lightning.clear(controller); + await super.clear(controller); + } + + @override + void onStyleReset() { + _lightning.onStyleReset(); + super.onStyleReset(); + } + + /// Runs [work] after whatever lightning work is already in flight. + /// + /// A scrub delivers frames faster than a snapshot fetch completes, and two + /// overlapping `setGeoJsonSource` calls on one source leave whichever + /// finished last on screen — not whichever frame the timeline is on. + void _enqueueLightning(Future Function() work) { + _lightningChain = _lightningChain.then((_) => work()).catchError(( + Object error, + StackTrace stackTrace, + ) { + Log.handle(error, stackTrace, 'radar lightning overlay'); + }); + } + + /// Loads the strike snapshot times once, and registers them with the overlay + /// so it can prefetch around whatever frame is shown. + Future _ensureLightningFrames() async { + final controller = _controller; + if (controller == null || _lightningSeconds.isNotEmpty) return; + final result = await _lightning.history(); + result.when( + ok: (seconds) { + _lightningSeconds = List.of(seconds)..sort(); + }, + err: (failure) { + // Left empty, so the next frame retries: the strike history is a + // side dish here, and a failed fetch must not disable the toggle. + Log.warning('radar lightning history: ${failure.message}'); + }, + ); + if (_lightningSeconds.isEmpty) return; + await _lightning.prepare(controller, [ + for (final sec in _lightningSeconds) '$sec', + ]); + } + + /// Draws the strikes belonging to the frame the echo is showing. + Future _applyLightning({bool scrubbing = false}) async { + if (!showLightning.value) return; + await _ensureLightningFrames(); + final controller = _controller; + final frame = _currentFrame; + if (controller == null || !showLightning.value) return; + + final id = frame == null ? null : _lightningIdFor(frame.time); + if (id == null) { + // Mounted and empty rather than absent: "the overlay is on and this + // frame has no matching strike data" is not the same as "off". + await _lightning.showEmpty(controller); + return; + } + await _lightning.show(controller, id, scrubbing: scrubbing); + } + + /// The strike snapshot nearest [frameTime], or null when the closest one is + /// further away than [_lightningTolerance]. + String? _lightningIdFor(DateTime frameTime) { + if (_lightningSeconds.isEmpty) return null; + final target = frameTime.millisecondsSinceEpoch ~/ 1000; + var best = _lightningSeconds.first; + var bestDelta = (best - target).abs(); + for (final sec in _lightningSeconds) { + final delta = (sec - target).abs(); + if (delta < bestDelta) { + best = sec; + bestDelta = delta; + } + } + return bestDelta > _lightningTolerance.inSeconds ? null : '$best'; + } + @override Widget buildLegend(BuildContext context) => ListenableBuilder( listenable: chromeListenable, diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index a75ff290b..5a8ca8a59 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -7,6 +7,7 @@ import 'package:dpip/core/settings/default_map_layer.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/core/settings/map_reference_outline_controller.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/disaster_map/domain/disaster_map_repository.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; @@ -77,6 +78,10 @@ class _MapPageState extends State { RadarMapLayer( context.read(), context.read(), + // The echo's optional lightning overlay reads the same strike repository + // the standalone 閃電 layer does — one cache, one source of marks. + lightning: context.read(), + settings: context.read(), ), // The wind-forecast block sits right after radar: the picker groups by // category in declared order, and the numerical-forecast group (QPESUMS diff --git a/lib/features/map/presentation/widgets/radar_overlay_menu.dart b/lib/features/map/presentation/widgets/radar_overlay_menu.dart index 1e4fdd28a..f206616c3 100644 --- a/lib/features/map/presentation/widgets/radar_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/radar_overlay_menu.dart @@ -4,12 +4,16 @@ library; import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/map/presentation/widgets/scan_range_overlay_menu.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// The radar layer's own options chip — the shared chrome menu, titled for -/// radar. Kept as a separate type so callers read "radar options", not "some -/// overlay menu". +/// radar and carrying one extra row the other rasters do not have: the +/// lightning overlay, which draws the strikes belonging to whichever echo frame +/// is on screen. class RadarOverlayMenu extends StatelessWidget { const RadarOverlayMenu({ super.key, @@ -28,12 +32,32 @@ class RadarOverlayMenu extends StatelessWidget { final ValueChanged onShowTerrainChanged; @override - Widget build(BuildContext context) => ScanRangeOverlayMenu( - layer: layer, - tooltip: AppLocalizations.of(context).radarOverlayMenuTooltip, - showTownLabels: showTownLabels, - onShowTownLabelsChanged: onShowTownLabelsChanged, - showTerrain: showTerrain, - onShowTerrainChanged: onShowTerrainChanged, + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: layer.showLightning, + builder: (context, showLightning, _) { + final l10n = AppLocalizations.of(context); + return ScanRangeOverlayMenu( + layer: layer, + tooltip: l10n.radarOverlayMenuTooltip, + showTownLabels: showTownLabels, + onShowTownLabelsChanged: onShowTownLabelsChanged, + showTerrain: showTerrain, + onShowTerrainChanged: onShowTerrainChanged, + // Off by default, so having it on is a departure worth the chip's dot. + extraActive: showLightning, + extraSections: [ + const MapMenuDivider(), + SectionHeader(l10n.mapOverlaySectionData), + MapMenuToggleRow( + selected: showLightning, + icon: Icons.bolt_outlined, + title: l10n.radarLightningOverlay, + subtitle: l10n.radarLightningOverlayHint, + tooltip: l10n.radarLightningOverlaySubtitle, + onTap: () => layer.setShowLightning(!showLightning), + ), + ], + ); + }, ); } diff --git a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart index 79f12e384..4a577e992 100644 --- a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -27,6 +27,8 @@ class ScanRangeOverlayMenu extends StatelessWidget { required this.onShowTownLabelsChanged, required this.showTerrain, required this.onShowTerrainChanged, + this.extraSections = const [], + this.extraActive = false, }); final ScanRangeOverlayChrome layer; @@ -35,6 +37,17 @@ class ScanRangeOverlayMenu extends StatelessWidget { /// is configuring). final String tooltip; + /// Rows a specific raster adds above the shared reference section — the radar + /// echo's lightning overlay, for one. They come first because they are about + /// the *data* being shown; the reference chrome underneath is the same four + /// toggles on every raster, and a reader looking for the layer-specific + /// switch should not have to scroll past them. + final List extraSections; + + /// Whether [extraSections] currently holds a non-default choice, so the + /// chip's "not the defaults" dot accounts for them too. + final bool extraActive; + final ValueListenable showTownLabels; final ValueChanged onShowTownLabelsChanged; @@ -72,7 +85,8 @@ class ScanRangeOverlayMenu extends StatelessWidget { !showCounty || !showTown || !showLabels || - !showRelief, + !showRelief || + extraActive, onTap: () => controller.isOpen ? controller.close() : controller.open(), ), @@ -85,6 +99,7 @@ class ScanRangeOverlayMenu extends StatelessWidget { showTerrain: showTerrain, onShowTerrainChanged: onShowTerrainChanged, ), + ...extraSections, const MapMenuDivider(), SectionHeader(l10n.mapOverlaySectionReference), MapMenuToggleRow( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b2018d793..c7912fde4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3988,5 +3988,21 @@ }, "@eewSpokenAnnouncementDescription": { "description": "Explains what the spoken-announcement toggle does and when it speaks" + }, + "mapOverlaySectionData": "Data layers", + "@mapOverlaySectionData": { + "description": "Section title in map overlay settings menus: the data overlays a layer can add on top of itself" + }, + "radarLightningOverlay": "Show lightning", + "@radarLightningOverlay": { + "description": "Lightning overlay toggle in the map's radar overlay menu." + }, + "radarLightningOverlayHint": "Strikes from the frame on screen", + "@radarLightningOverlayHint": { + "description": "Hint under the lightning toggle in the radar overlay menu." + }, + "radarLightningOverlaySubtitle": "Overlays the lightning strikes recorded at the same time as the radar frame you are looking at.", + "@radarLightningOverlaySubtitle": { + "description": "Tooltip for the lightning toggle in the radar overlay menu." } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index b38b0f46b..b244780b6 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "Tinatayang intensidad sa iyong lokasyon: {intensity}.", "eewSpokenMaxIntensity": "Tinatayang pinakamataas na intensidad: {intensity}.", "eewSpokenAnnouncementTitle": "Basahin ang tinatayang intensidad", - "eewSpokenAnnouncementDescription": "Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala." + "eewSpokenAnnouncementDescription": "Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala.", + "mapOverlaySectionData": "Mga layer ng datos", + "radarLightningOverlay": "Ipakita ang kidlat", + "radarLightningOverlayHint": "Kidlat sa oras ng frame na nakikita", + "radarLightningOverlaySubtitle": "Ipinapatong ang mga kidlat na naitala sa parehong oras ng radar na tinitingnan mo." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 2860d3038..bdbda2370 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "Perkiraan intensitas di lokasi Anda: {intensity}.", "eewSpokenMaxIntensity": "Perkiraan intensitas maksimum: {intensity}.", "eewSpokenAnnouncementTitle": "Bacakan intensitas perkiraan", - "eewSpokenAnnouncementDescription": "Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar." + "eewSpokenAnnouncementDescription": "Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar.", + "mapOverlaySectionData": "Lapisan data", + "radarLightningOverlay": "Tampilkan petir", + "radarLightningOverlayHint": "Petir pada waktu bingkai yang tampil", + "radarLightningOverlaySubtitle": "Menampilkan sambaran petir yang tercatat pada waktu yang sama dengan citra radar yang sedang dilihat." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 0ebbe5d6f..9426e3a4d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "現在地の予想震度、{intensity}。", "eewSpokenMaxIntensity": "予想最大震度、{intensity}。", "eewSpokenAnnouncementTitle": "予想震度を読み上げる", - "eewSpokenAnnouncementDescription": "強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。" + "eewSpokenAnnouncementDescription": "強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。", + "mapOverlaySectionData": "データレイヤー", + "radarLightningOverlay": "雷を表示", + "radarLightningOverlayHint": "表示中のエコーと同時刻の落雷", + "radarLightningOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の落雷を重ねて表示します。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 787c107f9..e56868936 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "현재 위치 예상 진도, {intensity}.", "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}.", "eewSpokenAnnouncementTitle": "예상 진도 음성 안내", - "eewSpokenAnnouncementDescription": "지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다." + "eewSpokenAnnouncementDescription": "지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다.", + "mapOverlaySectionData": "데이터 레이어", + "radarLightningOverlay": "번개 표시", + "radarLightningOverlayHint": "화면에 표시된 시각의 낙뢰", + "radarLightningOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 낙뢰를 겹쳐서 표시합니다." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 705871ac6..8a4728e2f 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: {intensity}", "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}", "eewSpokenAnnouncementTitle": "อ่านออกเสียงความรุนแรงที่คาดการณ์", - "eewSpokenAnnouncementDescription": "เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน" + "eewSpokenAnnouncementDescription": "เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน", + "mapOverlaySectionData": "ชั้นข้อมูล", + "radarLightningOverlay": "แสดงฟ้าผ่า", + "radarLightningOverlayHint": "ฟ้าผ่าในเวลาเดียวกับภาพที่แสดง", + "radarLightningOverlaySubtitle": "ซ้อนตำแหน่งฟ้าผ่าที่บันทึกในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 13d337b5f..5698698dd 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "Cường độ dự kiến tại vị trí của bạn: {intensity}.", "eewSpokenMaxIntensity": "Cường độ tối đa dự kiến: {intensity}.", "eewSpokenAnnouncementTitle": "Đọc cường độ dự kiến", - "eewSpokenAnnouncementDescription": "Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động." + "eewSpokenAnnouncementDescription": "Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động.", + "mapOverlaySectionData": "Lớp dữ liệu", + "radarLightningOverlay": "Hiện sét", + "radarLightningOverlayHint": "Sét cùng thời điểm với ảnh đang xem", + "radarLightningOverlaySubtitle": "Chồng các cú sét được ghi nhận cùng thời điểm với ảnh radar đang hiển thị." } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 4b05be91c..a53cedaf4 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗讀預估震度", - "eewSpokenAnnouncementDescription": "開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。" + "eewSpokenAnnouncementDescription": "開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。", + "mapOverlaySectionData": "資料圖層", + "radarLightningOverlay": "顯示閃電", + "radarLightningOverlayHint": "顯示同畫面回波同一時間嘅落雷", + "radarLightningOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅閃電落雷。" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3d94cd0fd..96de7d2d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1979,5 +1979,9 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗读预估烈度", - "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。" + "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。", + "mapOverlaySectionData": "数据图层", + "radarLightningOverlay": "显示闪电", + "radarLightningOverlayHint": "显示与画面回波同一时间的落雷", + "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index cb72be38e..e9b67118e 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "所在地预估烈度,{intensity}。", "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。", "eewSpokenAnnouncementTitle": "朗读预估烈度", - "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。" + "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。", + "mapOverlaySectionData": "数据图层", + "radarLightningOverlay": "显示闪电", + "radarLightningOverlayHint": "显示与画面回波同一时间的落雷", + "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index cb9c46e94..a40ea0a62 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗讀預估震度", - "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。" + "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。", + "mapOverlaySectionData": "資料圖層", + "radarLightningOverlay": "顯示閃電", + "radarLightningOverlayHint": "顯示與畫面回波同一時間嘅落雷", + "radarLightningOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅閃電落雷。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index b24e0505f..7d7b8fb83 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1987,5 +1987,9 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗讀預估震度", - "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。" + "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。", + "mapOverlaySectionData": "資料圖層", + "radarLightningOverlay": "顯示閃電", + "radarLightningOverlayHint": "顯示與畫面回波同時間的落雷", + "radarLightningOverlaySubtitle": "在目前的雷達回波上疊加同一時間的閃電落雷。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index e9912f770..e84d556b5 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6304,6 +6304,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.'** String get eewSpokenAnnouncementDescription; + + /// Section title in map overlay settings menus: the data overlays a layer can add on top of itself + /// + /// In en, this message translates to: + /// **'Data layers'** + String get mapOverlaySectionData; + + /// Lightning overlay toggle in the map's radar overlay menu. + /// + /// In en, this message translates to: + /// **'Show lightning'** + String get radarLightningOverlay; + + /// Hint under the lightning toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Strikes from the frame on screen'** + String get radarLightningOverlayHint; + + /// Tooltip for the lightning toggle in the radar overlay menu. + /// + /// In en, this message translates to: + /// **'Overlays the lightning strikes recorded at the same time as the radar frame you are looking at.'** + String get radarLightningOverlaySubtitle; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index aeafddcc2..27f27274b 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3320,4 +3320,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.'; + + @override + String get mapOverlaySectionData => 'Data layers'; + + @override + String get radarLightningOverlay => 'Show lightning'; + + @override + String get radarLightningOverlayHint => 'Strikes from the frame on screen'; + + @override + String get radarLightningOverlaySubtitle => + 'Overlays the lightning strikes recorded at the same time as the radar frame you are looking at.'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 5baed22f8..14641265f 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3338,4 +3338,17 @@ class AppLocalizationsFil extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala.'; + + @override + String get mapOverlaySectionData => 'Mga layer ng datos'; + + @override + String get radarLightningOverlay => 'Ipakita ang kidlat'; + + @override + String get radarLightningOverlayHint => 'Kidlat sa oras ng frame na nakikita'; + + @override + String get radarLightningOverlaySubtitle => + 'Ipinapatong ang mga kidlat na naitala sa parehong oras ng radar na tinitingnan mo.'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 273b7834a..f23cb4178 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3331,4 +3331,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar.'; + + @override + String get mapOverlaySectionData => 'Lapisan data'; + + @override + String get radarLightningOverlay => 'Tampilkan petir'; + + @override + String get radarLightningOverlayHint => + 'Petir pada waktu bingkai yang tampil'; + + @override + String get radarLightningOverlaySubtitle => + 'Menampilkan sambaran petir yang tercatat pada waktu yang sama dengan citra radar yang sedang dilihat.'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 1fbb99eae..49593f6de 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3259,4 +3259,16 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。'; + + @override + String get mapOverlaySectionData => 'データレイヤー'; + + @override + String get radarLightningOverlay => '雷を表示'; + + @override + String get radarLightningOverlayHint => '表示中のエコーと同時刻の落雷'; + + @override + String get radarLightningOverlaySubtitle => '表示中のレーダーエコーと同じ時刻の落雷を重ねて表示します。'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 8377fe20b..e440e530d 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3259,4 +3259,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다.'; + + @override + String get mapOverlaySectionData => '데이터 레이어'; + + @override + String get radarLightningOverlay => '번개 표시'; + + @override + String get radarLightningOverlayHint => '화면에 표시된 시각의 낙뢰'; + + @override + String get radarLightningOverlaySubtitle => + '현재 보고 있는 레이더 영상과 같은 시각의 낙뢰를 겹쳐서 표시합니다.'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 288d3b439..869d683cd 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3313,4 +3313,17 @@ class AppLocalizationsTh extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน'; + + @override + String get mapOverlaySectionData => 'ชั้นข้อมูล'; + + @override + String get radarLightningOverlay => 'แสดงฟ้าผ่า'; + + @override + String get radarLightningOverlayHint => 'ฟ้าผ่าในเวลาเดียวกับภาพที่แสดง'; + + @override + String get radarLightningOverlaySubtitle => + 'ซ้อนตำแหน่งฟ้าผ่าที่บันทึกในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 9ab556fe5..0e7afe520 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3321,4 +3321,17 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động.'; + + @override + String get mapOverlaySectionData => 'Lớp dữ liệu'; + + @override + String get radarLightningOverlay => 'Hiện sét'; + + @override + String get radarLightningOverlayHint => 'Sét cùng thời điểm với ảnh đang xem'; + + @override + String get radarLightningOverlaySubtitle => + 'Chồng các cú sét được ghi nhận cùng thời điểm với ảnh radar đang hiển thị.'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 27e8bbbac..14b2b0f57 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3241,4 +3241,16 @@ class AppLocalizationsYue extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。'; + + @override + String get mapOverlaySectionData => '資料圖層'; + + @override + String get radarLightningOverlay => '顯示閃電'; + + @override + String get radarLightningOverlayHint => '顯示同畫面回波同一時間嘅落雷'; + + @override + String get radarLightningOverlaySubtitle => '喺而家嘅雷達回波上面疊加同一時間嘅閃電落雷。'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 3b965b2e1..a15f4b666 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3241,6 +3241,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '打开强震监视器时,先用语音朗读预估烈度,再播放警示音。'; + + @override + String get mapOverlaySectionData => '数据图层'; + + @override + String get radarLightningOverlay => '显示闪电'; + + @override + String get radarLightningOverlayHint => '显示与画面回波同一时间的落雷'; + + @override + String get radarLightningOverlaySubtitle => '在当前的雷达回波上叠加同一时间的闪电落雷。'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6479,6 +6491,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get eewSpokenAnnouncementDescription => '打开强震监视器时,先用语音朗读预估烈度,再播放警示音。'; + + @override + String get mapOverlaySectionData => '数据图层'; + + @override + String get radarLightningOverlay => '显示闪电'; + + @override + String get radarLightningOverlayHint => '显示与画面回波同一时间的落雷'; + + @override + String get radarLightningOverlaySubtitle => '在当前的雷达回波上叠加同一时间的闪电落雷。'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9717,6 +9741,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get eewSpokenAnnouncementDescription => '開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。'; + + @override + String get mapOverlaySectionData => '資料圖層'; + + @override + String get radarLightningOverlay => '顯示閃電'; + + @override + String get radarLightningOverlayHint => '顯示與畫面回波同一時間嘅落雷'; + + @override + String get radarLightningOverlaySubtitle => '喺目前嘅雷達回波上疊加同一時間嘅閃電落雷。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12955,4 +12991,16 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get eewSpokenAnnouncementDescription => '開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。'; + + @override + String get mapOverlaySectionData => '資料圖層'; + + @override + String get radarLightningOverlay => '顯示閃電'; + + @override + String get radarLightningOverlayHint => '顯示與畫面回波同時間的落雷'; + + @override + String get radarLightningOverlaySubtitle => '在目前的雷達回波上疊加同一時間的閃電落雷。'; } diff --git a/test/features/map/layer_stacking_test.dart b/test/features/map/layer_stacking_test.dart index 2499f0f3a..8f2d5637a 100644 --- a/test/features/map/layer_stacking_test.dart +++ b/test/features/map/layer_stacking_test.dart @@ -17,7 +17,6 @@ import 'dart:typed_data'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/map/presentation/layers/qpesums_layer.dart'; -import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart'; @@ -104,7 +103,7 @@ Future<(RecordingMapController, List)> _scrub( void main() { test('a scrub never buries the admin borders under the echo', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadar(_ids(9))); final (controller, ids) = await _scrub(layer); for (final boundary in [AdminBoundary.county, AdminBoundary.town]) { @@ -126,7 +125,7 @@ void main() { }); test('the borders still stay under the township names', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadar(_ids(9))); final (controller, _) = await _scrub(layer); // The labels are the top-most text on every surface: a border line must // never cross a place name. @@ -137,7 +136,7 @@ void main() { }); test('the scan-range circle is drawn over the echo, not under it', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadar(_ids(9))); layer.setShowScanRange(true); final (controller, ids) = await _scrub(layer); @@ -153,7 +152,7 @@ void main() { }); test('the seam sits between the frames and the chrome', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadar(_ids(9))); final (controller, ids) = await _scrub(layer); final seam = layer.frameSeamLayerId; @@ -166,7 +165,7 @@ void main() { }); test('the seam is torn down with the layer', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadar(_ids(9))); final (controller, _) = await _scrub(layer); expect(controller.order, contains(layer.frameSeamLayerId)); @@ -180,7 +179,7 @@ void main() { test('every timeline layer keeps its own chrome above its frames', () async { final layers = [ - RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()), + testRadarLayer(_FakeRadar(_ids(9))), QpesumsMapLayer(_FakeQpesums(_ids(9)), testReferenceOutline()), WindForecastMapLayer( _FakeWind(_ids(9)), diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index cf9616093..0c55bf146 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; +import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/shared/map/admin_outline.dart'; import 'package:dpip/shared/map/map_style.dart' show outlineLayerId, townLabelLayerId; @@ -97,9 +100,8 @@ class _BlockedNeighboursRadarRepository extends _FakeRadarRepository { void main() { test('frames chronological', () async { - final layer = RadarMapLayer( + final layer = testRadarLayer( _FakeRadarRepository(['1700000600', '1700000000']), - testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; expect(frames.map((f) => f.id), ['1700000000', '1700000600']); @@ -107,7 +109,7 @@ void main() { test('a settle mounts the preload ring around the target', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -140,7 +142,7 @@ void main() { test('a cancelled incomplete frame is recreated before reuse', () async { final source = _FakeRadarRepository(_ids(15)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); final oldFrame = frames[2].id; @@ -169,7 +171,7 @@ void main() { 'a complete retired frame remains reusable without cancellation', () async { final source = _FakeRadarRepository(_ids(15)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); final oldFrame = frames[2].id; @@ -193,7 +195,7 @@ void main() { test('a blocked warm cannot leave two timestamps at full opacity', () async { final source = _BlockingWarmRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -224,7 +226,7 @@ void main() { 'scrubbing inside the ring is two opacity writes, nothing else', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -275,7 +277,7 @@ void main() { test('timeline touch cancels preload before the first frame event', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -296,7 +298,7 @@ void main() { test('native idle makes an ambient-cache ring scrub-ready', () async { final source = _ControlledReadinessRadarRepository(_ids(5))..ready = false; - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -323,7 +325,7 @@ void main() { test('a late native idle completes a settle after an L1 miss', () async { final source = _ControlledReadinessRadarRepository(_ids(5))..ready = false; - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -341,7 +343,7 @@ void main() { test('a scrub derives the visible region once, not once per frame', () async { final source = _ControlledReadinessRadarRepository(_ids(9))..ready = false; - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -380,7 +382,7 @@ void main() { test('camera movement invalidates native readiness during a scrub', () async { final source = _ControlledReadinessRadarRepository(_ids(5)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -400,10 +402,7 @@ void main() { }); test('frames mount without opacity or per-tile fades', () async { - final layer = RadarMapLayer( - _FakeRadarRepository(_ids(9)), - testReferenceOutline(), - ); + final layer = testRadarLayer(_FakeRadarRepository(_ids(9))); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -429,7 +428,7 @@ void main() { test('an idle-preloaded scrub target restores and flips from L1', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -462,7 +461,7 @@ void main() { 'idle settle fills the resident ceiling without extra draw passes', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -517,7 +516,7 @@ void main() { () async { final ids = _ids(40); final source = _BlockedNeighboursRadarRepository(ids); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; source.blockedFrames = {frames[23].id, frames[17].id, frames[24].id}; final controller = RecordingMapController(); @@ -554,7 +553,7 @@ void main() { test('hiding a settled map releases decoded preload sources', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -592,7 +591,7 @@ void main() { 'a replacement map refreshes repaired L1 before mounting tiles', () async { final source = _FakeRadarRepository(_ids(5)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final oldController = RecordingMapController(); @@ -629,7 +628,7 @@ void main() { 'one gesture cancels warm once and restarts it after settling', () async { final source = _FakeRadarRepository(_ids(12)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -657,7 +656,7 @@ void main() { 'returning to the map restores the cancelled GIF preload window', () async { final source = _BlockingWarmRadarRepository(_ids(12)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -697,7 +696,7 @@ void main() { 'a timeline born off-screen does no warm work before first reveal', () async { final source = _FakeRadarRepository(_ids(12)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -732,7 +731,7 @@ void main() { test('hiding the map cancels all in-flight idle preload lanes', () async { final ids = _ids(40); final source = _BlockedNeighboursRadarRepository(ids); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; source.blockedFrames = {frames[23].id, frames[17].id, frames[24].id}; final controller = RecordingMapController(); @@ -763,7 +762,7 @@ void main() { 'memory pressure releases speculative sources while the map is visible', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -808,7 +807,7 @@ void main() { 'a map that is still warming can be trimmed without losing its frame', () async { final source = _BlockingWarmRadarRepository(_ids(12)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -855,7 +854,7 @@ void main() { test('a long cached scrub keeps the resident source set bounded', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -896,7 +895,7 @@ void main() { test('a cold fast scrub mounts only the final ring on finger-up', () async { final source = _ControlledReadinessRadarRepository(_ids(12)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -928,7 +927,7 @@ void main() { test('a settle abandons the frames the scrub swept past', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -946,7 +945,7 @@ void main() { test('finger-up settles the cold frame held during scrubbing', () async { final source = _ControlledReadinessRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -972,7 +971,7 @@ void main() { test('a settle warms outward from the frame, far beyond the ring', () async { // 25 frames so the ±4 ring is a strict subset of the warm spread. final source = _FakeRadarRepository(_ids(25)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -994,7 +993,7 @@ void main() { test('a settled fill uses the full frame budget at a series edge', () async { final source = _FakeRadarRepository(_ids(700)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1019,7 +1018,7 @@ void main() { test('scrubbing never launches a whole-history warm scan', () async { final source = _FakeRadarRepository(_ids(25)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1053,7 +1052,7 @@ void main() { test('a cold scrub target cannot replace the complete frame', () async { final source = _ControlledReadinessRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1101,7 +1100,7 @@ void main() { test('a held scrub frame can retry after backpressure quiet', () async { final source = _ControlledReadinessRadarRepository(_ids(9)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1127,7 +1126,7 @@ void main() { 'an older readiness completion cannot overwrite a newer target', () async { final source = _ControlledReadinessRadarRepository(_ids(12)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1147,7 +1146,7 @@ void main() { test('clear releases tiles and removes every mounted frame', () async { final source = _FakeRadarRepository(_ids(5)); - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1169,20 +1168,14 @@ void main() { test('history length uncapped', () async { final ids = [for (var i = 0; i < 500; i++) '${1700000000 + i * 600}']; - final layer = RadarMapLayer( - _FakeRadarRepository(ids.reversed.toList()), - testReferenceOutline(), - ); + final layer = testRadarLayer(_FakeRadarRepository(ids.reversed.toList())); expect((await layer.frames()).valueOrNull!.length, 500); }); group('overlays', () { /// A layer attached to a live map, ready for the toggles. Future<(RadarMapLayer, RecordingMapController)> attached() async { - final layer = RadarMapLayer( - _FakeRadarRepository(_ids(3)), - testReferenceOutline(), - ); + final layer = testRadarLayer(_FakeRadarRepository(_ids(3))); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); await layer.prepare(controller, frames); @@ -1417,6 +1410,135 @@ void main() { expect(fresh.calls, contains('addLineLayer:admin-county-outline')); }); }); + + group('lightning overlay', () { + // Radar frames run 1700000000 + i*600 (see [_ids]); the strike snapshots + // are offset by a minute so nothing lines up exactly — which is the real + // case, and what the nearest-within-tolerance match exists for. + const radarFrame = 1700000000 + 4 * 600; + List lightningNear() => const [ + radarFrame - 660, + radarFrame - 60, + radarFrame + 540, + ]; + + Future<(RadarMapLayer, RecordingMapController, _FakeLightning)> shown({ + required List history, + bool enabled = true, + }) async { + final lightning = _FakeLightning(history); + final layer = testRadarLayer( + _FakeRadarRepository(_ids(9)), + lightning: lightning, + ); + if (enabled) layer.setShowLightning(true); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + // The strike work is deliberately off the echo's critical path, so it + // lands a microtask or two behind the frame it belongs to. + await pumpEventQueue(); + return (layer, controller, lightning); + } + + test('stays off the map until it is switched on', () async { + final (_, controller, lightning) = await shown( + history: lightningNear(), + enabled: false, + ); + expect( + controller.calls, + isNot(contains('addSource:radar-lightning-src')), + ); + expect( + lightning.historyCalls, + 0, + reason: + 'an overlay nobody asked for must not cost a request — the strike ' + 'history is only fetched once the toggle is on', + ); + }); + + test('draws the snapshot nearest the frame on screen', () async { + final (_, controller, lightning) = await shown(history: lightningNear()); + + expect(controller.calls, contains('addSource:radar-lightning-src')); + expect(controller.calls, contains('addSymbolLayer:radar-lightning-lyr')); + expect( + lightning.fetched, + contains(radarFrame - 60), + reason: 'the nearest snapshot to the shown frame, not the newest', + ); + final features = + controller.sourceData['radar-lightning-src']!['features'] as List; + expect(features, hasLength(1)); + }); + + test('draws nothing when no snapshot is near the frame', () async { + // Every snapshot is more than the ten-minute tolerance away: strikes + // that far out of step with the echo are a different storm, so the + // overlay stays mounted and empty rather than showing them. + final (_, controller, _) = await shown( + history: const [radarFrame - 3600, radarFrame + 3600], + ); + + expect(controller.calls, contains('addSource:radar-lightning-src')); + final features = + controller.sourceData['radar-lightning-src']!['features'] as List; + expect(features, isEmpty); + }); + + test('switching it back off takes the strikes off the map', () async { + final (layer, controller, _) = await shown(history: lightningNear()); + controller.calls.clear(); + + layer.setShowLightning(false); + await pumpEventQueue(); + + expect(controller.calls, contains('removeLayer:radar-lightning-lyr')); + expect(controller.calls, contains('removeSource:radar-lightning-src')); + }); + }); +} + +/// A strike repository with a fixed history and one cloud-to-ground strike in +/// every snapshot — enough to tell "drew this frame" from "drew nothing". +class _FakeLightning implements MeteorLightningRepository { + _FakeLightning(this._history); + + final List _history; + + /// Snapshot seconds actually requested, in order. + final List fetched = []; + int historyCalls = 0; + + @override + Future>> history() async { + historyCalls++; + return Ok(_history); + } + + @override + Future> latest() => at(_history.last); + + @override + Future> at(int second) async { + fetched.add(second); + return Ok( + LightningSnapshot( + time: second, + strikes: [ + LightningStrike( + type: 1, + time: second - 30, + latitude: 23.5, + longitude: 121, + ), + ], + ), + ); + } } /// [count] frame ids, newest first (the wire order). diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index e315a87b8..dcb8495ed 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -56,11 +56,11 @@ void _useTallSurface(WidgetTester tester) { } void main() { - testWidgets('the chip opens a menu carrying all six overlay toggles', ( + testWidgets('the chip opens a menu carrying all seven overlay toggles', ( tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -76,21 +76,52 @@ void main() { expect(find.text(l10n.radarTownOutline), findsOneWidget); expect(find.text(l10n.mapTownLabels), findsOneWidget); expect(find.text(l10n.mapTerrainRelief), findsOneWidget); + expect(find.text(l10n.radarLightningOverlay), findsOneWidget); // The menu is sectioned like the typhoon one: the raster's reference // chrome first, then the base-map settings. expect(find.text(l10n.mapOverlaySectionReference), findsOneWidget); expect(find.text(l10n.mapOverlaySectionMap), findsOneWidget); + expect(find.text(l10n.mapOverlaySectionData), findsOneWidget); // Reference chrome (scan range, county, town, 國界) and the name and - // relief toggles all ship on; nothing ships off. + // relief toggles all ship on. Lightning is the one that ships off: it is + // extra data drawn over the echo, not chrome, so it is opt-in. expect(find.byIcon(Icons.check_box), findsNWidgets(6)); - expect(find.byIcon(Icons.check_box_outline_blank), findsNothing); + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + }); + + testWidgets('the lightning row toggles the overlay and its chip dot', ( + tester, + ) async { + _useTallSurface(tester); + final layer = testRadarLayer(_FakeRadarRepository()); + await tester.pumpWidget(_wrap(layer)); + + final l10n = await _l10n(); + expect(layer.showLightning.value, isFalse); + // Everything else ships at its default, so the chip is undotted until + // lightning is switched on. + expect( + tester.widget(find.byType(MapChipButton)).active, + isFalse, + ); + + await tester.tap(find.byType(MapChipButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.radarLightningOverlay)); + await tester.pumpAndSettle(); + + expect(layer.showLightning.value, isTrue); + expect( + tester.widget(find.byType(MapChipButton)).active, + isTrue, + ); }); testWidgets('tapping the terrain-relief row reports the flip upward', ( tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); final terrain = ValueNotifier(true); final flipped = []; await tester.pumpWidget( @@ -109,7 +140,7 @@ void main() { testWidgets('tapping the national-border row turns it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -127,7 +158,7 @@ void main() { testWidgets('tapping the coverage row turns it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -144,7 +175,7 @@ void main() { testWidgets('tapping the county row turns it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -160,7 +191,7 @@ void main() { testWidgets('tapping the township row turns only it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -177,7 +208,7 @@ void main() { tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); final labels = ValueNotifier(true); final flipped = []; await tester.pumpWidget( @@ -203,7 +234,7 @@ void main() { tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); + final layer = testRadarLayer(_FakeRadarRepository()); await tester.pumpWidget(_wrap(layer)); // Both overlays ship on, so at rest the chip is unmarked. diff --git a/test/features/map/raster_source_maxzoom_test.dart b/test/features/map/raster_source_maxzoom_test.dart index 747024522..5b6276392 100644 --- a/test/features/map/raster_source_maxzoom_test.dart +++ b/test/features/map/raster_source_maxzoom_test.dart @@ -7,7 +7,6 @@ /// source declares is what the mounted raster source carries as `maxzoom`. library; -import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -32,7 +31,7 @@ void main() { final source = _CappedRadarRepository(_ids(9)) ..sourceMinZoom = 3 ..sourceMaxZoom = 8; - final layer = RadarMapLayer(source, testReferenceOutline()); + final layer = testRadarLayer(source); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index 85456e280..dcb16f630 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -6,6 +6,10 @@ import 'dart:math' show Point; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; +import 'package:dpip/features/weather/domain/lightning_snapshot.dart'; +import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; +import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/shared/map/map_style.dart' show countyFillLayerId, @@ -23,6 +27,36 @@ import 'package:maplibre_gl/maplibre_gl.dart'; MapReferenceOutlineController testReferenceOutline() => MapReferenceOutlineController(SettingsStore.inMemory({})); +/// A [RadarMapLayer] wired for a test: a fresh reference-outline controller, a +/// fresh settings store, and a lightning repository that answers "no snapshots" +/// so the strike overlay (off by default) stays out of every assertion about +/// the echo. A test that is *about* the lightning overlay passes its own. +RadarMapLayer testRadarLayer( + RadarRepository source, { + MapReferenceOutlineController? referenceOutline, + MeteorLightningRepository? lightning, + SettingsStore? settings, +}) => RadarMapLayer( + source, + referenceOutline ?? testReferenceOutline(), + lightning: lightning ?? EmptyLightningRepository(), + settings: settings ?? SettingsStore.inMemory({}), +); + +/// A lightning repository with nothing in it — the default for radar tests. +class EmptyLightningRepository implements MeteorLightningRepository { + @override + Future>> history() async => const Ok([]); + + @override + Future> latest() async => + const Ok(LightningSnapshot(time: 0, strikes: [])); + + @override + Future> at(int second) async => + Ok(LightningSnapshot(time: second, strikes: const [])); +} + /// A [RasterFrameSource] that records the tile-memory calls a layer makes. /// /// Those calls are the contract that keeps a scrub cheap — which frames were @@ -170,6 +204,15 @@ class RecordingMapController implements MapLibreMapController { calls.add('addSource:$sourceId'); } + @override + Future setGeoJsonSource( + String sourceId, + Map geojson, + ) async { + sourceData[sourceId] = geojson; + calls.add('setGeoJsonSource:$sourceId'); + } + /// `raster-opacity-transition` each layer was mounted with, by layer id. final Map mountTransitions = {}; From 5e3540827f19cd0f6ec1a7bd1f60bfb5f498fd79 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 10 Sep 2026 10:13:17 +0800 Subject: [PATCH 2/4] fix(eew): stop the earthquake replay running on behind another tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正地震重播切到其他分頁後仍繼續抓取資料,要切回資料頁才會停 Fix(en-US): the earthquake replay now stops fetching when you leave the tab, instead of running on until you come back --- .../pages/report_replay_page.dart | 377 ++++++++++-------- lib/shared/navigation/refresh_on_appear.dart | 95 +++++ .../navigation/active_while_visible_test.dart | 167 ++++++++ 3 files changed, 466 insertions(+), 173 deletions(-) create mode 100644 test/shared/navigation/active_while_visible_test.dart diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 53e8c96b4..70d11f02c 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -37,7 +37,10 @@ import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; import 'package:dpip/features/earthquake/presentation/eew_realtime_controller.dart'; import 'package:dpip/features/earthquake/presentation/rts_realtime_controller.dart'; +import 'package:dpip/features/earthquake/presentation/pages/report_list_page.dart' + show ReportListPage; import 'package:dpip/features/earthquake/presentation/widgets/eew_card.dart'; +import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; import 'package:dpip/features/earthquake/replay_session.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -108,21 +111,32 @@ class _ReportReplayPageState extends State { cwaOnly: () => context.read().enabled, )..start(); _startTicker(); - // The session's channels live outside RealtimeService (a replay must not - // look like a live feed), so its lifecycle pause never reaches them — - // this page pauses its own polling and its 5 Hz UI tick itself, or a - // backgrounded replay keeps two polls a second running indefinitely. - _lifecycle = AppLifecycleListener( - onPause: () { - _ticker?.cancel(); - _ticker = null; - _session.pause(); - }, - onResume: () { - _startTicker(); - _session.resume(); - }, - ); + } + + /// Runs or idles the polling and the UI tick to match whether anyone is + /// actually looking — the [ActiveWhileVisible] around the body decides that. + /// + /// Both sides are idempotent (the channels' `resume`/`pause` and + /// [_startTicker] tolerate being called when already in that state), so this + /// can be driven from either the foreground or the on-screen signal without + /// tracking which one moved. + /// + /// The session's channels live outside `RealtimeService` — a replay must not + /// look like a live feed — so the service's own lifecycle pause never reaches + /// them and this page has to stop them itself. Waiting for [dispose] is not + /// enough: `MainShell` pops this route when the user leaves the data tab, but + /// go_router freezes the exit transition the moment the branch deactivates, + /// so `dispose` does not run until the user comes *back*. Until then the + /// replay would keep polling twice a second for a page nobody can see. + void _applyActivity(bool active) { + if (active) { + _startTicker(); + _session.resume(); + } else { + _ticker?.cancel(); + _ticker = null; + _session.pause(); + } } void _startTicker() { @@ -132,11 +146,8 @@ class _ReportReplayPageState extends State { ); } - late final AppLifecycleListener _lifecycle; - @override void dispose() { - _lifecycle.dispose(); _ticker?.cancel(); _tick.dispose(); _session.dispose(); @@ -145,111 +156,115 @@ class _ReportReplayPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: _ReplayMap( - stationRepository: context.read(), - travelTimeTable: context.read>(), - boxGrid: context.read>(), - rts: _session.rts, - eew: _session.eew, - tick: _tick, - clock: _session.clock, - eewIndex: _eewIndex, + return ActiveWhileVisible( + tabIndex: ReportListPage.tabIndex, + onActiveChanged: _applyActivity, + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: _ReplayMap( + stationRepository: context.read(), + travelTimeTable: context.read>(), + boxGrid: context.read>(), + rts: _session.rts, + eew: _session.eew, + tick: _tick, + clock: _session.clock, + eewIndex: _eewIndex, + ), ), - ), - Positioned( - top: 0, - left: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FrostedSurface( - borderRadius: AppRadius.large, - child: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), + Positioned( + top: 0, + left: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FrostedSurface( + borderRadius: AppRadius.large, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), ), - ), - const SizedBox(height: AppSpacing.md), - // The replay surface is the 強震監視器 frozen in time — - // the same intensity legend the live monitor carries, - // switching to the EEW felt-scale while an alert is up - // (the legacy monitor did exactly this on active EEW). - ListenableBuilder( - listenable: _session.eew, - builder: (context, _) { - final hasEew = _session.eew.alerts.isNotEmpty; - return MapLegendCard( - child: IntensityLegend( - mode: hasEew - ? IntensityLegendMode.eew - : IntensityLegendMode.rts, - ), - ); - }, - ), - ], + const SizedBox(height: AppSpacing.md), + // The replay surface is the 強震監視器 frozen in time — + // the same intensity legend the live monitor carries, + // switching to the EEW felt-scale while an alert is up + // (the legacy monitor did exactly this on active EEW). + ListenableBuilder( + listenable: _session.eew, + builder: (context, _) { + final hasEew = _session.eew.alerts.isNotEmpty; + return MapLegendCard( + child: IntensityLegend( + mode: hasEew + ? IntensityLegendMode.eew + : IntensityLegendMode.rts, + ), + ); + }, + ), + ], + ), ), ), ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListenableBuilder( - listenable: _session.eew, - builder: (context, _) { - final alerts = _session.eew.alerts; - if (alerts.isEmpty) return const SizedBox.shrink(); - // One card at a time — tapping cycles through the - // active alerts (parallel earthquakes, overlapping - // reports) instead of stacking every one on screen. - // The index is clamped by modulo, so a report leaving - // the active set mid-replay can't point past the list. - final index = _eewIndex % alerts.length; - final eew = alerts[index]; - return _EewAlertCard( - eew: eew, - clock: () => _session.clock.now(), - position: index + 1, - count: alerts.length, - onTap: alerts.length > 1 - ? () => setState( - () => _eewIndex = - (_eewIndex + 1) % alerts.length, - ) - : null, - ); - }, - ), - const SizedBox(height: AppSpacing.sm), - _ReplayStatusBar( - clock: _session.clock, - tick: _tick, - rts: _session.rts, - eew: _session.eew, - ), - ], + Positioned( + bottom: 0, + left: 0, + right: 0, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListenableBuilder( + listenable: _session.eew, + builder: (context, _) { + final alerts = _session.eew.alerts; + if (alerts.isEmpty) return const SizedBox.shrink(); + // One card at a time — tapping cycles through the + // active alerts (parallel earthquakes, overlapping + // reports) instead of stacking every one on screen. + // The index is clamped by modulo, so a report leaving + // the active set mid-replay can't point past the list. + final index = _eewIndex % alerts.length; + final eew = alerts[index]; + return _EewAlertCard( + eew: eew, + clock: () => _session.clock.now(), + position: index + 1, + count: alerts.length, + onTap: alerts.length > 1 + ? () => setState( + () => _eewIndex = + (_eewIndex + 1) % alerts.length, + ) + : null, + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + _ReplayStatusBar( + clock: _session.clock, + tick: _tick, + rts: _session.rts, + eew: _session.eew, + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ); } @@ -461,28 +476,34 @@ class _ReplayMapState extends State<_ReplayMap> { }); } - /// Pauses the 1 Hz blink and the wave-front ticker while the app is - /// backgrounded — their platform writes would keep running under the lock - /// screen otherwise. Restarting on resume just resets the blink phase, - /// which is invisible. - late final AppLifecycleListener _blinkLifecycle = AppLifecycleListener( - onPause: () { - _blinkTimer?.cancel(); - _blinkTimer = null; - _wavefrontTicker?.cancel(); - _wavefrontTicker = null; - }, - onResume: () { + /// Whether anyone is looking, as reported by the [ActiveWhileVisible] around + /// the map. Kept because the style may finish loading while hidden, and + /// [_onStyleLoaded] must not start the timers behind the user's back. + bool _active = true; + + /// Runs or pauses the 1 Hz blink and the 60 Hz wave-front ticker. + /// + /// Neither is worth a frame while the page is off screen, and leaving the tab + /// does not dispose this route (see [_ReportReplayPageState._applyActivity]), + /// so without this they would keep writing to a native map surface the user + /// switched away from. Restarting resets the blink phase, which is invisible. + void _applyTickers(bool active) { + _active = active; + if (active) { if (_ready) { _setupBlink(); _startWavefrontTicker(); } - }, - ); + } else { + _blinkTimer?.cancel(); + _blinkTimer = null; + _wavefrontTicker?.cancel(); + _wavefrontTicker = null; + } + } @override void dispose() { - _blinkLifecycle.dispose(); widget.rts.removeListener(_onRts); widget.tick.removeListener(_onTick); _blinkTimer?.cancel(); @@ -806,8 +827,9 @@ class _ReplayMapState extends State<_ReplayMap> { unawaited(_updateRts()); unawaited(_updateBox()); unawaited(_updateEew()); - _setupBlink(); - _startWavefrontTicker(); + // Through [_applyTickers], so a style that finishes loading while the tab + // is already hidden does not start the two timers behind it. + _applyTickers(_active); _frameTaiwan(); // A style (re)load wipes every runtime overlay and resets the base // style's township-label layer to visible — re-assert the saved choices. @@ -1182,51 +1204,60 @@ class _ReplayMapState extends State<_ReplayMap> { @override Widget build(BuildContext context) { - return GsiOverlayScope( - controller: _gsi, - child: Stack( - children: [ - Positioned.fill( - child: BaseMap( - // GPS on: the map shows the user's position, and the EEW cards' - // local-intensity tiles resolve against the current location the - // same way the legacy monitor's did. - showUserLocation: true, - compassEnabled: false, - includeTerrainInStyle: !_initialOsmEnabled, - onMapCreated: _onMapCreated, - onStyleLoaded: () => unawaited(_onStyleLoaded()), - onCameraMove: (position) => _bearing.value = position.bearing, + return ActiveWhileVisible( + tabIndex: ReportListPage.tabIndex, + onActiveChanged: _applyTickers, + child: GsiOverlayScope( + controller: _gsi, + child: Stack( + children: [ + Positioned.fill( + child: BaseMap( + // GPS on: the map shows the user's position, and the EEW cards' + // local-intensity tiles resolve against the current location the + // same way the legacy monitor's did. + showUserLocation: true, + compassEnabled: false, + // This route is retained inside the data branch while another tab + // is selected, so the surface needs the branch index to know when + // to idle its native render loop — the same reason the + // [ActiveWhileVisible] above stops this page's timers. + tabIndex: ReportListPage.tabIndex, + includeTerrainInStyle: !_initialOsmEnabled, + onMapCreated: _onMapCreated, + onStyleLoaded: () => unawaited(_onStyleLoaded()), + onCameraMove: (position) => _bearing.value = position.bearing, + ), ), - ), - // Base-map options (OSM detailed map / terrain relief / township - // names) above the compass — the same chrome the 強震監視器 carries in - // the map tab, persisted to the shared settings store. No sheet - // here, so the controls stay up for the whole replay. - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - MapBasemapMenu( - showTownLabels: _showTownLabels, - onShowTownLabelsChanged: _setShowTownLabels, - showTerrain: _showTerrain, - onShowTerrainChanged: _setShowTerrain, - ), - const SizedBox(height: AppSpacing.sm), - MapCompass(bearing: _bearing, onPressed: _resetNorth), - ], + // Base-map options (OSM detailed map / terrain relief / township + // names) above the compass — the same chrome the 強震監視器 carries in + // the map tab, persisted to the shared settings store. No sheet + // here, so the controls stay up for the whole replay. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + MapBasemapMenu( + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, + ), + const SizedBox(height: AppSpacing.sm), + MapCompass(bearing: _bearing, onPressed: _resetNorth), + ], + ), ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/shared/navigation/refresh_on_appear.dart b/lib/shared/navigation/refresh_on_appear.dart index 0f14b0de5..4088192e1 100644 --- a/lib/shared/navigation/refresh_on_appear.dart +++ b/lib/shared/navigation/refresh_on_appear.dart @@ -161,6 +161,101 @@ class _RefreshOnAppearState extends State Widget build(BuildContext context) => widget.child; } +/// Reports when the page inside it is — and stops being — in front of the user, +/// so work that only makes sense on screen can be stopped while it is not. +/// +/// The sibling of [RefreshOnAppear] for the *other* half of the problem: that +/// one fetches on re-entry, this one idles on exit. The condition is both +/// halves of "someone is looking": the app is in the foreground **and** +/// [VisibleTab.isOnScreen] holds for [tabIndex] — so a covered shell counts as +/// hidden, unlike the tab-only test [RefreshOnAppear] deliberately uses. +/// +/// It exists because leaving is not observable from a page's own lifecycle. A +/// branch's pages stay mounted in the shell's `IndexedStack`, and even a route +/// that is being popped on the way out stops mid-transition: go_router wraps an +/// inactive branch in `TickerMode(enabled: false)`, so the exit animation +/// freezes and `dispose` does not run until the user returns to that tab. +/// Anything a page stops in `dispose` alone — a poll, a timer, a native render +/// loop — therefore keeps running behind the tab the user switched to. +/// +/// [onActiveChanged] fires once after the first frame with the current state, +/// then on every flip. Handlers should be idempotent. +class ActiveWhileVisible extends StatefulWidget { + const ActiveWhileVisible({ + super.key, + required this.tabIndex, + required this.onActiveChanged, + required this.child, + }); + + /// The shell branch this page belongs to, or null for a surface that belongs + /// to no branch (then only the foreground and shell-cover tests apply). + final int? tabIndex; + + /// Called with `true` when the page is in front of the user, `false` when it + /// is not. + final ValueChanged onActiveChanged; + + final Widget child; + + @override + State createState() => _ActiveWhileVisibleState(); +} + +class _ActiveWhileVisibleState extends State + with WidgetsBindingObserver { + VisibleTab? _visibleTab; + bool _foreground = true; + bool? _reported; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _report(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final visibleTab = VisibleTabScope.of(context); + if (visibleTab == _visibleTab) return; + _visibleTab?.removeListener(_report); + _visibleTab = visibleTab?..addListener(_report); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // `resumed` is the only state the app is actually usable in; everything + // else (inactive, hidden, paused, detached) is a reason to idle. + _foreground = state == AppLifecycleState.resumed; + _report(); + } + + /// Absent scope (a test, or a page hosted outside the shell) = on screen. + bool get _active => + _foreground && (_visibleTab?.isOnScreen(widget.tabIndex) ?? true); + + void _report() { + final active = _active; + if (active == _reported) return; + _reported = active; + widget.onActiveChanged(active); + } + + @override + void dispose() { + _visibleTab?.removeListener(_report); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + /// Hands the shell's [VisibleTab] down to the pages inside it. /// /// An [InheritedWidget] rather than a provider so a page can be pumped in a test diff --git a/test/shared/navigation/active_while_visible_test.dart b/test/shared/navigation/active_while_visible_test.dart new file mode 100644 index 000000000..cb86a7a2d --- /dev/null +++ b/test/shared/navigation/active_while_visible_test.dart @@ -0,0 +1,167 @@ +/// The gate that stops a page's work when the user is no longer looking at it. +/// +/// Worth its own test because the failure it prevents is invisible: a page that +/// keeps polling behind another tab looks exactly like one that stopped, and +/// the reason `dispose` cannot be relied on (go_router freezing an inactive +/// branch's exit transition) is not reproducible in a unit test of the page. +library; + +import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps the gate under [visibleTab] and returns every state it reported. +Future> _pump( + WidgetTester tester, { + required VisibleTab visibleTab, + int? tabIndex = 2, +}) async { + final reported = []; + await tester.pumpWidget( + MaterialApp( + home: VisibleTabScope( + visibleTab: visibleTab, + child: ActiveWhileVisible( + tabIndex: tabIndex, + onActiveChanged: reported.add, + child: const SizedBox(), + ), + ), + ), + ); + // The first report is deferred to a post-frame callback, so the page has a + // built subtree to act on by the time it hears anything. + await tester.pump(); + return reported; +} + +void main() { + testWidgets('a page born on its own tab is active', (tester) async { + final reported = await _pump(tester, visibleTab: VisibleTab(2)); + expect(reported, [true]); + }); + + testWidgets('a page born behind another tab is told so, not left running', ( + tester, + ) async { + // The case the replay page hit: it is built while its branch is selected, + // but a page can also be restored into a hidden branch — either way the + // first thing it hears must be the truth, not a default. + final reported = await _pump(tester, visibleTab: VisibleTab(0)); + expect(reported, [false]); + }); + + testWidgets('leaving the tab idles it and coming back wakes it', ( + tester, + ) async { + final visibleTab = VisibleTab(2); + final reported = await _pump(tester, visibleTab: visibleTab); + + visibleTab.value = 0; + await tester.pump(); + expect(reported, [true, false], reason: 'switched away → idle'); + + visibleTab.value = 2; + await tester.pump(); + expect(reported, [true, false, true]); + }); + + testWidgets('a full-screen route over the shell counts as hidden', ( + tester, + ) async { + // The tab index never changes when a page is pushed over the whole shell, + // so a gate that only compared indices would keep the work running under + // an opaque page. + final visibleTab = VisibleTab(2); + final reported = await _pump(tester, visibleTab: visibleTab); + + visibleTab.shellOnTop = false; + await tester.pump(); + expect(reported, [true, false]); + + visibleTab.shellOnTop = true; + await tester.pump(); + expect(reported, [true, false, true]); + }); + + testWidgets('backgrounding the app idles a page on the visible tab', ( + tester, + ) async { + final visibleTab = VisibleTab(2); + final reported = await _pump(tester, visibleTab: visibleTab); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(reported, [true, false]); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(reported, [true, false, true]); + }); + + testWidgets('returning to the foreground on a hidden tab stays idle', ( + tester, + ) async { + // Both halves have to hold. Resuming the app while some *other* tab is on + // screen must not restart a page the user still cannot see. + final visibleTab = VisibleTab(0); + final reported = await _pump(tester, visibleTab: visibleTab); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(reported, [false], reason: 'never active, so never reported again'); + }); + + testWidgets('a page hosted without a shell scope is always active', ( + tester, + ) async { + // A test, a preview, or a page opened outside the shell has no tab to be + // hidden behind — it must not gate itself off and do nothing. + final reported = []; + await tester.pumpWidget( + MaterialApp( + home: ActiveWhileVisible( + tabIndex: 2, + onActiveChanged: reported.add, + child: const SizedBox(), + ), + ), + ); + await tester.pump(); + expect(reported, [true]); + }); + + testWidgets('a page belonging to no branch only follows the shell', ( + tester, + ) async { + final visibleTab = VisibleTab(0); + final reported = await _pump( + tester, + visibleTab: visibleTab, + tabIndex: null, + ); + expect(reported, [ + true, + ], reason: 'no branch → the tab index is not its cue'); + + visibleTab.shellOnTop = false; + await tester.pump(); + expect(reported, [true, false]); + }); + + testWidgets('the listener is dropped when the page goes away', ( + tester, + ) async { + final visibleTab = VisibleTab(2); + final reported = await _pump(tester, visibleTab: visibleTab); + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + visibleTab.value = 0; + await tester.pump(); + + expect(reported, [true], reason: 'a disposed gate reports nothing'); + }); +} From 037c0a86857e9347123f1ed52968347ed65bcbfb Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 10 Sep 2026 10:15:51 +0800 Subject: [PATCH 3/4] fix(eew): stop the replay reading its page context after the page closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正離開地震重播時,若剛好有一次抓取還在進行會記錄一筆錯誤 Fix(en-US): leaving the earthquake replay while a fetch is in flight no longer logs an error --- .../presentation/pages/report_replay_page.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 70d11f02c..94a469fae 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -104,11 +104,18 @@ class _ReportReplayPageState extends State { @override void initState() { super.initState(); + // The notifier itself, not `context`: this closure is called from inside a + // fetch, which can still be in flight when the page unmounts — and a + // `context.read` there throws "State no longer has a context". Capturing + // the app-scoped instance keeps the point of the closure (each fetch reads + // the setting *fresh*, so toggling it mid-replay takes effect on the next + // poll) without outliving anything. + final cwaOnly = context.read(); _session = ReplaySession( context.read(), context.read().clock, widget.replayTimestamp, - cwaOnly: () => context.read().enabled, + cwaOnly: () => cwaOnly.enabled, )..start(); _startTicker(); } From e395164481091704a32027b3729add0239165a37 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 10 Sep 2026 10:15:51 +0800 Subject: [PATCH 4/4] fix(map): keep an off-screen map from being rebuilt as if it were stuck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正離開含地圖的頁面時會記錄一筆錯誤,並且白白重建一次地圖 Fix(en-US): leaving a page with a map no longer logs an error and rebuilds the map for nothing --- lib/shared/map/base_map.dart | 30 ++++++++++++ test/shared/map/base_map_tab_test.dart | 64 ++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/lib/shared/map/base_map.dart b/lib/shared/map/base_map.dart index 1d62038e6..231d8d3b8 100644 --- a/lib/shared/map/base_map.dart +++ b/lib/shared/map/base_map.dart @@ -9,6 +9,7 @@ import 'package:dpip/shared/map/map_trace.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; @@ -333,6 +334,7 @@ class _BaseMapState extends State with WidgetsBindingObserver { 'render-sync native-error pause=$pause ' 'dt=${started.elapsedMilliseconds}ms error=$error', ); + if (_isDetachedSurface(error)) return; Log.handle(error, stackTrace, 'map render pause'); if (!pause && mounted && @@ -347,6 +349,34 @@ class _BaseMapState extends State with WidgetsBindingObserver { ); } + /// Whether a failed resume merely means the native view is not in the view + /// hierarchy — in which case it is not a stuck renderer and there is nothing + /// to recover. + /// + /// The fork's watchdog resolves `map#resume` only once MapLibre confirms a + /// rendered frame, and reports `window: false` when the `MLNMapView` has no + /// window. Such a view *cannot* draw, so "no frame" describes where the + /// surface is, not the state of its renderer. + /// + /// It happens on the ordinary way out of a nested route. `MainShell` pops a + /// page when its tab is left, but go_router freezes an inactive branch's exit + /// transition, so the page is still mounted — detached — when the user + /// returns and the shell resumes every surface the branch owns. Treating that + /// as a fault used to throw the platform view away and rebuild it (a style + /// reload and every layer remounted) for a map already on its way off screen, + /// and log an exception the user could do nothing about. + bool _isDetachedSurface(Object error) { + if (error is! PlatformException || error.code != 'map_resume_timeout') { + return false; + } + final details = error.details; + final detached = details is Map && details['window'] == false; + if (detached) { + _trace(() => 'render-sync resume ignored: surface is not in a window'); + } + return detached; + } + void _onTabChanged() { _trace( () => diff --git a/test/shared/map/base_map_tab_test.dart b/test/shared/map/base_map_tab_test.dart index 9f68c56d2..081bd394f 100644 --- a/test/shared/map/base_map_tab_test.dart +++ b/test/shared/map/base_map_tab_test.dart @@ -4,6 +4,7 @@ import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_test/flutter_test.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; @@ -16,7 +17,10 @@ import 'package:provider/provider.dart'; class _FakePlatform extends MapLibrePlatform { final paused = []; int buildCount = 0; - bool failResume = false; + + /// Thrown by the next resume, standing in for whatever the native side + /// reports back through the method channel. + Object? resumeError; @override Future initPlatform(int id) async {} @@ -34,9 +38,8 @@ class _FakePlatform extends MapLibrePlatform { @override Future setRenderPaused(bool paused) async { this.paused.add(paused); - if (!paused && failResume) { - throw StateError('native renderer did not resume'); - } + final error = resumeError; + if (!paused && error != null) throw error; } @override @@ -134,7 +137,7 @@ void main() { final visibleTab = VisibleTab(2); final created = []; final invalidated = []; - platform.failResume = true; + platform.resumeError = StateError('native renderer did not resume'); await pump( tester, VisibleTabScope( @@ -166,6 +169,57 @@ void main() { } }); + testWidgets('a surface with no window is left alone, not rebuilt', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final visibleTab = VisibleTab(2); + final created = []; + final invalidated = []; + // What the fork reports for a view that is not in the hierarchy: it + // cannot render, so the watchdog times out waiting for a frame. A route + // whose exit transition go_router froze is in exactly this state when its + // branch is selected again. + platform.resumeError = PlatformException( + code: 'map_resume_timeout', + message: 'MapLibre did not render a frame after resume.', + details: const { + 'window': false, + 'hidden': false, + 'frame': '{{0, 0}, {402, 874}}', + }, + ); + await pump( + tester, + VisibleTabScope( + visibleTab: visibleTab, + child: BaseMap( + tabIndex: 2, + recreateOnReturn: true, + onMapCreated: created.add, + onMapInvalidated: invalidated.add, + ), + ), + ); + + visibleTab.value = 0; + await tester.pump(); + visibleTab.value = 2; + await tester.pump(); + await tester.pump(); + + expect(platform.paused, [true, false], reason: 'the resume still ran'); + // Rebuilding costs a style reload and every layer remount, on a map that + // is off screen precisely because it is on its way out. + expect(platform.buildCount, 1); + expect(invalidated, isEmpty); + expect(created, hasLength(1)); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('Android keeps pause-resume when recreation is requested', ( tester, ) async {