From 2908b67089c91ebde9e6fca1465b543855194cc5 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:33:43 +0800 Subject: [PATCH 1/6] fix(eew): show a cancelled alert as cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 地震速報被發布單位取消後會標示「已取消」,不再倒數、預估所在地震度、畫震波圈或語音播報 Fix(en-US): a cancelled earthquake alert is marked as cancelled instead of still counting down, estimating local shaking, drawing wave rings and being read aloud --- lib/features/earthquake/domain/eew.dart | 71 +++++++++++ .../monitor_eew_announcement_controller.dart | 51 ++++++-- .../pages/report_replay_page.dart | 27 +++-- .../presentation/widgets/eew_card.dart | 36 ++++-- .../widgets/home_eew_section.dart | 47 ++++++-- .../widgets/home_monitor_banner.dart | 28 ++++- .../map/presentation/layers/rts_layer.dart | 36 +++--- .../widgets/monitor_eew_card.dart | 31 +++-- .../widgets/rts_monitor_panel.dart | 8 +- lib/l10n/app_en.arb | 8 ++ lib/l10n/app_fil.arb | 4 +- lib/l10n/app_id.arb | 4 +- lib/l10n/app_ja.arb | 4 +- lib/l10n/app_ko.arb | 4 +- lib/l10n/app_th.arb | 4 +- lib/l10n/app_vi.arb | 4 +- lib/l10n/app_yue.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/l10n/app_zh_Hans.arb | 4 +- lib/l10n/app_zh_Hant_HK.arb | 4 +- lib/l10n/app_zh_TW.arb | 4 +- lib/l10n/gen/app_localizations.dart | 12 ++ lib/l10n/gen/app_localizations_en.dart | 6 + lib/l10n/gen/app_localizations_fil.dart | 7 ++ lib/l10n/gen/app_localizations_id.dart | 7 ++ lib/l10n/gen/app_localizations_ja.dart | 6 + lib/l10n/gen/app_localizations_ko.dart | 6 + lib/l10n/gen/app_localizations_th.dart | 7 ++ lib/l10n/gen/app_localizations_vi.dart | 6 + lib/l10n/gen/app_localizations_yue.dart | 6 + lib/l10n/gen/app_localizations_zh.dart | 24 ++++ lib/shared/widgets/eew_cancelled_notice.dart | 86 ++++++++++++++ ...itor_eew_announcement_controller_test.dart | 112 +++++++++++++++++- test/features/earthquake/eew_test.dart | 67 +++++++++++ .../presentation/widgets/eew_card_test.dart | 19 +++ .../widgets/home_eew_section_test.dart | 63 ++++++++++ .../widgets/home_monitor_banner_test.dart | 36 ++++++ .../layers/rts_layer_eew_push_test.dart | 27 +++++ .../layers/rts_layer_eew_test.dart | 25 ++++ .../widgets/monitor_eew_card_test.dart | 25 ++++ .../widgets/rts_monitor_panel_eew_test.dart | 29 +++++ 41 files changed, 878 insertions(+), 85 deletions(-) create mode 100644 lib/shared/widgets/eew_cancelled_notice.dart diff --git a/lib/features/earthquake/domain/eew.dart b/lib/features/earthquake/domain/eew.dart index 0b59a4a2f..990c7b884 100644 --- a/lib/features/earthquake/domain/eew.dart +++ b/lib/features/earthquake/domain/eew.dart @@ -38,6 +38,77 @@ extension EewAgencyX on Eew { bool get isJma => agency.toLowerCase() == 'jma'; } +/// Which earthquake a report belongs to. +extension EewIdentityX on Eew { + /// One event across all its reports: the publishing agency plus its own + /// event id — the same pair the server keys its list on, since two agencies + /// number their events independently. The agency is lower-cased because + /// nothing guarantees one feed spells it the same way every time. + String get eventKey => '${agency.toLowerCase()}:$id'; +} + +/// What a report is, read from [Eew.status]. +enum EewReportKind { + /// 預警 — an ordinary report (`0`, and any code this app does not know). + forecast, + + /// 警報 — the agency's public warning (`1`). + warning, + + /// 取消 — the agency withdrew the event: it was not an earthquake, or not + /// the one the earlier reports described (`2` or `3`). + cancelled, +} + +/// The meaning of the wire `status` integer. +/// +/// The public JSON (`/api/v2/eq/eew`, GET and `?sse=1`) carries the +/// publishing agency's own code through untouched — the edge decodes it as a +/// single byte of the binary NATS message and copies it into the JSON — so +/// the cancel code depends on who encoded it: +/// +/// | code | meaning | where it comes from | +/// |---|---|---| +/// | `0` | 預警 | every agency | +/// | `1` | 警報 | CWA's public warning | +/// | `2` | 取消 | the event server's own model (`0 normal, 1 alert, 2 cancel`), which is how the `nied` feed's cancel arrives | +/// | `3` | 取消 | the CWA feed, and what every ExpTech client keys its cancel styling on | +/// +/// No feed uses `2` or `3` for anything else, so both read as a cancel. +/// [Eew.isFinal] (`final`) is separate: `1` marks the agency's last report +/// for the event, which says nothing about whether it was cancelled. +/// +/// An unknown code reads as [EewReportKind.forecast], never as a cancel: a +/// misread code must leave an alert on screen, not hide one. +extension EewStatusX on Eew { + EewReportKind get kind => switch (status) { + 1 => EewReportKind.warning, + 2 || 3 => EewReportKind.cancelled, + _ => EewReportKind.forecast, + }; + + /// Whether the agency withdrew this event. A cancelled report must never + /// drive an alert: no countdown, no local estimate, no wave rings, no + /// speech — only a "cancelled" notice until it leaves the list. + bool get isCancelled => kind == EewReportKind.cancelled; + + /// Whether this is the agency's public 警報 rather than a 預警. + bool get isWarning => kind == EewReportKind.warning; +} + +/// The cancel filter every consumer of an alert list needs. +extension EewListX on List { + /// The alerts that still describe an earthquake in progress — everything + /// but the cancelled ones, in their original order. + List get uncancelled => [ + for (final alert in this) + if (!alert.isCancelled) alert, + ]; + + /// Whether any alert still describes an earthquake in progress. + bool get hasUncancelled => any((alert) => !alert.isCancelled); +} + /// The earthquake parameters carried by an [Eew]. @freezed abstract class EewInfo with _$EewInfo { diff --git a/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart b/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart index ed37f2555..fa2fde7a3 100644 --- a/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart +++ b/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart @@ -45,9 +45,14 @@ class MonitorEewAnnouncementController { final Future Function(Eew alert) _estimate; final Duration speechTimeout; + /// The highest serial announced per event ([EewIdentityX.eventKey]). final Map _seenSerials = {}; bool _active = false; bool _hasCurrentAlert = false; + + /// The event the current (or last) phrase was about — so a cancel that + /// lands for it mid-phrase can cut it off even while another alert stays up. + String? _spokenKey; int _generation = 0; /// Activates announcements only for the foreground, visible monitor. @@ -58,35 +63,47 @@ class MonitorEewAnnouncementController { _gate.setActive(value); if (!value) { _hasCurrentAlert = false; + _spokenKey = null; unawaited(_speech.stop()); } } /// Consumes a feed snapshot. Stale/offline/calm snapshots stop speech; live /// duplicates and older serials are ignored. + /// + /// A cancelled report is never announced, and a snapshot whose alerts are + /// all cancelled counts as calm: an intensity read out for an event the + /// agency has just withdrawn is the one thing worse than silence, so a + /// phrase already in flight for it is cut off. void update( RealtimeState> state, { required String languageTag, required EewSpeechFormatter format, }) { if (!_active) return; - final alerts = state.data; - if (state.status != RealtimeStatus.live || - alerts == null || - alerts.isEmpty) { - if (!_hasCurrentAlert) return; - _hasCurrentAlert = false; - _generation++; - _gate.cancelAnnouncement(); - unawaited(_speech.stop()); + final all = state.data; + if (state.status != RealtimeStatus.live || all == null) { + _silence(); return; } + final alerts = all.uncancelled; + if (alerts.isEmpty) { + _silence(); + return; + } + final spoken = _spokenKey; + if (spoken != null && + all.any((alert) => alert.isCancelled && alert.eventKey == spoken)) { + _silence(); + } final alert = alerts.first; - final previous = _seenSerials[alert.id]; + final key = alert.eventKey; + final previous = _seenSerials[key]; if (previous != null && alert.serial <= previous) return; - _seenSerials[alert.id] = alert.serial; + _seenSerials[key] = alert.serial; _hasCurrentAlert = true; + _spokenKey = key; final generation = ++_generation; final gateGeneration = _gate.beginAnnouncement(); @@ -95,6 +112,17 @@ class MonitorEewAnnouncementController { ); } + /// Stops whatever is being said and releases the warning sound the gate was + /// holding for it — a calm feed, an aged one, or a withdrawn report. + void _silence() { + if (!_hasCurrentAlert) return; + _hasCurrentAlert = false; + _spokenKey = null; + _generation++; + _gate.cancelAnnouncement(); + unawaited(_speech.stop()); + } + Future _announce( Eew alert, int generation, @@ -126,6 +154,7 @@ class MonitorEewAnnouncementController { void dispose() { _active = false; _hasCurrentAlert = false; + _spokenKey = null; _generation++; _gate.setActive(false); unawaited(_speech.stop()); diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 77a2320dc..0178ada3b 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -332,7 +332,8 @@ class _ReportReplayPageState extends State { legend: ListenableBuilder( listenable: _session.eew, builder: (context, _) { - final hasEew = _session.eew.alerts.isNotEmpty; + final hasEew = + _session.eew.alerts.hasUncancelled; return MapLegendCard( child: IntensityLegend( mode: hasEew @@ -625,7 +626,9 @@ class _ReplayMapState extends State<_ReplayMap> { /// Whether [_updateEew] has anything to do: an alert to draw, or rings and /// a fill still on the map to clear. - bool get _wavefrontWanted => widget.eew.alerts.isNotEmpty || !_eewSourceEmpty; + /// A cancelled alert has no wavefront, so it alone does not want one. + bool get _wavefrontWanted => + widget.eew.alerts.hasUncancelled || !_eewSourceEmpty; /// Starts the wavefront ticker when the visible map has an alert to draw. /// It stops itself on the first tick with nothing left to draw or clear — @@ -666,7 +669,7 @@ class _ReplayMapState extends State<_ReplayMap> { await controller.setLayerVisibility(_ids.boxLine, false); } - final hasEew = widget.eew.alerts.isNotEmpty; + final hasEew = widget.eew.alerts.hasUncancelled; if (hasEew) { _epicenterVisible = !_epicenterVisible; await controller.setLayerVisibility( @@ -827,7 +830,7 @@ class _ReplayMapState extends State<_ReplayMap> { void _onEewChange() { _syncWavefrontTicker(); - if (widget.eew.alerts.isNotEmpty != _dotsDrawnForEew) { + if (widget.eew.alerts.hasUncancelled != _dotsDrawnForEew) { unawaited(_updateRts()); } } @@ -918,7 +921,9 @@ class _ReplayMapState extends State<_ReplayMap> { if (_eewUpdating) return; _eewUpdating = true; try { - final empty = widget.eew.alerts.isEmpty; + // A cancelled alert draws nothing (see [_eewGeoJson]), so a set of only + // cancelled alerts clears the overlay the way an empty one does. + final empty = !widget.eew.alerts.hasUncancelled; // Nothing to draw and nothing drawn — skip the per-tick round trip. if (empty && _eewSourceEmpty) return; try { @@ -956,9 +961,11 @@ class _ReplayMapState extends State<_ReplayMap> { /// churn isn't, so a repeat is skipped. Future _updateAreaFill(MapLibreMapController controller) async { final alerts = widget.eew.alerts; - final selected = alerts.isEmpty + final shown = alerts.isEmpty ? null : alerts[widget.eewIndex % alerts.length]; + // The card is showing a cancelled alert: there is no shaking to tint. + final selected = shown == null || shown.isCancelled ? null : shown; final model = _mlIntensity; // The model arriving mid-replay repaints the same serial from its levels. final key = selected == null @@ -1035,7 +1042,7 @@ class _ReplayMapState extends State<_ReplayMap> { Map _rtsGeoJson() { final eventLit = widget.alerts.alerting; - final eewActive = widget.eew.alerts.isNotEmpty; + final eewActive = widget.eew.alerts.hasUncancelled; _dotsDrawnForEew = eewActive; final dark = Theme.of(context).brightness == Brightness.dark; final features = >[]; @@ -1136,7 +1143,7 @@ class _ReplayMapState extends State<_ReplayMap> { /// radius does not depend on the box. List<_SWaveCover> _sWaveCovers(SeismicTravelTimeTable table, DateTime now) { final covers = <_SWaveCover>[]; - for (final eew in widget.eew.alerts) { + for (final eew in widget.eew.alerts.uncancelled) { final info = eew.info; final elapsed = now.difference( DateTime.fromMillisecondsSinceEpoch(info.time, isUtc: true), @@ -1194,7 +1201,9 @@ class _ReplayMapState extends State<_ReplayMap> { final table = _travelTimeTable; final now = widget.clock.now(); final features = >[]; - for (final eew in widget.eew.alerts) { + // A cancelled alert draws nothing: its rings would show a wave the agency + // has just said is not coming. + for (final eew in widget.eew.alerts.uncancelled) { final info = eew.info; final center = info.latlng; final elapsed = now.difference( diff --git a/lib/features/earthquake/presentation/widgets/eew_card.dart b/lib/features/earthquake/presentation/widgets/eew_card.dart index d1451f5ec..73329bcaa 100644 --- a/lib/features/earthquake/presentation/widgets/eew_card.dart +++ b/lib/features/earthquake/presentation/widgets/eew_card.dart @@ -16,6 +16,10 @@ /// the hazard overlays use; the countdown ticks against the calibrated /// [AppTime] clock (or the caller's own — the replay page passes its /// [ReplayClock]), so the seconds are right even if the device clock drifts. +/// +/// A cancelled report keeps its header and epicentre line but trades the +/// intensity badge for a 已取消 tag and the estimate tiles for a notice — a +/// withdrawn event has no shaking to estimate and nothing to count down to. library; import 'dart:async'; @@ -34,6 +38,7 @@ import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; +import 'package:dpip/shared/widgets/eew_cancelled_notice.dart'; import 'package:dpip/shared/widgets/eew_estimate_tile.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:flutter/material.dart'; @@ -145,7 +150,10 @@ class _EewCardContentState extends State with SecondTicker { ? null : context.read().byCode(code); final observer = _fix ?? (town == null ? null : LatLng(town.lat, town.lng)); - final estimate = observer == null + // A cancelled event has no shaking to estimate and no wave to count down + // to — the card says it was cancelled instead. + final cancelled = widget.eew.isCancelled; + final estimate = observer == null || cancelled ? null : estimateLocalShaking(widget.eew, observer, table: _table); @@ -166,12 +174,16 @@ class _EewCardContentState extends State with SecondTicker { children: [ Row( children: [ - Icon(Icons.warning_amber_rounded, size: 15, color: colors.error), + Icon( + cancelled ? Icons.cancel_outlined : Icons.warning_amber_rounded, + size: 15, + color: cancelled ? colors.onSurfaceVariant : colors.error, + ), const SizedBox(width: 4), Text( 'EEW', style: theme.textTheme.labelSmall?.copyWith( - color: colors.error, + color: cancelled ? colors.onSurfaceVariant : colors.error, fontWeight: FontWeight.w800, letterSpacing: 0.4, ), @@ -202,11 +214,14 @@ class _EewCardContentState extends State with SecondTicker { widget.trailing!, const SizedBox(width: AppSpacing.sm), ], - IntensityBadge( - label: maxIntensity.label, - color: IntensityColors.discrete(maxIntensity.colorLevel), - size: 32, - ), + if (cancelled) + const EewCancelledTag() + else + IntensityBadge( + label: maxIntensity.label, + color: IntensityColors.discrete(maxIntensity.colorLevel), + size: 32, + ), ], ), const SizedBox(height: AppSpacing.sm), @@ -235,7 +250,10 @@ class _EewCardContentState extends State with SecondTicker { ), ], ), - if (estimate != null) ...[ + if (cancelled) ...[ + const SizedBox(height: AppSpacing.sm), + const EewCancelledNotice(), + ] else if (estimate != null) ...[ const SizedBox(height: AppSpacing.sm), Row( children: [ diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index f25184938..ad53ab016 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -12,6 +12,10 @@ /// aged out must never present its last snapshot as now. Calm state (no alert, /// or an unverified feed) renders nothing at all, so the sheet's ordinary layout /// is untouched outside an earthquake. +/// +/// A cancelled alert is shown as cancelled — a neutral card with no severity +/// border, no local estimate and no countdown — and only while nothing still +/// in progress outranks it. library; import 'dart:async'; @@ -33,6 +37,7 @@ import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; +import 'package:dpip/shared/widgets/eew_cancelled_notice.dart'; import 'package:dpip/shared/widgets/eew_estimate_tile.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:flutter/material.dart'; @@ -74,7 +79,13 @@ class HomeEewSection extends StatelessWidget { alerts.isEmpty) { return const SizedBox.shrink(); } - return _EewSectionBody(alert: alerts.first); + // An alert still in progress outranks a cancelled one; a cancelled card + // shows only when nothing else is left to warn about. + final alert = alerts.firstWhere( + (alert) => !alert.isCancelled, + orElse: () => alerts.first, + ); + return _EewSectionBody(alert: alert); } } @@ -158,7 +169,11 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { ? null : context.read().byCode(code); final observer = _fix ?? (town == null ? null : LatLng(town.lat, town.lng)); - final estimate = observer == null + // A cancelled event has no shaking to estimate and no wave to count down + // to — computing one would put a number on screen for an earthquake the + // agency has just said is not happening. + final cancelled = widget.alert.isCancelled; + final estimate = observer == null || cancelled ? null : estimateLocalShaking(widget.alert, observer, table: _table); @@ -183,7 +198,9 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { // among unrelated sections — it needs to stand out on its own, so it // keeps the severity border the map monitor's card also carries. side: BorderSide( - color: IntensityColors.discrete(maxIntensity.colorLevel), + color: cancelled + ? colors.outline + : IntensityColors.discrete(maxIntensity.colorLevel), width: 2, ), ), @@ -195,9 +212,11 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { Row( children: [ Icon( - Icons.warning_amber_outlined, + cancelled + ? Icons.cancel_outlined + : Icons.warning_amber_outlined, size: 18, - color: colors.error, + color: cancelled ? colors.onSurfaceVariant : colors.error, ), const SizedBox(width: AppSpacing.sm), Expanded( @@ -229,11 +248,14 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { ), ), const SizedBox(width: AppSpacing.sm), - IntensityBadge( - label: maxIntensity.label, - color: IntensityColors.discrete(maxIntensity.colorLevel), - size: 32, - ), + if (cancelled) + const EewCancelledTag() + else + IntensityBadge( + label: maxIntensity.label, + color: IntensityColors.discrete(maxIntensity.colorLevel), + size: 32, + ), ], ), const SizedBox(height: AppSpacing.xs), @@ -246,7 +268,10 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { color: colors.onSurfaceVariant, ), ), - if (estimate != null) ...[ + if (cancelled) ...[ + const SizedBox(height: AppSpacing.sm), + const EewCancelledNotice(), + ] else if (estimate != null) ...[ const SizedBox(height: AppSpacing.sm), Row( children: [ diff --git a/lib/features/home/presentation/widgets/home_monitor_banner.dart b/lib/features/home/presentation/widgets/home_monitor_banner.dart index a0e1ba68f..d9e900588 100644 --- a/lib/features/home/presentation/widgets/home_monitor_banner.dart +++ b/lib/features/home/presentation/widgets/home_monitor_banner.dart @@ -15,6 +15,12 @@ /// EEW cards elsewhere in the app already use, so this reads as "a card that /// happens to sit up here," not a raw coloured bar glued to the top of the /// screen. +/// +/// A cancelled alert keeps the banner up for as long as it stays in the list, +/// but on neutral surface colours with 已取消 in place of the report number: +/// the user who saw the warning arrive needs to see it withdrawn, and a +/// banner that simply vanished would read the same as one that ran its +/// course. library; import 'package:dpip/app/theme/app_radius.dart'; @@ -65,8 +71,14 @@ class HomeMonitorBanner extends StatelessWidget { final eew = context.watch>>(); final alerts = eew.state.data ?? const []; - final ink = colors.onErrorContainer; - final alert = alerts.first; + // An alert still in progress outranks a cancelled one; the banner turns + // neutral and says 已取消 only when nothing else is left to warn about. + final alert = alerts.firstWhere( + (alert) => !alert.isCancelled, + orElse: () => alerts.first, + ); + final cancelled = alert.isCancelled; + final ink = cancelled ? colors.onSurface : colors.onErrorContainer; return IgnorePointer( ignoring: dismiss > 0.5, @@ -80,7 +92,9 @@ class HomeMonitorBanner extends StatelessWidget { vertical: AppSpacing.xs, ), child: Material( - color: colors.errorContainer, + color: cancelled + ? colors.surfaceContainerHighest + : colors.errorContainer, // Higher than the card language elsewhere uses — this one // genuinely overlaps the gold support bar underneath it (see // Home's overlap stack), so the shadow needs to read as real @@ -116,7 +130,9 @@ class HomeMonitorBanner extends StatelessWidget { shape: BoxShape.circle, ), child: Icon( - Icons.warning_amber_rounded, + cancelled + ? Icons.cancel_outlined + : Icons.warning_amber_rounded, size: 16, color: ink, ), @@ -137,7 +153,9 @@ class HomeMonitorBanner extends StatelessWidget { ), ), Text( - l10n.eewSerial(alert.serial), + cancelled + ? l10n.eewCancelled + : l10n.eewSerial(alert.serial), maxLines: 1, style: theme.textTheme.labelSmall?.copyWith( color: ink.withValues(alpha: 0.8), diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 8c7165358..7772becc2 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -47,7 +47,8 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// [table] may be null while the bundled asset is still loading: the cross /// still renders, the rings wait for the table. A report whose origin is in the /// future (a clock skew or a just-published event) renders the cross only — -/// never a wavefront that hasn't started. +/// never a wavefront that hasn't started. A cancelled alert renders nothing at +/// all: its rings would show a wave the agency has just said is not coming. Map eewWaveGeoJson( List alerts, SeismicTravelTimeTable? table, @@ -55,6 +56,7 @@ Map eewWaveGeoJson( ) { final features = >[]; for (final eew in alerts) { + if (eew.isCancelled) continue; final info = eew.info; final elapsed = now.difference( DateTime.fromMillisecondsSinceEpoch(info.time, isUtc: true), @@ -310,7 +312,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { @override double get bottomChromeFraction { // A live alert raises the sheet: the EEW cards sit above the status strip, - // so a deliberate framing must leave room for the whole stack. + // so a deliberate framing must leave room for the whole stack. A cancelled + // alert still has its card up, so it counts here too. final live = _eew.state.status == RealtimeStatus.live && (_eew.state.data?.isNotEmpty ?? false); @@ -416,11 +419,12 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { unawaited(_pushUpdate()); } - /// Whether an EEW is out on a live feed — which decides which stations the - /// monitor draws (see [rtsStationMark]). + /// Whether an EEW still in progress is out on a live feed — which decides + /// which stations the monitor draws (see [rtsStationMark]) and whether the + /// wavefront ticker runs. A cancelled alert is not one: it has no wavefront. bool get _eewActive => _eew.state.status == RealtimeStatus.live && - (_eew.state.data?.isNotEmpty ?? false); + (_eew.state.data?.hasUncancelled ?? false); /// The [_eewActive] the station dots on the map were drawn for. bool _dotsDrawnForEew = false; @@ -509,14 +513,17 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// Repaints the EEW overlay. An alert is only current while its feed is live; /// a stale/offline EEW feed's last snapshot must never be presented as an - /// expanding wavefront, so anything not live renders nothing. + /// expanding wavefront, so anything not live renders nothing. Nor does a + /// cancelled alert (see [eewWaveGeoJson]), so a set of only cancelled + /// alerts clears the overlay the same way calm does. Future _pushEew() async { final controller = _controller; if (controller == null || !_added || !_surfaceVisible) return; - final live = + final listed = _eew.state.status == RealtimeStatus.live && (_eew.state.data?.isNotEmpty ?? false); - await _updateAreaFill(controller, live ? _eew.state.data! : const []); + await _updateAreaFill(controller, listed ? _eew.state.data! : const []); + final live = _eewActive; // Nothing to draw and nothing drawn — the overwhelmingly common case. if (!live && _eewSourceEmpty) return; try { @@ -590,7 +597,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// against what the map already holds. (List<(int, List>)>, String) _survivingBoxes(RtsBoxGrid grid) { final box = _alerts.areas.boxes; - final alerts = _eew.state.data ?? const []; + // A cancelled alert's wave sweeps nothing: its boxes stay live readings. + final alerts = (_eew.state.data ?? const []).uncancelled; final now = AppTime.utc; final covers = _travelTime == null ? const <_SWaveCover>[] @@ -712,10 +720,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { await controller.setLayerVisibility(_ids.boxLine, false); } - final hasEew = - _eew.state.status == RealtimeStatus.live && - (_eew.state.data?.isNotEmpty ?? false); - if (hasEew) { + if (_eewActive) { _epicenterVisible = !_epicenterVisible; await controller.setLayerVisibility( _ids.eewEpicenter, @@ -745,6 +750,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// With two simultaneous quakes this must use whichever alert [eewIndex] /// currently selects (the same one the monitor card is showing), not just /// the newest — the ground tint and the card are one choice, not two. + /// A cancelled selection clears the wash, the same as no alert. /// Recomputed only when *that* alert's id/serial changes — cheap to check, /// but the 368-town estimate + platform write isn't, so a repeat is /// skipped. Keying on the selected alert alone (not the whole set) means @@ -754,9 +760,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { MapLibreMapController controller, List alerts, ) async { - final selected = alerts.isEmpty + final shown = alerts.isEmpty ? null : alerts[eewIndex.value % alerts.length]; + // The card is showing a cancelled alert: there is no shaking to tint. + final selected = shown == null || shown.isCancelled ? null : shown; final model = _mlIntensity; // The model arriving mid-alert repaints the same serial from its levels. final key = selected == null diff --git a/lib/features/map/presentation/widgets/monitor_eew_card.dart b/lib/features/map/presentation/widgets/monitor_eew_card.dart index 8839eb44e..d57b2e706 100644 --- a/lib/features/map/presentation/widgets/monitor_eew_card.dart +++ b/lib/features/map/presentation/widgets/monitor_eew_card.dart @@ -3,7 +3,8 @@ /// its tile styling (`EewEstimateTile`), so the map overlay's numbers and /// colours can never drift from the monitor's. The S-wave countdown ticks /// against the calibrated [AppTime] clock, pauses while the map tab is not the -/// selected branch, and stops on dispose. +/// selected branch, and stops on dispose. A cancelled report shows as +/// cancelled: a neutral border, a 已取消 tag, and no estimate or countdown. /// /// Lives in this feature (not `features/earthquake`) because the layering gate /// forbids `features/map` importing another feature's presentation; the home @@ -28,6 +29,7 @@ import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; +import 'package:dpip/shared/widgets/eew_cancelled_notice.dart'; import 'package:dpip/shared/widgets/eew_estimate_tile.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:flutter/material.dart'; @@ -145,7 +147,10 @@ class _MonitorEewCardState extends State with SecondTicker { ? null : context.read().byCode(code); final observer = _fix ?? (town == null ? null : LatLng(town.lat, town.lng)); - final estimate = observer == null + // A cancelled event has no shaking to estimate and no wave to count down + // to — the card says it was cancelled instead. + final cancelled = widget.alert.isCancelled; + final estimate = observer == null || cancelled ? null : estimateLocalShaking(widget.alert, observer, table: _table); @@ -170,7 +175,9 @@ class _MonitorEewCardState extends State with SecondTicker { // calm/low reading stays close to the neutral outline it replaces, // a severe one is unmistakable before you even read the number. side: BorderSide( - color: IntensityColors.discrete(maxIntensity.colorLevel), + color: cancelled + ? colors.outline + : IntensityColors.discrete(maxIntensity.colorLevel), width: 2, ), ), @@ -198,11 +205,14 @@ class _MonitorEewCardState extends State with SecondTicker { widget.trailing!, ], const SizedBox(width: AppSpacing.sm), - IntensityBadge( - label: maxIntensity.label, - color: IntensityColors.discrete(maxIntensity.colorLevel), - size: 32, - ), + if (cancelled) + const EewCancelledTag() + else + IntensityBadge( + label: maxIntensity.label, + color: IntensityColors.discrete(maxIntensity.colorLevel), + size: 32, + ), ], ), const SizedBox(height: AppSpacing.xs), @@ -215,7 +225,10 @@ class _MonitorEewCardState extends State with SecondTicker { color: colors.onSurfaceVariant, ), ), - if (estimate != null) ...[ + if (cancelled) ...[ + const SizedBox(height: AppSpacing.sm), + const EewCancelledNotice(), + ] else if (estimate != null) ...[ const SizedBox(height: AppSpacing.sm), Row( children: [ diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 0573180c9..4aa19c379 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -220,7 +220,7 @@ class _RtsMonitorPanelState extends State return; } final state = widget.eew.state; - final alerts = state.data; + final alerts = state.data?.uncancelled; if (state.status != RealtimeStatus.live || alerts == null || alerts.isEmpty) { @@ -326,7 +326,9 @@ class _EewAlert extends StatelessWidget { /// alert is active it turns red-on-`errorContainer` as a whole — the legacy /// monitor's `MorphingSheet` did the same (`borderColor`/`backgroundColor` on /// `activeEew.isNotEmpty`, binary rather than scaled by severity) — so the -/// strip reads as urgent even collapsed, not just the card above it. +/// strip reads as urgent even collapsed, not just the card above it. A +/// cancelled alert does not count: the strip goes back to calm the moment +/// nothing still in progress is left. class _StatusBar extends StatelessWidget { const _StatusBar({required this.state, required this.eew}); @@ -346,7 +348,7 @@ class _StatusBar extends StatelessWidget { final hasActiveEew = eew.state.status == RealtimeStatus.live && - (eew.state.data?.isNotEmpty ?? false); + (eew.state.data?.hasUncancelled ?? false); final time = state.data?.time ?? 0; final hasData = time != 0; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ea0b5dcf7..eedfb1993 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4248,5 +4248,13 @@ "locationTrackLegendLatest": "Latest position", "@locationTrackLegendLatest": { "description": "Map legend row: the marker on the most recent recorded position" + }, + "eewCancelled": "Cancelled", + "@eewCancelled": { + "description": "Tag on an earthquake early warning card once the issuing agency has cancelled the alert" + }, + "eewCancelledNotice": "The issuing agency cancelled this alert", + "@eewCancelledNotice": { + "description": "Line on a cancelled earthquake early warning card, in place of the local intensity estimate and S-wave countdown" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 616f292cb..e3ef2a701 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "Na-clear na ang ruta", "locationTrackLegendNewer": "Mas bago", "locationTrackLegendOlder": "Mas luma", - "locationTrackLegendLatest": "Pinakabagong lokasyon" + "locationTrackLegendLatest": "Pinakabagong lokasyon", + "eewCancelled": "Kinansela", + "eewCancelledNotice": "Kinansela ng naglabas na ahensya ang alertong ito" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4fbcbeb86..d50fc8d2e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "Jejak dihapus", "locationTrackLegendNewer": "Lebih baru", "locationTrackLegendOlder": "Lebih lama", - "locationTrackLegendLatest": "Posisi terakhir" + "locationTrackLegendLatest": "Posisi terakhir", + "eewCancelled": "Dibatalkan", + "eewCancelledNotice": "Lembaga penerbit telah membatalkan peringatan ini" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index cffb2e617..dec213886 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "履歴を消去しました", "locationTrackLegendNewer": "新しい", "locationTrackLegendOlder": "古い", - "locationTrackLegendLatest": "最新の位置" + "locationTrackLegendLatest": "最新の位置", + "eewCancelled": "取り消し", + "eewCancelledNotice": "発表元がこの速報を取り消しました" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ddc1ce01e..cd4ba395d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "이동 경로를 지웠습니다", "locationTrackLegendNewer": "최근", "locationTrackLegendOlder": "이전", - "locationTrackLegendLatest": "마지막 위치" + "locationTrackLegendLatest": "마지막 위치", + "eewCancelled": "취소됨", + "eewCancelledNotice": "발표 기관이 이 경보를 취소했습니다" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 52db18301..2077b6d35 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "ล้างเส้นทางแล้ว", "locationTrackLegendNewer": "ใหม่กว่า", "locationTrackLegendOlder": "เก่ากว่า", - "locationTrackLegendLatest": "ตำแหน่งล่าสุด" + "locationTrackLegendLatest": "ตำแหน่งล่าสุด", + "eewCancelled": "ยกเลิกแล้ว", + "eewCancelledNotice": "หน่วยงานผู้ออกประกาศได้ยกเลิกการแจ้งเตือนนี้แล้ว" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 79e5a3591..c32cd19e6 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "Đã xóa lộ trình", "locationTrackLegendNewer": "Mới hơn", "locationTrackLegendOlder": "Cũ hơn", - "locationTrackLegendLatest": "Vị trí mới nhất" + "locationTrackLegendLatest": "Vị trí mới nhất", + "eewCancelled": "Đã hủy", + "eewCancelledNotice": "Cơ quan phát hành đã hủy cảnh báo này" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index b30f955d0..576afec77 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "已清除軌跡", "locationTrackLegendNewer": "較新", "locationTrackLegendOlder": "較舊", - "locationTrackLegendLatest": "最新位置" + "locationTrackLegendLatest": "最新位置", + "eewCancelled": "已取消", + "eewCancelledNotice": "發布單位已取消呢個速報" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1e92b8f43..4385e6f66 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2082,5 +2082,7 @@ "locationTrackCleared": "已清除軌跡", "locationTrackLegendNewer": "較新", "locationTrackLegendOlder": "較舊", - "locationTrackLegendLatest": "最新位置" + "locationTrackLegendLatest": "最新位置", + "eewCancelled": "已取消", + "eewCancelledNotice": "發布單位已取消此速報" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 971839312..81dbf44d3 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "已清除轨迹", "locationTrackLegendNewer": "较新", "locationTrackLegendOlder": "较旧", - "locationTrackLegendLatest": "最新位置" + "locationTrackLegendLatest": "最新位置", + "eewCancelled": "已取消", + "eewCancelledNotice": "发布单位已取消此速报" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 56bfbb5c3..274399d24 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "已清除軌跡", "locationTrackLegendNewer": "較新", "locationTrackLegendOlder": "較舊", - "locationTrackLegendLatest": "最新位置" + "locationTrackLegendLatest": "最新位置", + "eewCancelled": "已取消", + "eewCancelledNotice": "發布單位已取消此速報" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 5b1bd9d48..7e5539efa 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2090,5 +2090,7 @@ "locationTrackCleared": "已清除軌跡", "locationTrackLegendNewer": "較新", "locationTrackLegendOlder": "較舊", - "locationTrackLegendLatest": "最新位置" + "locationTrackLegendLatest": "最新位置", + "eewCancelled": "已取消", + "eewCancelledNotice": "發布單位已取消此速報" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 59b5f2dc8..74cec06d2 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6647,6 +6647,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Latest position'** String get locationTrackLegendLatest; + + /// Tag on an earthquake early warning card once the issuing agency has cancelled the alert + /// + /// In en, this message translates to: + /// **'Cancelled'** + String get eewCancelled; + + /// Line on a cancelled earthquake early warning card, in place of the local intensity estimate and S-wave countdown + /// + /// In en, this message translates to: + /// **'The issuing agency cancelled this alert'** + String get eewCancelledNotice; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 4fb7df436..ac5688703 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3517,4 +3517,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get locationTrackLegendLatest => 'Latest position'; + + @override + String get eewCancelled => 'Cancelled'; + + @override + String get eewCancelledNotice => 'The issuing agency cancelled this alert'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 5e9a92bf9..efd81bbe1 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3538,4 +3538,11 @@ class AppLocalizationsFil extends AppLocalizations { @override String get locationTrackLegendLatest => 'Pinakabagong lokasyon'; + + @override + String get eewCancelled => 'Kinansela'; + + @override + String get eewCancelledNotice => + 'Kinansela ng naglabas na ahensya ang alertong ito'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index fc2457e88..f336fc666 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3531,4 +3531,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get locationTrackLegendLatest => 'Posisi terakhir'; + + @override + String get eewCancelled => 'Dibatalkan'; + + @override + String get eewCancelledNotice => + 'Lembaga penerbit telah membatalkan peringatan ini'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 35c6e7d50..6fedc4261 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3453,4 +3453,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get locationTrackLegendLatest => '最新の位置'; + + @override + String get eewCancelled => '取り消し'; + + @override + String get eewCancelledNotice => '発表元がこの速報を取り消しました'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 1efec15e0..a0a6b6cd9 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3455,4 +3455,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get locationTrackLegendLatest => '마지막 위치'; + + @override + String get eewCancelled => '취소됨'; + + @override + String get eewCancelledNotice => '발표 기관이 이 경보를 취소했습니다'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 0bac8d0df..15f9c79bb 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3510,4 +3510,11 @@ class AppLocalizationsTh extends AppLocalizations { @override String get locationTrackLegendLatest => 'ตำแหน่งล่าสุด'; + + @override + String get eewCancelled => 'ยกเลิกแล้ว'; + + @override + String get eewCancelledNotice => + 'หน่วยงานผู้ออกประกาศได้ยกเลิกการแจ้งเตือนนี้แล้ว'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 3334d4bab..7b588fcf4 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3522,4 +3522,10 @@ class AppLocalizationsVi extends AppLocalizations { @override String get locationTrackLegendLatest => 'Vị trí mới nhất'; + + @override + String get eewCancelled => 'Đã hủy'; + + @override + String get eewCancelledNotice => 'Cơ quan phát hành đã hủy cảnh báo này'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 2fd1eb96a..8564f5b54 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3432,4 +3432,10 @@ class AppLocalizationsYue extends AppLocalizations { @override String get locationTrackLegendLatest => '最新位置'; + + @override + String get eewCancelled => '已取消'; + + @override + String get eewCancelledNotice => '發布單位已取消呢個速報'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 037b983c6..efbe46cca 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3432,6 +3432,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get locationTrackLegendLatest => '最新位置'; + + @override + String get eewCancelled => '已取消'; + + @override + String get eewCancelledNotice => '發布單位已取消此速報'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6858,6 +6864,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get locationTrackLegendLatest => '最新位置'; + + @override + String get eewCancelled => '已取消'; + + @override + String get eewCancelledNotice => '发布单位已取消此速报'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -10285,6 +10297,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get locationTrackLegendLatest => '最新位置'; + + @override + String get eewCancelled => '已取消'; + + @override + String get eewCancelledNotice => '發布單位已取消此速報'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -13712,4 +13730,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get locationTrackLegendLatest => '最新位置'; + + @override + String get eewCancelled => '已取消'; + + @override + String get eewCancelledNotice => '發布單位已取消此速報'; } diff --git a/lib/shared/widgets/eew_cancelled_notice.dart b/lib/shared/widgets/eew_cancelled_notice.dart new file mode 100644 index 000000000..86bd071c0 --- /dev/null +++ b/lib/shared/widgets/eew_cancelled_notice.dart @@ -0,0 +1,86 @@ +/// How every EEW card says "this alert was cancelled" — a small tag for the +/// header and a strip that takes the place of the local-estimate tiles. +/// +/// Shared for the same reason [EewEstimateTile] is: the earthquake list, the +/// replay overlay, the map monitor and the home sheet each carry their own +/// card (the layering gate keeps them apart), and a cancel that looked +/// different on each of them is a cancel somebody misreads. Deliberately +/// neutral — surface tones, never the error or intensity colours — because +/// the one thing a cancelled card must not do is still look like a warning. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; + +/// The "已取消" pill beside a cancelled card's title. +class EewCancelledTag extends StatelessWidget { + const EewCancelledTag({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: AppRadius.small, + border: Border.all(color: colors.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + child: Text( + AppLocalizations.of(context).eewCancelled, + maxLines: 1, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +/// The line a cancelled card shows where a live one shows its local +/// intensity and S-wave countdown: a cancelled event has neither. +class EewCancelledNotice extends StatelessWidget { + const EewCancelledNotice({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: AppRadius.small, + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Row( + children: [ + Icon( + Icons.cancel_outlined, + size: 18, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + AppLocalizations.of(context).eewCancelledNotice, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart b/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart index a69080f23..b64bfe64d 100644 --- a/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart +++ b/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart @@ -30,11 +30,11 @@ class _FakeSpeech implements SpeechService { void dispose() {} } -Eew _alert(int serial) => Eew( +Eew _alert(int serial, {String id = 'event', int status = 0}) => Eew( agency: 'CWA', - id: 'event', + id: id, serial: serial, - status: 0, + status: status, isFinal: false, info: const EewInfo( time: 0, @@ -211,4 +211,110 @@ void main() { controller.dispose(); }, ); + + group('cancelled reports', () { + test('a cancelled report is never announced', () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + controller.update( + _live(_alert(1, status: 3)), + languageTag: 'zh-TW', + format: (_) => 'should not be said', + ); + await _flush(); + + expect(speech.spoken, isEmpty); + expect(speech.stops, 0); + controller.dispose(); + }); + + test('a cancel for the alert being spoken cuts the phrase off and ' + 'releases the held warning', () async { + final speech = _FakeSpeech(); + final gate = ForegroundEewAnnouncementGate(); + final controller = MonitorEewAnnouncementController( + speech, + gate, + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + controller.update( + _live(_alert(1)), + languageTag: 'zh-TW', + format: (_) => '震度四級', + ); + await _flush(); + var displayed = false; + await gate.submit(() async => displayed = true); + final stopsBefore = speech.stops; + + controller.update( + _live(_alert(2, status: 3)), + languageTag: 'zh-TW', + format: (_) => 'should not be said', + ); + await _flush(); + + expect(speech.spoken, ['zh-TW:震度四級']); + expect(speech.stops, greaterThan(stopsBefore)); + expect(displayed, isTrue); + controller.dispose(); + }); + + test('a cancel for the spoken event stops it even while another alert ' + 'stays up, and the other is not re-announced', () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (alert) async => (scale: alert.serial, isLocal: true), + ); + controller.setActive(true); + controller.update( + RealtimeState( + status: RealtimeStatus.live, + data: [_alert(1, id: 'b')], + ), + languageTag: 'zh-TW', + format: (estimate) => 'b${estimate.scale}', + ); + await _flush(); + controller.update( + RealtimeState( + status: RealtimeStatus.live, + data: [ + _alert(2, id: 'a'), + _alert(1, id: 'b'), + ], + ), + languageTag: 'zh-TW', + format: (estimate) => 'a${estimate.scale}', + ); + await _flush(); + expect(speech.spoken, ['zh-TW:b1', 'zh-TW:a2']); + final stopsBefore = speech.stops; + + controller.update( + RealtimeState( + status: RealtimeStatus.live, + data: [ + _alert(3, id: 'a', status: 3), + _alert(1, id: 'b'), + ], + ), + languageTag: 'zh-TW', + format: (estimate) => 'again${estimate.scale}', + ); + await _flush(); + + expect(speech.stops, greaterThan(stopsBefore)); + expect(speech.spoken, ['zh-TW:b1', 'zh-TW:a2']); + controller.dispose(); + }); + }); } diff --git a/test/features/earthquake/eew_test.dart b/test/features/earthquake/eew_test.dart index 07d86fd7f..efdaf16bd 100644 --- a/test/features/earthquake/eew_test.dart +++ b/test/features/earthquake/eew_test.dart @@ -71,4 +71,71 @@ void main() { expect(withAgency('').isJma, isFalse); }); }); + + group('Eew.kind reads the wire status', () { + Eew withStatus(int status, {String author = 'cwa'}) => + Eew.fromJson({...wire, 'status': status, 'author': author}); + + test('0 is an ordinary 預警 report', () { + expect(withStatus(0).kind, EewReportKind.forecast); + expect(withStatus(0).isCancelled, isFalse); + expect(withStatus(0).isWarning, isFalse); + }); + + test('1 is the agency\'s public 警報', () { + expect(withStatus(1).kind, EewReportKind.warning); + expect(withStatus(1).isWarning, isTrue); + expect(withStatus(1).isCancelled, isFalse); + }); + + test('3 is a CWA cancel and 2 the event server\'s cancel code', () { + expect(withStatus(3).isCancelled, isTrue); + expect(withStatus(2, author: 'nied').isCancelled, isTrue); + expect(withStatus(2).kind, EewReportKind.cancelled); + }); + + test('an unknown code never reads as a cancel — a misread code must ' + 'leave an alert up, not hide one', () { + for (final status in [-1, 4, 9, 255]) { + expect(withStatus(status).kind, EewReportKind.forecast); + expect(withStatus(status).isCancelled, isFalse); + } + }); + + test('a final report is not a cancel', () { + final last = Eew.fromJson({...wire, 'status': 0, 'final': 1}); + expect(last.isFinal, isTrue); + expect(last.isCancelled, isFalse); + }); + }); + + group('Eew.eventKey', () { + test('is one key per agency event, across serials and spellings', () { + final a = Eew.fromJson({...wire, 'serial': 1}); + final b = Eew.fromJson({...wire, 'serial': 5, 'author': 'CWA'}); + expect(a.eventKey, b.eventKey); + }); + + test('keeps two agencies\' same-numbered events apart', () { + final cwa = Eew.fromJson(wire); + final nied = Eew.fromJson({...wire, 'author': 'nied'}); + expect(cwa.eventKey, isNot(nied.eventKey)); + }); + }); + + group('cancel filter', () { + final live = Eew.fromJson({...wire, 'id': 'live', 'status': 0}); + final cancelled = Eew.fromJson({...wire, 'id': 'gone', 'status': 3}); + + test('uncancelled keeps order and drops only the cancelled', () { + expect([cancelled, live].uncancelled, [live]); + expect([live, cancelled, live].uncancelled, [live, live]); + }); + + test('hasUncancelled is false for a set of only cancelled alerts', () { + expect([cancelled].hasUncancelled, isFalse); + expect([cancelled, live].hasUncancelled, isTrue); + expect(const [].hasUncancelled, isFalse); + }); + }); } diff --git a/test/features/earthquake/presentation/widgets/eew_card_test.dart b/test/features/earthquake/presentation/widgets/eew_card_test.dart index 15135d3a8..182bb0ff8 100644 --- a/test/features/earthquake/presentation/widgets/eew_card_test.dart +++ b/test/features/earthquake/presentation/widgets/eew_card_test.dart @@ -229,4 +229,23 @@ void main() { expect(find.text(l10n.eewArrived), findsOneWidget); await tester.pumpWidget(const SizedBox()); }); + + testWidgets('a cancelled report says so and drops the badge, the local ' + 'estimate and the countdown', (tester) async { + final store = await _store(); + await tester.pumpWidget( + _wrap(store, eew: _alert().copyWith(status: 3, serial: 4)), + ); + final l10n = AppLocalizations.of(tester.element(find.byType(EewCard))); + + expect(find.text(l10n.eewCancelled), findsOneWidget); + expect(find.text(l10n.eewCancelledNotice), findsOneWidget); + expect(find.byType(IntensityBadge), findsNothing); + expect(find.text(l10n.eewLocalIntensity), findsNothing); + expect(find.text(l10n.eewSWave), findsNothing); + // Still recognisably the same event. + expect(find.text('花蓮縣'), findsOneWidget); + expect(find.text('Report 4'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); } diff --git a/test/features/home/presentation/widgets/home_eew_section_test.dart b/test/features/home/presentation/widgets/home_eew_section_test.dart index 4f6c53044..efc642103 100644 --- a/test/features/home/presentation/widgets/home_eew_section_test.dart +++ b/test/features/home/presentation/widgets/home_eew_section_test.dart @@ -340,6 +340,69 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + testWidgets('a cancelled alert shows as cancelled, with no estimate or ' + 'countdown even where a township is selected', (tester) async { + final setup = await _liveNotifier([_alert().copyWith(status: 3)]); + final store = await _store(); + store.addSaved('6300100'); + store.select(2); + final directory = TownDirectory(const { + '6300100': Town( + code: '6300100', + city: '臺北', + town: '中正', + lat: 25.03, + lng: 121.52, + cityLevel: '市', + townLevel: '區', + ), + }); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: _providers( + setup.notifier, + store, + directory: directory, + travelTimes: Future.value(_table()), + child: const Scaffold(body: HomeEewSection()), + ), + ), + ); + await tester.pump(); + + expect(find.text('花蓮縣'), findsOneWidget); + expect(find.text('Cancelled'), findsOneWidget); + expect( + find.text('The issuing agency cancelled this alert'), + findsOneWidget, + ); + expect(find.text('S-wave'), findsNothing); + expect(find.text('Estimated at my location'), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('an alert still in progress outranks a cancelled one', ( + tester, + ) async { + final setup = await _liveNotifier([ + _alert().copyWith(id: 'gone', status: 3), + _alert().copyWith( + id: 'live', + info: _alert().info.copyWith(location: '臺東縣'), + ), + ]); + final store = await _store(); + await tester.pumpWidget(_wrap(setup.notifier, store)); + + expect(find.text('臺東縣'), findsOneWidget); + expect(find.text('花蓮縣'), findsNothing); + expect(find.text('Cancelled'), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('a fix that arrives after dispose does not rebuild', ( tester, ) async { diff --git a/test/features/home/presentation/widgets/home_monitor_banner_test.dart b/test/features/home/presentation/widgets/home_monitor_banner_test.dart index 57f8908ed..5f1f0b037 100644 --- a/test/features/home/presentation/widgets/home_monitor_banner_test.dart +++ b/test/features/home/presentation/widgets/home_monitor_banner_test.dart @@ -169,4 +169,40 @@ void main() { expect(request, isNotNull); expect(request!.layerId, 'monitor'); }); + + testWidgets('a cancelled alert stays up on neutral colours and says it was ' + 'cancelled', (tester) async { + final notifier = await _notifier([_alert().copyWith(status: 3)]); + await tester.pumpWidget(_wrap(notifier, MapCameraHandoff())); + + final context = tester.element(find.byType(HomeMonitorBanner)); + final l10n = AppLocalizations.of(context); + expect(find.text('臺東縣'), findsOneWidget); + expect(find.text(l10n.eewCancelled), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + final material = tester.widget( + find.descendant( + of: find.byType(HomeMonitorBanner), + matching: find.byType(Material), + ), + ); + expect(material.color, isNot(Theme.of(context).colorScheme.errorContainer)); + }); + + testWidgets('an alert still in progress outranks a cancelled one', ( + tester, + ) async { + final cancelled = _alert().copyWith(id: 'gone', status: 3); + final live = _alert().copyWith( + id: 'live', + serial: 2, + info: _alert().info.copyWith(location: '花蓮縣'), + ); + final notifier = await _notifier([cancelled, live]); + await tester.pumpWidget(_wrap(notifier, MapCameraHandoff())); + + expect(find.text('花蓮縣'), findsOneWidget); + expect(find.text('臺東縣'), findsNothing); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + }); } diff --git a/test/features/map/presentation/layers/rts_layer_eew_push_test.dart b/test/features/map/presentation/layers/rts_layer_eew_push_test.dart index 751677195..da6872ff3 100644 --- a/test/features/map/presentation/layers/rts_layer_eew_push_test.dart +++ b/test/features/map/presentation/layers/rts_layer_eew_push_test.dart @@ -268,4 +268,31 @@ void main() { reason: 'cleared stays cleared without further uploads', ); }); + + test( + 'a feed of only cancelled alerts draws nothing, like a calm one', + () async { + final eewElapsed = _FakeElapsed(); + final built = await _build( + alerts: [_alert().copyWith(status: 3)], + eewElapsed: eewElapsed, + ); + final controller = _EewRecordingController(); + expect(built.eewChannel.state.status, RealtimeStatus.live); + + await built.layer.render(controller); + await pumpEventQueue(); + for (var i = 0; i < 5; i++) { + built.rtsElapsed.advance(const Duration(seconds: 1)); + built.rtsChannel.recomputeStatus(); + await pumpEventQueue(); + } + + expect( + controller.eewPushes, + isEmpty, + reason: 'a cancelled alert has no wavefront to draw', + ); + }, + ); } diff --git a/test/features/map/presentation/layers/rts_layer_eew_test.dart b/test/features/map/presentation/layers/rts_layer_eew_test.dart index b3400ffa0..963e618b9 100644 --- a/test/features/map/presentation/layers/rts_layer_eew_test.dart +++ b/test/features/map/presentation/layers/rts_layer_eew_test.dart @@ -33,6 +33,31 @@ Eew _alert({ ); void main() { + test('a cancelled alert draws neither rings nor a cross', () { + final origin = DateTime.utc(2026, 1, 1); + final live = _alert(id: 'live', origin: origin); + final geo = eewWaveGeoJson( + [ + _alert(id: 'gone', origin: origin, longitude: 120).copyWith(status: 3), + live, + ], + _table, + origin.add(const Duration(seconds: 5)), + ); + final features = geo['features'] as List; + // Only the live alert's three ring features and its cross. + expect(features, hasLength(4)); + final cross = features.singleWhere((f) => f['properties']['type'] == 'x'); + expect(cross['geometry']['coordinates'], [121.5, 23.5]); + + final onlyCancelled = eewWaveGeoJson( + [live.copyWith(status: 2)], + _table, + origin.add(const Duration(seconds: 5)), + ); + expect(onlyCancelled['features'], isEmpty); + }); + test('no alerts renders an empty collection', () { final geo = eewWaveGeoJson(const [], _table, DateTime.utc(2026, 1, 1)); expect(geo['features'], isEmpty); diff --git a/test/features/map/presentation/widgets/monitor_eew_card_test.dart b/test/features/map/presentation/widgets/monitor_eew_card_test.dart index 2b0e3fd98..4d54b568d 100644 --- a/test/features/map/presentation/widgets/monitor_eew_card_test.dart +++ b/test/features/map/presentation/widgets/monitor_eew_card_test.dart @@ -16,6 +16,7 @@ import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/map/presentation/widgets/monitor_eew_card.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -213,4 +214,28 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }, ); + + testWidgets('a cancelled report at the selected town shows the cancel, ' + 'not a countdown or an estimate', (tester) async { + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + final regions = RegionStore(SettingsStore.inMemory()) + ..setCurrentCode('100'); + await tester.pumpWidget( + _card( + alert: _alert(time: DateTime.now().millisecondsSinceEpoch) + .copyWith(status: 3), + table: Future.value(_table), + location: location(), + regions: regions, + ), + ); + await tester.pump(); + expect(find.text('Hualien'), findsOneWidget); + expect(find.text(l10n.eewCancelled), findsOneWidget); + expect(find.text(l10n.eewCancelledNotice), findsOneWidget); + expect(find.byType(IntensityBadge), findsNothing); + expect(find.text(l10n.eewSWave), findsNothing); + expect(find.text(l10n.eewLocalIntensity), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + }); } diff --git a/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart b/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart index 20b48ca4d..9225ba9f8 100644 --- a/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart +++ b/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart @@ -307,6 +307,35 @@ void main() { }, ); + testWidgets('a cancelled alert keeps its card but leaves the strip calm', ( + tester, + ) async { + final feeds = await _liveFeeds(alerts: [_alert().copyWith(status: 3)]); + final store = await _store(); + await tester.pumpWidget(_wrap(feeds.rts, feeds.eew, store)); + + expect(find.text('花蓮縣'), findsOneWidget); + expect(find.text('Cancelled'), findsOneWidget); + final strip = + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration! as BoxDecoration).boxShadow != null, + ), + ) + .decoration! + as BoxDecoration; + final colors = Theme.of(tester.element(find.text('Seismic Monitor'))) + .colorScheme; + expect(strip.color, colors.surface.withValues(alpha: 0.94)); + expect(strip.border, isNull); + + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('drops the alert cards once the EEW feed has aged past live', ( tester, ) async { From fd1bad3c63e12da6a239155af3da7a7e1bd1db4a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:41:10 +0800 Subject: [PATCH 2/6] fix(eew): keep the newest report when a server falls behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 切換連線伺服器時,地震速報不會再從較新的報數退回較舊的報數 Fix(en-US): an earthquake alert no longer steps back to an older report after the app switches servers --- .../earthquake/data/eew_realtime_source.dart | 29 +++ lib/features/earthquake/domain/eew_feed.dart | 106 +++++++++++ .../earthquake/domain/eew_feed_test.dart | 173 ++++++++++++++++++ .../earthquake/eew_realtime_source_test.dart | 65 +++++++ 4 files changed, 373 insertions(+) create mode 100644 lib/features/earthquake/domain/eew_feed.dart create mode 100644 test/features/earthquake/domain/eew_feed_test.dart diff --git a/lib/features/earthquake/data/eew_realtime_source.dart b/lib/features/earthquake/data/eew_realtime_source.dart index 5e3e53863..b014fd9ae 100644 --- a/lib/features/earthquake/data/eew_realtime_source.dart +++ b/lib/features/earthquake/data/eew_realtime_source.dart @@ -7,9 +7,11 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/api_exception.dart'; import 'package:dpip/core/network/sse_event.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/elapsed.dart'; import 'package:dpip/core/realtime/sse_realtime_source.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_feed.dart'; import 'package:flutter/foundation.dart' show listEquals; /// The live EEW feed, streamed over Server-Sent Events (`/api/v2/eq/eew?sse=1`). @@ -44,6 +46,11 @@ import 'package:flutter/foundation.dart' show listEquals; /// of date, which is the truth — and the feed is `live` again only once the /// stream answers, at which point polling stops at once and any poll still in /// flight is discarded. +/// +/// Every answer — streamed or polled — passes through [reduceEewFeed] on its +/// way out, so what the channel publishes is never a report older than one +/// already shown: the server a reconnect or a poll lands on may be a serial +/// behind the one before it. class EewRealtimeSource extends SseRealtimeSource> { /// [connect] opens one EEW SSE connection — in production /// `EarthquakeApi.openEewSse`; the source calls it again to reconnect. @@ -54,6 +61,9 @@ class EewRealtimeSource extends SseRealtimeSource> { /// [cwaOnly] is read fresh on every [decode] and every poll (a closure, not /// a captured bool) so toggling `EewCwaOnlySettings` takes effect on the /// very next event instead of needing the connection to be rebuilt. + /// + /// [now] stamps each snapshot for the reducer — the calibrated clock in + /// production, since it is compared with server-stamped instants. EewRealtimeSource( Stream Function() connect, { required bool Function() cwaOnly, @@ -61,6 +71,7 @@ class EewRealtimeSource extends SseRealtimeSource> { Elapsed? elapsed, Future Function(Duration delay)? delay, Random? random, + DateTime Function()? now, }) : this._( connect, cwaOnly, @@ -68,6 +79,7 @@ class EewRealtimeSource extends SseRealtimeSource> { elapsed ?? SystemElapsed(), delay, random, + now ?? (() => AppTime.utc), ); EewRealtimeSource._( @@ -77,6 +89,7 @@ class EewRealtimeSource extends SseRealtimeSource> { this._monotonic, Future Function(Duration delay)? delay, Random? random, + this._now, ) : super( connect: connect, label: 'eew', @@ -103,6 +116,11 @@ class EewRealtimeSource extends SseRealtimeSource> { final bool Function() cwaOnly; final Future> Function()? _poll; final Elapsed _monotonic; + final DateTime Function() _now; + + /// Survives reconnects, failovers and the switch to polling and back — + /// that is the point of it. + EewFeedState _feed = EewFeedState.initial; /// When the stream started failing, or null while it answers. Duration? _downSince; @@ -125,8 +143,19 @@ class EewRealtimeSource extends SseRealtimeSource> { @override bool get servingFallback => _servingFallback; + /// The answer, folded through [reduceEewFeed]. The channel calls this once + /// a second whether or not a new frame arrived, so the fold runs on the same + /// snapshot repeatedly; it is idempotent for an unchanged one. @override Future>> fetch() async { + final result = await _fetchUnreduced(); + return result.map((frame) { + _feed = reduceEewFeed(_feed, frame, _now()); + return _feed.visible; + }); + } + + Future>> _fetchUnreduced() async { final streamed = await super.fetch(); final poll = _poll; if (poll == null || _closed) return streamed; diff --git a/lib/features/earthquake/domain/eew_feed.dart b/lib/features/earthquake/domain/eew_feed.dart new file mode 100644 index 000000000..0959136a2 --- /dev/null +++ b/lib/features/earthquake/domain/eew_feed.dart @@ -0,0 +1,106 @@ +/// Folds each snapshot of the live EEW feed into the alert list the app +/// shows — the one place the feed's report order is enforced. +/// +/// Every SSE frame is the server's whole current list, and the app used to +/// show each one verbatim. Within one server that is safe: the edge keeps a +/// single entry per agency + event and only ever replaces it with a higher +/// serial. Across servers it is not. The feed is served from more than one +/// region, each fed by its own subscription to the same upstream, and after +/// a failover — or a reconnect that lands on the other region — the new +/// server can be a report behind the old one. Shown verbatim, the card on +/// screen went from 第 3 報 back to 第 2 報: older magnitude, older epicentre, +/// older intensity, presented as the latest word. +/// +/// [reduceEewFeed] keeps, per event, the newest report it has seen, and shows +/// that one whenever the server lists the event at all. It never holds an +/// event the server has dropped: what is listed is the server's call, and only +/// *which report* of a listed event is shown is this function's. +/// +/// Pure (no clock, no I/O): the caller passes the instant a snapshot arrived. +library; + +import 'package:dpip/features/earthquake/domain/eew.dart'; + +/// What [reduceEewFeed] remembers about one event between snapshots. +class EewTrack { + const EewTrack({required this.report, required this.firstSeen}); + + /// The newest report of the event seen so far. + final Eew report; + + /// When the event was first listed — what [EewFeedState.memory] is + /// measured from. + final DateTime firstSeen; +} + +/// The reducer's state between snapshots: the newest report per event, and +/// the list the app shows. +class EewFeedState { + const EewFeedState._(this._tracks, this.visible); + + /// Before the first snapshot: nothing remembered, nothing shown. + static const EewFeedState initial = EewFeedState._({}, []); + + /// How long an event's newest serial is remembered after it was first + /// listed. Long enough to outlast any EEW (the server itself drops an event + /// four minutes after its origin) and any failover, short enough that the + /// memory stays a handful of entries. + static const Duration memory = Duration(hours: 1); + + /// Per [EewIdentityX.eventKey]. + final Map _tracks; + + /// The alerts to show, in the server's order, each at its newest report. + final List visible; + + /// What is remembered about [eventKey], or null — for tests and diagnostics. + EewTrack? trackOf(String eventKey) => _tracks[eventKey]; +} + +/// Whether [incoming] supersedes [current] as an event's latest word. +/// +/// A higher serial always does; a lower one never does. On an equal serial the +/// incoming copy wins — it is the same report — except that a cancel is never +/// un-done by a copy of the same serial that lacks it. +bool _supersedes(Eew incoming, Eew current) { + if (incoming.serial != current.serial) { + return incoming.serial > current.serial; + } + return incoming.isCancelled || !current.isCancelled; +} + +/// Folds [frame], the server's whole list as it arrived at [now], into +/// [previous]. +/// +/// The result shows exactly the events [frame] lists, in its order, each at the +/// newest report seen for it — so a lagging server can neither take a card +/// back a serial nor un-cancel an event, but an event it drops leaves the list +/// at once. An event stays remembered while it is listed and for +/// [EewFeedState.memory] after it was first listed, so one that comes back +/// from a lagging server still comes back at its newest report. +EewFeedState reduceEewFeed( + EewFeedState previous, + List frame, + DateTime now, +) { + final tracks = Map.of(previous._tracks); + final order = []; + for (final incoming in frame) { + final key = incoming.eventKey; + if (!order.contains(key)) order.add(key); + final track = tracks[key]; + if (track == null) { + tracks[key] = EewTrack(report: incoming, firstSeen: now); + } else if (_supersedes(incoming, track.report)) { + tracks[key] = EewTrack(report: incoming, firstSeen: track.firstSeen); + } + } + // Forget only what is no longer listed: an event the server still lists + // keeps its first sighting, however long that has been. + tracks.removeWhere( + (key, track) => + !order.contains(key) && + now.difference(track.firstSeen) >= EewFeedState.memory, + ); + return EewFeedState._(tracks, [for (final key in order) tracks[key]!.report]); +} diff --git a/test/features/earthquake/domain/eew_feed_test.dart b/test/features/earthquake/domain/eew_feed_test.dart new file mode 100644 index 000000000..4dcfdc51f --- /dev/null +++ b/test/features/earthquake/domain/eew_feed_test.dart @@ -0,0 +1,173 @@ +/// Pins the EEW feed reducer: a report never regresses, an event the server +/// drops leaves at once, and one a lagging server brings back returns at its +/// newest report. +library; + +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_feed.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final DateTime _t0 = DateTime.utc(2026, 10, 16, 3); + +Eew _report( + int serial, { + String id = 'e1', + String agency = 'cwa', + int status = 0, + double magnitude = 5.0, +}) => Eew( + agency: agency, + id: id, + serial: serial, + status: status, + isFinal: false, + info: EewInfo( + time: _t0.millisecondsSinceEpoch, + longitude: 121.5, + latitude: 23.8, + depth: 10, + magnitude: magnitude, + location: '花蓮縣', + max: 4, + ), +); + +/// Folds [frames] one second apart, returning the final state. +EewFeedState _fold(List> frames, {DateTime? start}) { + var state = EewFeedState.initial; + var now = start ?? _t0; + for (final frame in frames) { + state = reduceEewFeed(state, frame, now); + now = now.add(const Duration(seconds: 1)); + } + return state; +} + +void main() { + test('the first snapshot is shown as it came', () { + final state = _fold([ + [_report(1), _report(1, id: 'e2')], + ]); + expect(state.visible, [_report(1), _report(1, id: 'e2')]); + }); + + test('a newer serial replaces the card', () { + final state = _fold([ + [_report(1)], + [_report(2, magnitude: 5.4)], + ]); + expect(state.visible, [_report(2, magnitude: 5.4)]); + }); + + test('a lagging server cannot take a card back a serial', () { + final state = _fold([ + [_report(3, magnitude: 6.1)], + // Failover: the new region is a report behind. + [_report(2, magnitude: 5.7)], + ]); + expect(state.visible.single.serial, 3); + expect(state.visible.single.info.magnitude, 6.1); + }); + + test('it catches up the moment the lagging server does', () { + final state = _fold([ + [_report(3)], + [_report(2)], + [_report(4)], + ]); + expect(state.visible.single.serial, 4); + }); + + test('an event the server drops leaves at once', () { + final state = _fold([ + [_report(2), _report(1, id: 'e2')], + [_report(1, id: 'e2')], + ]); + expect(state.visible, [_report(1, id: 'e2')]); + expect( + _fold([ + [_report(2)], + [], + ]).visible, + isEmpty, + ); + }); + + test( + 'an event a lagging server brings back returns at its newest report', + () { + final state = _fold([ + [_report(3)], + [], + [_report(2)], + ]); + expect(state.visible.single.serial, 3); + }, + ); + + test('a lagging copy of the same serial cannot un-cancel an event', () { + final state = _fold([ + [_report(4, status: 3)], + [_report(4)], + [_report(3)], + ]); + expect(state.visible.single.isCancelled, isTrue); + }); + + test('a cancel carried on an equal serial is taken', () { + final state = _fold([ + [_report(4)], + [_report(4, status: 3)], + ]); + expect(state.visible.single.isCancelled, isTrue); + }); + + test('two agencies numbering an event alike are two events', () { + final state = _fold([ + [_report(5), _report(1, agency: 'nied')], + [_report(4), _report(2, agency: 'nied')], + ]); + expect(state.visible.map((e) => (e.agency, e.serial)), [ + ('cwa', 5), + ('nied', 2), + ]); + }); + + test( + 'the server order is kept and a doubled event collapses to its newest', + () { + final state = _fold([ + [_report(1, id: 'b'), _report(2, id: 'a'), _report(3, id: 'b')], + ]); + expect(state.visible.map((e) => (e.id, e.serial)), [('b', 3), ('a', 2)]); + }, + ); + + test('an unchanged frame folds to an equal list', () { + final once = _fold([ + [_report(2), _report(1, id: 'e2')], + ]); + final twice = reduceEewFeed(once, [_report(2), _report(1, id: 'e2')], _t0); + expect(twice.visible, once.visible); + }); + + group('memory', () { + test('an unlisted event is forgotten after the memory window', () { + var state = reduceEewFeed(EewFeedState.initial, [_report(3)], _t0); + state = reduceEewFeed(state, const [], _t0.add(EewFeedState.memory)); + expect(state.trackOf(_report(3).eventKey), isNull); + // Nothing left to hold it back: a later listing is taken as it comes. + state = reduceEewFeed(state, [ + _report(2), + ], _t0.add(EewFeedState.memory + const Duration(seconds: 1))); + expect(state.visible.single.serial, 2); + }); + + test('a listed event keeps its first sighting however long it stays', () { + var state = reduceEewFeed(EewFeedState.initial, [_report(1)], _t0); + final later = _t0.add(EewFeedState.memory * 2); + state = reduceEewFeed(state, [_report(2)], later); + expect(state.trackOf(_report(1).eventKey)!.firstSeen, _t0); + }); + }); +} diff --git a/test/features/earthquake/eew_realtime_source_test.dart b/test/features/earthquake/eew_realtime_source_test.dart index d4e06f218..6361512af 100644 --- a/test/features/earthquake/eew_realtime_source_test.dart +++ b/test/features/earthquake/eew_realtime_source_test.dart @@ -333,6 +333,71 @@ void main() { expect(source.servingFallback, isFalse); }); }); + + group('fetch folds every snapshot through the report-order reducer', () { + String frame(List<({String id, int serial, int status})> alerts) => + jsonEncode([ + for (final a in alerts) + { + 'author': 'cwa', + 'id': a.id, + 'serial': a.serial, + 'status': a.status, + 'final': 0, + 'eq': { + 'time': 1700000000000, + 'lon': 121.5, + 'lat': 24.0, + 'depth': 10.0, + 'mag': 5.0 + a.serial / 10, + 'loc': '花蓮縣', + 'max': 4, + }, + }, + ]); + + Future> answer( + EewRealtimeSource source, + StreamController stream, + String data, + ) async { + stream.add(SseEvent(data: data)); + await pumpEventQueue(); + final result = await source.fetch(); + return result.valueOrNull!; + } + + test('a reconnect onto a server a report behind does not take the card ' + 'back, and a dropped event still leaves', () async { + final stream = StreamController.broadcast(); + addTearDown(stream.close); + final source = EewRealtimeSource( + () => stream.stream, + cwaOnly: () => false, + now: () => DateTime.utc(2026, 10, 16), + ); + addTearDown(source.dispose); + await source.fetch(); // opens the connection + + final third = await answer( + source, + stream, + frame([(id: 'a', serial: 3, status: 0)]), + ); + expect(third.single.serial, 3); + + final lagging = await answer( + source, + stream, + frame([(id: 'a', serial: 2, status: 0)]), + ); + expect(lagging.single.serial, 3); + expect(lagging.single.info.magnitude, closeTo(5.3, 1e-9)); + + final gone = await answer(source, stream, '[]'); + expect(gone, isEmpty); + }); + }); } class _FakeElapsed implements Elapsed { From fea74991590fb5febf6128ad95fe296075d49e6e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:45:16 +0800 Subject: [PATCH 3/6] fix(eew): drop an alert the feed has stopped updating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 速報伺服器停止更新時,地震速報不會再一直停留在畫面上;已取消的速報顯示三分鐘後移除 Fix(en-US): an earthquake alert no longer stays on screen indefinitely when the server stops updating it, and a cancelled one clears after three minutes --- lib/features/earthquake/domain/eew_feed.dart | 130 ++++++++++++++++-- .../earthquake/domain/eew_feed_test.dart | 125 ++++++++++++++++- .../earthquake/eew_realtime_source_test.dart | 35 ++++- 3 files changed, 272 insertions(+), 18 deletions(-) diff --git a/lib/features/earthquake/domain/eew_feed.dart b/lib/features/earthquake/domain/eew_feed.dart index 0959136a2..e120e3eec 100644 --- a/lib/features/earthquake/domain/eew_feed.dart +++ b/lib/features/earthquake/domain/eew_feed.dart @@ -16,21 +16,108 @@ /// event the server has dropped: what is listed is the server's call, and only /// *which report* of a listed event is shown is this function's. /// -/// Pure (no clock, no I/O): the caller passes the instant a snapshot arrived. +/// It also refuses to show a listed event past [EewExpiry]'s backstop. The +/// stream is silent between earthquakes, so an open connection is its only +/// liveness signal — and an open connection whose server has stopped updating +/// its list would otherwise keep the last alert on screen, counting nothing +/// down, for as long as the socket stayed up. +/// +/// Pure (no clock, no I/O): the caller passes the instant a snapshot arrived, +/// on the calibrated clock, since it is compared with server-stamped origins. library; import 'package:dpip/features/earthquake/domain/eew.dart'; +/// How long an alert may stay on screen when nothing takes it down. +/// +/// A backstop, not the normal exit: the server drops an event four minutes +/// (240 s) after its origin, and the app follows. These only decide when the +/// app stops believing a list that has gone on naming an event past the point +/// where it can still describe shaking anyone is about to feel. +/// +/// The numbers come from S-wave travel time across the area the feed covers, +/// read off the bundled CWA travel-time table: a shallow event's S wave is +/// 600 km out — farther than any point of Taiwan, Penghu, Kinmen or Matsu +/// lies from any epicentre the feed reports — about 145 s after origin. The +/// first report itself lands 10–20 s after origin, and strong shaking goes on +/// for tens of seconds behind the wavefront. +abstract final class EewExpiry { + /// An ordinary event: gone 250 s after origin — past the S wave's 145 s + /// to 600 km with the report delay and the shaking behind it to spare, and + /// deliberately just behind the server's own 240 s, so the backstop never + /// pre-empts the server's ordinary exit. + static const Duration standard = Duration(seconds: 250); + + /// A deep, large or 警報 event: gone 400 s after origin. + /// + /// A deep focus is felt across the whole island and beyond, and its wave + /// starts a focal depth away: from 500 km down the S wave needs ~165 s to + /// reach 600 km epicentral and ~195 s to reach 800 km. A large rupture + /// lasts tens of seconds itself, keeps being revised for longer, and is + /// felt past 600 km. A public 警報 is the report people act on. All three + /// get the extra two and a half minutes. + static const Duration extended = Duration(seconds: 400); + + /// Focal depth (km) from which an event is "deep" for [extended]. + static const double deepKm = 150; + + /// Magnitude from which an event is "large" for [extended]. + static const double largeMagnitude = 6.0; + + /// A cancelled event: gone 180 s after the cancel was first seen — long + /// enough for someone who saw the warning arrive to see it withdrawn, short + /// enough not to sit on Home as if it still mattered. + static const Duration cancelled = Duration(seconds: 180); + + /// Whatever the origin says, nothing stays past an hour after it was first + /// listed. Catches an origin time that is wrong — far in the future — which + /// would otherwise push the windows above out with it. + static const Duration hardCap = Duration(hours: 1); + + /// How long after origin [report] stays up — [standard] or [extended]. + static Duration lifetimeOf(Eew report) { + final info = report.info; + final extend = + info.depth >= deepKm || + info.magnitude >= largeMagnitude || + report.isWarning; + return extend ? extended : standard; + } +} + /// What [reduceEewFeed] remembers about one event between snapshots. class EewTrack { - const EewTrack({required this.report, required this.firstSeen}); + const EewTrack({ + required this.report, + required this.firstSeen, + this.cancelledSince, + }); /// The newest report of the event seen so far. final Eew report; - /// When the event was first listed — what [EewFeedState.memory] is - /// measured from. + /// When the event was first listed — what [EewFeedState.memory] and + /// [EewExpiry.hardCap] are measured from. final DateTime firstSeen; + + /// When a cancel for the event was first seen; null until then. + final DateTime? cancelledSince; + + /// The instant this event stops being shown, whether listed or not. + DateTime get expiresAt { + final cap = firstSeen.add(EewExpiry.hardCap); + final cancel = cancelledSince; + final due = report.isCancelled && cancel != null + ? cancel.add(EewExpiry.cancelled) + : DateTime.fromMillisecondsSinceEpoch( + report.info.time, + isUtc: true, + ).add(EewExpiry.lifetimeOf(report)); + return due.isBefore(cap) ? due : cap; + } + + /// Whether this event is past [expiresAt] at [now]. + bool isExpiredAt(DateTime now) => !now.isBefore(expiresAt); } /// The reducer's state between snapshots: the newest report per event, and @@ -50,7 +137,8 @@ class EewFeedState { /// Per [EewIdentityX.eventKey]. final Map _tracks; - /// The alerts to show, in the server's order, each at its newest report. + /// The alerts to show, in the server's order, each at its newest report, + /// leaving out any past its [EewExpiry] backstop. final List visible; /// What is remembered about [eventKey], or null — for tests and diagnostics. @@ -72,12 +160,15 @@ bool _supersedes(Eew incoming, Eew current) { /// Folds [frame], the server's whole list as it arrived at [now], into /// [previous]. /// -/// The result shows exactly the events [frame] lists, in its order, each at the +/// The result shows the events [frame] lists, in its order, each at the /// newest report seen for it — so a lagging server can neither take a card /// back a serial nor un-cancel an event, but an event it drops leaves the list -/// at once. An event stays remembered while it is listed and for -/// [EewFeedState.memory] after it was first listed, so one that comes back -/// from a lagging server still comes back at its newest report. +/// at once — less any that are past [EewTrack.expiresAt]. The channel polls +/// once a second with or without a new frame, so an expired event goes within +/// a second of its deadline even when the server has gone quiet. An event +/// stays remembered while it is listed and for [EewFeedState.memory] after it +/// was first listed, so one that comes back from a lagging server still comes +/// back at its newest report. EewFeedState reduceEewFeed( EewFeedState previous, List frame, @@ -90,9 +181,21 @@ EewFeedState reduceEewFeed( if (!order.contains(key)) order.add(key); final track = tracks[key]; if (track == null) { - tracks[key] = EewTrack(report: incoming, firstSeen: now); + tracks[key] = EewTrack( + report: incoming, + firstSeen: now, + cancelledSince: incoming.isCancelled ? now : null, + ); } else if (_supersedes(incoming, track.report)) { - tracks[key] = EewTrack(report: incoming, firstSeen: track.firstSeen); + tracks[key] = EewTrack( + report: incoming, + firstSeen: track.firstSeen, + // The cancel window runs from the first cancel seen, not from each + // repeat of it — the same frame is folded again every second. + cancelledSince: incoming.isCancelled + ? (track.cancelledSince ?? now) + : null, + ); } } // Forget only what is no longer listed: an event the server still lists @@ -102,5 +205,8 @@ EewFeedState reduceEewFeed( !order.contains(key) && now.difference(track.firstSeen) >= EewFeedState.memory, ); - return EewFeedState._(tracks, [for (final key in order) tracks[key]!.report]); + return EewFeedState._(tracks, [ + for (final key in order) + if (!tracks[key]!.isExpiredAt(now)) tracks[key]!.report, + ]); } diff --git a/test/features/earthquake/domain/eew_feed_test.dart b/test/features/earthquake/domain/eew_feed_test.dart index 4dcfdc51f..9bd563653 100644 --- a/test/features/earthquake/domain/eew_feed_test.dart +++ b/test/features/earthquake/domain/eew_feed_test.dart @@ -15,6 +15,8 @@ Eew _report( String agency = 'cwa', int status = 0, double magnitude = 5.0, + double depth = 10, + DateTime? origin, }) => Eew( agency: agency, id: id, @@ -22,10 +24,10 @@ Eew _report( status: status, isFinal: false, info: EewInfo( - time: _t0.millisecondsSinceEpoch, + time: (origin ?? _t0).millisecondsSinceEpoch, longitude: 121.5, latitude: 23.8, - depth: 10, + depth: depth, magnitude: magnitude, location: '花蓮縣', max: 4, @@ -157,9 +159,8 @@ void main() { state = reduceEewFeed(state, const [], _t0.add(EewFeedState.memory)); expect(state.trackOf(_report(3).eventKey), isNull); // Nothing left to hold it back: a later listing is taken as it comes. - state = reduceEewFeed(state, [ - _report(2), - ], _t0.add(EewFeedState.memory + const Duration(seconds: 1))); + final later = _t0.add(EewFeedState.memory + const Duration(seconds: 1)); + state = reduceEewFeed(state, [_report(2, origin: later)], later); expect(state.visible.single.serial, 2); }); @@ -170,4 +171,118 @@ void main() { expect(state.trackOf(_report(1).eventKey)!.firstSeen, _t0); }); }); + + group('expiry backstop', () { + /// Lists [report] continuously from its origin and reports whether it is + /// still shown at [after] — the server never drops it, which is the case + /// the backstop exists for. + bool shownAt(Eew report, Duration after) { + final origin = DateTime.fromMillisecondsSinceEpoch( + report.info.time, + isUtc: true, + ); + var state = reduceEewFeed(EewFeedState.initial, [report], origin); + state = reduceEewFeed(state, [report], origin.add(after)); + return state.visible.isNotEmpty; + } + + test('an ordinary event goes 250 s after its origin', () { + final report = _report(1); + expect(shownAt(report, const Duration(seconds: 249)), isTrue); + expect(shownAt(report, const Duration(seconds: 250)), isFalse); + expect(EewExpiry.lifetimeOf(report), EewExpiry.standard); + }); + + test('a deep, a large and a 警報 event each get 400 s', () { + for (final report in [ + _report(1, depth: 150), + _report(1, magnitude: 6.0), + _report(1, status: 1), + ]) { + expect(EewExpiry.lifetimeOf(report), EewExpiry.extended); + expect(shownAt(report, const Duration(seconds: 399)), isTrue); + expect(shownAt(report, const Duration(seconds: 400)), isFalse); + } + expect( + EewExpiry.lifetimeOf(_report(1, depth: 149.9)), + EewExpiry.standard, + ); + expect( + EewExpiry.lifetimeOf(_report(1, magnitude: 5.9)), + EewExpiry.standard, + ); + }); + + test('expiry is measured from the origin, not from arrival', () { + // An event first heard of 200 s after its origin still goes at 250 s. + final report = _report(1); + var state = reduceEewFeed(EewFeedState.initial, [ + report, + ], _t0.add(const Duration(seconds: 200))); + expect(state.visible, [report]); + state = reduceEewFeed(state, [ + report, + ], _t0.add(const Duration(seconds: 250))); + expect(state.visible, isEmpty); + }); + + test('a cancel stays up 180 s from when it was first seen, however often ' + 'the same frame repeats', () { + final live = _report(1); + final cancel = _report(2, status: 3); + var state = reduceEewFeed(EewFeedState.initial, [live], _t0); + final seen = _t0.add(const Duration(seconds: 20)); + state = reduceEewFeed(state, [cancel], seen); + for (var s = 1; s < 180; s++) { + state = reduceEewFeed(state, [cancel], seen.add(Duration(seconds: s))); + expect(state.visible, [cancel], reason: 'still up at +$s s'); + } + state = reduceEewFeed(state, [ + cancel, + ], seen.add(const Duration(seconds: 180))); + expect(state.visible, isEmpty); + expect(state.trackOf(cancel.eventKey)!.cancelledSince, seen); + }); + + test('an event first heard of already cancelled gets the same window', () { + final cancel = _report(2, status: 3); + var state = reduceEewFeed(EewFeedState.initial, [cancel], _t0); + state = reduceEewFeed(state, [ + cancel, + ], _t0.add(const Duration(seconds: 179))); + expect(state.visible, [cancel]); + state = reduceEewFeed(state, [ + cancel, + ], _t0.add(const Duration(seconds: 180))); + expect(state.visible, isEmpty); + }); + + test('an origin far in the future cannot hold an event past an hour', () { + final report = _report(1, origin: _t0.add(const Duration(days: 1))); + var state = reduceEewFeed(EewFeedState.initial, [report], _t0); + state = reduceEewFeed(state, [ + report, + ], _t0.add(const Duration(minutes: 59))); + expect(state.visible, [report]); + state = reduceEewFeed(state, [report], _t0.add(EewExpiry.hardCap)); + expect(state.visible, isEmpty); + }); + + test('an expired event stays remembered while it is listed, so the cap ' + 'is not re-armed by forgetting it', () { + final report = _report(1, origin: _t0.add(const Duration(days: 1))); + var state = reduceEewFeed(EewFeedState.initial, [report], _t0); + state = reduceEewFeed(state, [report], _t0.add(EewExpiry.hardCap * 3)); + expect(state.visible, isEmpty); + expect(state.trackOf(report.eventKey)!.firstSeen, _t0); + }); + + test('a newer serial cannot bring an expired event back', () { + var state = reduceEewFeed(EewFeedState.initial, [_report(1)], _t0); + state = reduceEewFeed(state, [ + _report(9), + ], _t0.add(const Duration(seconds: 300))); + expect(state.visible, isEmpty); + }); + }); } diff --git a/test/features/earthquake/eew_realtime_source_test.dart b/test/features/earthquake/eew_realtime_source_test.dart index 6361512af..eca22bafa 100644 --- a/test/features/earthquake/eew_realtime_source_test.dart +++ b/test/features/earthquake/eew_realtime_source_test.dart @@ -374,7 +374,9 @@ void main() { final source = EewRealtimeSource( () => stream.stream, cwaOnly: () => false, - now: () => DateTime.utc(2026, 10, 16), + // Half a minute after the fixtures' origin. + now: () => + DateTime.fromMillisecondsSinceEpoch(1700000030000, isUtc: true), ); addTearDown(source.dispose); await source.fetch(); // opens the connection @@ -397,6 +399,33 @@ void main() { final gone = await answer(source, stream, '[]'); expect(gone, isEmpty); }); + + test('a connection that stays open after the server stops updating ' + 'cannot keep an alert up past its expiry', () async { + final stream = StreamController.broadcast(); + addTearDown(stream.close); + var now = DateTime.fromMillisecondsSinceEpoch(1700000030000, isUtc: true); + final source = EewRealtimeSource( + () => stream.stream, + cwaOnly: () => false, + now: () => now, + ); + addTearDown(source.dispose); + await source.fetch(); + + final shown = await answer( + source, + stream, + frame([(id: 'a', serial: 1, status: 0)]), + ); + expect(shown, hasLength(1)); + + // No new frame: the channel's next polls are answered from the buffer. + now = DateTime.fromMillisecondsSinceEpoch(1700000249000, isUtc: true); + expect((await source.fetch()).valueOrNull, hasLength(1)); + now = DateTime.fromMillisecondsSinceEpoch(1700000250000, isUtc: true); + expect((await source.fetch()).valueOrNull, isEmpty); + }); }); } @@ -440,6 +469,10 @@ class _Fallback { return completer.future; }, elapsed: elapsed, + // Thirty seconds after [_wire]'s origin time: the alerts these tests + // poll are an earthquake in progress, not one the expiry rule retired. + now: () => + DateTime.fromMillisecondsSinceEpoch(1700000030000, isUtc: true), delay: (_) { final completer = Completer(); reconnects.add(completer); From 19711161673e33d64602e46ca1b639c44f038226 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:56:56 +0800 Subject: [PATCH 4/6] fix(eew): show and speak the alert that matters most first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 同時有多筆地震速報時,首頁與語音播報會優先顯示震波尚未抵達、所在地預估震度最高的那一筆,已取消的排在最後 Fix(en-US): with several earthquake alerts at once, Home and the spoken announcement now lead with the one whose S-wave has not reached you yet and that shakes your location hardest, with cancelled ones last --- lib/features/earthquake/domain/eew.dart | 5 + .../earthquake/domain/eew.freezed.dart | 49 +++--- lib/features/earthquake/domain/eew.g.dart | 2 + .../earthquake/domain/eew_priority.dart | 116 ++++++++++++++ .../monitor_eew_announcement_controller.dart | 17 +- .../presentation/eew_realtime_controller.dart | 7 +- .../pages/report_replay_page.dart | 16 ++ .../widgets/home_eew_section.dart | 52 +++--- .../widgets/home_monitor_banner.dart | 24 ++- .../widgets/rts_monitor_panel.dart | 41 +++++ .../earthquake/domain/eew_priority_test.dart | 148 ++++++++++++++++++ ...itor_eew_announcement_controller_test.dart | 96 ++++++++++-- .../eew_realtime_controller_test.dart | 16 +- .../widgets/home_eew_section_test.dart | 20 +++ .../widgets/home_monitor_banner_test.dart | 25 +++ .../widgets/rts_monitor_panel_eew_test.dart | 49 +++++- 16 files changed, 611 insertions(+), 72 deletions(-) create mode 100644 lib/features/earthquake/domain/eew_priority.dart create mode 100644 test/features/earthquake/domain/eew_priority_test.dart diff --git a/lib/features/earthquake/domain/eew.dart b/lib/features/earthquake/domain/eew.dart index 990c7b884..925be74e7 100644 --- a/lib/features/earthquake/domain/eew.dart +++ b/lib/features/earthquake/domain/eew.dart @@ -21,6 +21,11 @@ abstract class Eew with _$Eew { @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) required bool isFinal, @JsonKey(name: 'eq') required EewInfo info, + + /// When this report was issued (Unix ms, server-stamped) — the top-level + /// `time`, as opposed to the origin in [EewInfo.time]. Null when a payload + /// leaves it out; the newest-report ordering then falls back to origin. + @JsonKey(name: 'time') int? reportTime, }) = _Eew; factory Eew.fromJson(Map json) => _$EewFromJson(json); diff --git a/lib/features/earthquake/domain/eew.freezed.dart b/lib/features/earthquake/domain/eew.freezed.dart index 25dbb9e82..aec509b10 100644 --- a/lib/features/earthquake/domain/eew.freezed.dart +++ b/lib/features/earthquake/domain/eew.freezed.dart @@ -16,7 +16,10 @@ T _$identity(T value) => value; /// @nodoc mixin _$Eew { -@JsonKey(name: 'author') String get agency; String get id; int get serial; int get status;@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool get isFinal;@JsonKey(name: 'eq') EewInfo get info; +@JsonKey(name: 'author') String get agency; String get id; int get serial; int get status;@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool get isFinal;@JsonKey(name: 'eq') EewInfo get info;/// When this report was issued (Unix ms, server-stamped) — the top-level +/// `time`, as opposed to the origin in [EewInfo.time]. Null when a payload +/// leaves it out; the newest-report ordering then falls back to origin. +@JsonKey(name: 'time') int? get reportTime; /// Create a copy of Eew /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -29,16 +32,16 @@ $EewCopyWith get copyWith => _$EewCopyWithImpl(this as Eew, _$identity @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Eew&&(identical(other.agency, agency) || other.agency == agency)&&(identical(other.id, id) || other.id == id)&&(identical(other.serial, serial) || other.serial == serial)&&(identical(other.status, status) || other.status == status)&&(identical(other.isFinal, isFinal) || other.isFinal == isFinal)&&(identical(other.info, info) || other.info == info)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Eew&&(identical(other.agency, agency) || other.agency == agency)&&(identical(other.id, id) || other.id == id)&&(identical(other.serial, serial) || other.serial == serial)&&(identical(other.status, status) || other.status == status)&&(identical(other.isFinal, isFinal) || other.isFinal == isFinal)&&(identical(other.info, info) || other.info == info)&&(identical(other.reportTime, reportTime) || other.reportTime == reportTime)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,agency,id,serial,status,isFinal,info); +int get hashCode => Object.hash(runtimeType,agency,id,serial,status,isFinal,info,reportTime); @override String toString() { - return 'Eew(agency: $agency, id: $id, serial: $serial, status: $status, isFinal: $isFinal, info: $info)'; + return 'Eew(agency: $agency, id: $id, serial: $serial, status: $status, isFinal: $isFinal, info: $info, reportTime: $reportTime)'; } @@ -49,7 +52,7 @@ abstract mixin class $EewCopyWith<$Res> { factory $EewCopyWith(Eew value, $Res Function(Eew) _then) = _$EewCopyWithImpl; @useResult $Res call({ -@JsonKey(name: 'author') String agency, String id, int serial, int status,@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal,@JsonKey(name: 'eq') EewInfo info +@JsonKey(name: 'author') String agency, String id, int serial, int status,@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal,@JsonKey(name: 'eq') EewInfo info,@JsonKey(name: 'time') int? reportTime }); @@ -66,7 +69,7 @@ class _$EewCopyWithImpl<$Res> /// Create a copy of Eew /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? agency = null,Object? id = null,Object? serial = null,Object? status = null,Object? isFinal = null,Object? info = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? agency = null,Object? id = null,Object? serial = null,Object? status = null,Object? isFinal = null,Object? info = null,Object? reportTime = freezed,}) { return _then(Eew( agency: null == agency ? _self.agency : agency // ignore: cast_nullable_to_non_nullable as String,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable @@ -74,7 +77,8 @@ as String,serial: null == serial ? _self.serial : serial // ignore: cast_nullabl as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as int,isFinal: null == isFinal ? _self.isFinal : isFinal // ignore: cast_nullable_to_non_nullable as bool,info: null == info ? _self.info : info // ignore: cast_nullable_to_non_nullable -as EewInfo, +as EewInfo,reportTime: freezed == reportTime ? _self.reportTime : reportTime // ignore: cast_nullable_to_non_nullable +as int?, )); } /// Create a copy of Eew @@ -168,10 +172,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'author') String agency, String id, int serial, int status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal, @JsonKey(name: 'eq') EewInfo info)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: 'author') String agency, String id, int serial, int status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal, @JsonKey(name: 'eq') EewInfo info, @JsonKey(name: 'time') int? reportTime)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Eew() when $default != null: -return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_that.info);case _: +return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_that.info,_that.reportTime);case _: return orElse(); } @@ -189,10 +193,10 @@ return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_t /// } /// ``` -@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'author') String agency, String id, int serial, int status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal, @JsonKey(name: 'eq') EewInfo info) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: 'author') String agency, String id, int serial, int status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal, @JsonKey(name: 'eq') EewInfo info, @JsonKey(name: 'time') int? reportTime) $default,) {final _that = this; switch (_that) { case _Eew(): -return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_that.info);case _: +return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_that.info,_that.reportTime);case _: throw StateError('Unexpected subclass'); } @@ -209,10 +213,10 @@ return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_t /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'author') String agency, String id, int serial, int status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal, @JsonKey(name: 'eq') EewInfo info)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: 'author') String agency, String id, int serial, int status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal, @JsonKey(name: 'eq') EewInfo info, @JsonKey(name: 'time') int? reportTime)? $default,) {final _that = this; switch (_that) { case _Eew() when $default != null: -return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_that.info);case _: +return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_that.info,_that.reportTime);case _: return null; } @@ -224,7 +228,7 @@ return $default(_that.agency,_that.id,_that.serial,_that.status,_that.isFinal,_t @JsonSerializable() class _Eew implements Eew { - const _Eew({@JsonKey(name: 'author') required this.agency, required this.id, required this.serial, required this.status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) required this.isFinal, @JsonKey(name: 'eq') required this.info}); + const _Eew({@JsonKey(name: 'author') required this.agency, required this.id, required this.serial, required this.status, @JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) required this.isFinal, @JsonKey(name: 'eq') required this.info, @JsonKey(name: 'time') this.reportTime}); factory _Eew.fromJson(Map json) => _$EewFromJson(json); @override@JsonKey(name: 'author') final String agency; @@ -233,6 +237,10 @@ class _Eew implements Eew { @override final int status; @override@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) final bool isFinal; @override@JsonKey(name: 'eq') final EewInfo info; +/// When this report was issued (Unix ms, server-stamped) — the top-level +/// `time`, as opposed to the origin in [EewInfo.time]. Null when a payload +/// leaves it out; the newest-report ordering then falls back to origin. +@override@JsonKey(name: 'time') final int? reportTime; /// Create a copy of Eew /// with the given fields replaced by the non-null parameter values. @@ -247,16 +255,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Eew&&(identical(other.agency, agency) || other.agency == agency)&&(identical(other.id, id) || other.id == id)&&(identical(other.serial, serial) || other.serial == serial)&&(identical(other.status, status) || other.status == status)&&(identical(other.isFinal, isFinal) || other.isFinal == isFinal)&&(identical(other.info, info) || other.info == info)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Eew&&(identical(other.agency, agency) || other.agency == agency)&&(identical(other.id, id) || other.id == id)&&(identical(other.serial, serial) || other.serial == serial)&&(identical(other.status, status) || other.status == status)&&(identical(other.isFinal, isFinal) || other.isFinal == isFinal)&&(identical(other.info, info) || other.info == info)&&(identical(other.reportTime, reportTime) || other.reportTime == reportTime)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,agency,id,serial,status,isFinal,info); +int get hashCode => Object.hash(runtimeType,agency,id,serial,status,isFinal,info,reportTime); @override String toString() { - return 'Eew(agency: $agency, id: $id, serial: $serial, status: $status, isFinal: $isFinal, info: $info)'; + return 'Eew(agency: $agency, id: $id, serial: $serial, status: $status, isFinal: $isFinal, info: $info, reportTime: $reportTime)'; } @@ -267,7 +275,7 @@ abstract mixin class _$EewCopyWith<$Res> implements $EewCopyWith<$Res> { factory _$EewCopyWith(_Eew value, $Res Function(_Eew) _then) = __$EewCopyWithImpl; @override @useResult $Res call({ -@JsonKey(name: 'author') String agency, String id, int serial, int status,@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal,@JsonKey(name: 'eq') EewInfo info +@JsonKey(name: 'author') String agency, String id, int serial, int status,@JsonKey(name: 'final', fromJson: boolishInt, toJson: intFromBool) bool isFinal,@JsonKey(name: 'eq') EewInfo info,@JsonKey(name: 'time') int? reportTime }); @@ -284,7 +292,7 @@ class __$EewCopyWithImpl<$Res> /// Create a copy of Eew /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? agency = null,Object? id = null,Object? serial = null,Object? status = null,Object? isFinal = null,Object? info = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? agency = null,Object? id = null,Object? serial = null,Object? status = null,Object? isFinal = null,Object? info = null,Object? reportTime = freezed,}) { return _then(_Eew( agency: null == agency ? _self.agency : agency // ignore: cast_nullable_to_non_nullable as String,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable @@ -292,7 +300,8 @@ as String,serial: null == serial ? _self.serial : serial // ignore: cast_nullabl as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as int,isFinal: null == isFinal ? _self.isFinal : isFinal // ignore: cast_nullable_to_non_nullable as bool,info: null == info ? _self.info : info // ignore: cast_nullable_to_non_nullable -as EewInfo, +as EewInfo,reportTime: freezed == reportTime ? _self.reportTime : reportTime // ignore: cast_nullable_to_non_nullable +as int?, )); } diff --git a/lib/features/earthquake/domain/eew.g.dart b/lib/features/earthquake/domain/eew.g.dart index 26ce13c33..80859e72f 100644 --- a/lib/features/earthquake/domain/eew.g.dart +++ b/lib/features/earthquake/domain/eew.g.dart @@ -15,6 +15,7 @@ _Eew _$EewFromJson(Map json) => _Eew( status: (json['status'] as num).toInt(), isFinal: boolishInt(json['final']), info: EewInfo.fromJson(json['eq'] as Map), + reportTime: (json['time'] as num?)?.toInt(), ); Map _$EewToJson(_Eew instance) => { @@ -24,6 +25,7 @@ Map _$EewToJson(_Eew instance) => { 'status': instance.status, 'final': intFromBool(instance.isFinal), 'eq': instance.info.toJson(), + 'time': instance.reportTime, }; _EewInfo _$EewInfoFromJson(Map json) => _EewInfo( diff --git a/lib/features/earthquake/domain/eew_priority.dart b/lib/features/earthquake/domain/eew_priority.dart new file mode 100644 index 000000000..e923e32d2 --- /dev/null +++ b/lib/features/earthquake/domain/eew_priority.dart @@ -0,0 +1,116 @@ +/// Which of several simultaneous alerts matters most to the person looking — +/// the one the Home card shows and the monitor reads aloud. +/// +/// The feed's own order is no order at all: the edge builds each snapshot by +/// walking a hash table, so "the first alert" was whichever event happened to +/// hash first. With two events up at once — an aftershock during a main +/// shock, two agencies' reports of one sequence — that put a distant M4 on the +/// Home card while an M6 whose S wave had not reached the user yet sat second. +library; + +import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; +import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; + +/// One alert as [rankEews] weighed it. +class RankedEew { + const RankedEew(this.alert, {this.estimate}); + + final Eew alert; + + /// The shaking estimated at the observer — null without an observer, and + /// always null for a cancelled alert, which has none. + final EewLocalEstimate? estimate; + + /// When the S wave reaches the observer, or null without an [estimate]. + DateTime? get arrival { + final estimate = this.estimate; + if (estimate == null) return null; + return DateTime.fromMillisecondsSinceEpoch( + alert.info.time, + isUtc: true, + ).add(Duration(seconds: estimate.sArrivalSeconds)); + } + + /// Whether the S wave has reached the observer by [now]. False when that is + /// unknown. + bool arrivedAt(DateTime now) { + final arrival = this.arrival; + return arrival != null && !now.isBefore(arrival); + } +} + +/// [alerts] most-relevant first, for an observer at [observer] (null when +/// there is no point to estimate for) at [now]. +/// +/// Arrival is weighed only with both an [observer] and a [now]; without an +/// observer there is no arrival to judge and [now] may be left out. +/// +/// In order of precedence: +/// 1. an alert still in progress before a cancelled one; +/// 2. one whose S wave has not reached the observer yet before one whose has +/// — the only alerts there is still time to act on; +/// 3. the higher estimated shaking at the observer (the agency's own maximum +/// intensity when there is no observer); +/// 4. the newer report. +/// +/// Ties keep the feed's order, so the result is deterministic. +List rankEews( + List alerts, { + DateTime? now, + LatLng? observer, + SeismicTravelTimeTable? table, +}) { + final entries = [ + for (final (index, alert) in alerts.indexed) + ( + index: index, + ranked: RankedEew( + alert, + estimate: observer == null || alert.isCancelled + ? null + : estimateLocalShaking(alert, observer, table: table), + ), + ), + ]; + int byFlag(bool a, bool b) => a == b ? 0 : (a ? 1 : -1); + entries.sort((a, b) { + final x = a.ranked; + final y = b.ranked; + final cancelled = byFlag(x.alert.isCancelled, y.alert.isCancelled); + if (cancelled != 0) return cancelled; + if (now != null) { + final arrived = byFlag(x.arrivedAt(now), y.arrivedAt(now)); + if (arrived != 0) return arrived; + } + final shaking = _shaking(y).compareTo(_shaking(x)); + if (shaking != 0) return shaking; + final newer = _issued(y.alert).compareTo(_issued(x.alert)); + if (newer != 0) return newer; + return a.index.compareTo(b.index); + }); + return [for (final entry in entries) entry.ranked]; +} + +/// The most relevant of [alerts] (see [rankEews]), or null when there are +/// none. +Eew? primaryEew( + List alerts, { + DateTime? now, + LatLng? observer, + SeismicTravelTimeTable? table, +}) { + if (alerts.isEmpty) return null; + return rankEews( + alerts, + now: now, + observer: observer, + table: table, + ).first.alert; +} + +double _shaking(RankedEew ranked) => + ranked.estimate?.intensity ?? ranked.alert.info.max.toDouble(); + +int _issued(Eew alert) => alert.reportTime ?? alert.info.time; diff --git a/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart b/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart index fa2fde7a3..258b85bda 100644 --- a/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart +++ b/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart @@ -14,6 +14,7 @@ import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/speech/speech_service.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; /// A shaking scale together with whether it is local or the max fallback. typedef SpokenEewEstimate = ({int scale, bool isLocal}); @@ -21,7 +22,8 @@ typedef SpokenEewEstimate = ({int scale, bool isLocal}); /// Resolves the phrase after a local/fallback estimate has been selected. typedef EewSpeechFormatter = String Function(SpokenEewEstimate estimate); -/// Announces each new active EEW serial while the monitor is visible. +/// Announces each new active EEW serial while the monitor is visible — of +/// the most relevant alert only, when several are up. /// /// Every accepted update stops the previous utterance immediately. Async /// estimate/speech completions carry a generation, so an obsolete report can @@ -31,6 +33,7 @@ class MonitorEewAnnouncementController { this._speech, this._gate, this._estimate, { + Eew Function(List alerts)? primary, // Android's system engine can spend several seconds starting an utterance. // The stock Google zh-TW voice did not finish even inside five seconds on // the emulator, while en-US and ja-JP did. Eight still bounds a wedged @@ -38,13 +41,21 @@ class MonitorEewAnnouncementController { // playback has its own, slightly longer safety fallback in the foreground // gate, so a healthy slow voice never overlaps the alarm. this.speechTimeout = const Duration(seconds: 8), - }); + }) : _primary = primary ?? _rankedFirst; + + static Eew _rankedFirst(List alerts) => primaryEew(alerts)!; final SpeechService _speech; final ForegroundEewAnnouncementGate _gate; final Future Function(Eew alert) _estimate; final Duration speechTimeout; + /// Picks the one alert to speak from the uncancelled ones (never empty). + /// The caller's [rankEews] — it alone knows where the listener is and what + /// time it is on the timeline being watched; the default ranks without an + /// observer (highest maximum intensity, then newest report). + final Eew Function(List alerts) _primary; + /// The highest serial announced per event ([EewIdentityX.eventKey]). final Map _seenSerials = {}; bool _active = false; @@ -97,7 +108,7 @@ class MonitorEewAnnouncementController { _silence(); } - final alert = alerts.first; + final alert = _primary(alerts); final key = alert.eventKey; final previous = _seenSerials[key]; if (previous != null && alert.serial <= previous) return; diff --git a/lib/features/earthquake/presentation/eew_realtime_controller.dart b/lib/features/earthquake/presentation/eew_realtime_controller.dart index fd6a56324..459fa4cd0 100644 --- a/lib/features/earthquake/presentation/eew_realtime_controller.dart +++ b/lib/features/earthquake/presentation/eew_realtime_controller.dart @@ -1,6 +1,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; /// Provider-facing view of the live EEW feed: a distinct [ChangeNotifier] type /// widgets can `watch`, with EEW-specific getters over the realtime state. @@ -13,8 +14,10 @@ class EewRealtimeController extends RealtimeNotifier> { /// Active EEW alerts, newest first; empty when there is no alert. List get alerts => state.data ?? const []; - /// The most relevant alert, or null when there is none. - Eew? get primaryAlert => alerts.isEmpty ? null : alerts.first; + /// The most relevant alert, or null when there is none — ranked without an + /// observer ([primaryEew]): an alert in progress before a cancelled one, + /// then the highest maximum intensity, then the newest report. + Eew? get primaryAlert => primaryEew(alerts); /// Current feed freshness. RealtimeStatus get status => state.status; diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 0178ada3b..441c13a22 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -31,6 +31,7 @@ import 'package:dpip/features/earthquake/domain/eew_town_levels.dart'; import 'package:dpip/shared/seismic/spoken_intensity.dart'; import 'package:dpip/features/earthquake/domain/monitor_eew_announcement_controller.dart'; import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; import 'package:dpip/core/speech/speech_service.dart'; import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; @@ -215,6 +216,11 @@ class _ReportReplayPageState extends State { final speech = context.read(); if (speech == null) return null; final location = context.read(); + unawaited( + location?.lastKnownFix().then((fix) { + if (fix != null) _rankObserver = geo.LatLng(fix.lat, fix.lng); + }), + ); return MonitorEewAnnouncementController( speech, // A gate of this page's own, never NotificationService's. A replay @@ -235,9 +241,19 @@ class _ReportReplayPageState extends State { ); return (scale: estimate.scale, isLocal: true); }, + // The live monitor's choice, judged on the replay's own timeline: an + // arrival is "still to come" against the replay clock, not real now. + primary: (alerts) => primaryEew( + alerts, + now: _session.clock.now(), + observer: _rankObserver, + )!, ); } + /// The listener's last cached fix, for choosing which alert to speak. + geo.LatLng? _rankObserver; + void _syncAnnouncement() { final controller = _announcement; final l10n = _l10n; diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index ad53ab016..f63ba82f5 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -31,7 +31,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; -import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -79,20 +79,14 @@ class HomeEewSection extends StatelessWidget { alerts.isEmpty) { return const SizedBox.shrink(); } - // An alert still in progress outranks a cancelled one; a cancelled card - // shows only when nothing else is left to warn about. - final alert = alerts.firstWhere( - (alert) => !alert.isCancelled, - orElse: () => alerts.first, - ); - return _EewSectionBody(alert: alert); + return _EewSectionBody(alerts: alerts); } } class _EewSectionBody extends StatelessWidget { - const _EewSectionBody({required this.alert}); + const _EewSectionBody({required this.alerts}); - final Eew alert; + final List alerts; @override Widget build(BuildContext context) { @@ -107,7 +101,7 @@ class _EewSectionBody extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () => context.pushNamed(AppRoutes.eew), - child: _EewAlertCard(alert: alert), + child: _EewAlertCard(alerts: alerts), ), ); } @@ -116,10 +110,14 @@ class _EewSectionBody extends StatelessWidget { /// The home sheet's alert card — a condensed version of the monitor page's /// `EewCard`, sharing its domain math. The S-wave countdown ticks against the /// calibrated [AppTime] clock and stops updating on dispose. +/// +/// Shows one alert: the most relevant of [alerts] to where the user is +/// ([rankEews]) — re-chosen every tick, because "whose S wave has not arrived +/// yet" changes with the clock, not with the feed. class _EewAlertCard extends StatefulWidget { - const _EewAlertCard({required this.alert}); + const _EewAlertCard({required this.alerts}); - final Eew alert; + final List alerts; @override State<_EewAlertCard> createState() => _EewAlertCardState(); @@ -158,7 +156,6 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final info = widget.alert.info; // The observer point is the user's GPS fix when one is available, else the // selected township's centroid — same resolution the home weather uses. @@ -169,13 +166,22 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { ? null : context.read().byCode(code); final observer = _fix ?? (town == null ? null : LatLng(town.lat, town.lng)); - // A cancelled event has no shaking to estimate and no wave to count down - // to — computing one would put a number on screen for an earthquake the - // agency has just said is not happening. - final cancelled = widget.alert.isCancelled; - final estimate = observer == null || cancelled - ? null - : estimateLocalShaking(widget.alert, observer, table: _table); + // The ranking estimates each alert at the observer anyway, so the chosen + // one's estimate is reused rather than computed twice. A cancelled event + // has none — computing one would put a number on screen for an earthquake + // the agency has just said is not happening — and ranks last, so it is + // shown only when nothing still in progress is left. + final now = AppTime.utc; + final primary = rankEews( + widget.alerts, + now: now, + observer: observer, + table: _table, + ).first; + final alert = primary.alert; + final info = alert.info; + final cancelled = alert.isCancelled; + final estimate = primary.estimate; final originUtc = DateTime.fromMillisecondsSinceEpoch(info.time); final maxIntensity = Intensity.displayForReport(info.max, originUtc); @@ -183,7 +189,7 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { ? null : originUtc .add(Duration(seconds: estimate.sArrivalSeconds)) - .difference(AppTime.utc) + .difference(now) .inSeconds; final arrived = remaining != null && remaining <= 0; @@ -229,7 +235,7 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { ), ), Text( - l10n.eewSerial(widget.alert.serial), + l10n.eewSerial(alert.serial), style: theme.textTheme.labelSmall?.copyWith( color: colors.onSurfaceVariant, ), diff --git a/lib/features/home/presentation/widgets/home_monitor_banner.dart b/lib/features/home/presentation/widgets/home_monitor_banner.dart index d9e900588..fe87668ee 100644 --- a/lib/features/home/presentation/widgets/home_monitor_banner.dart +++ b/lib/features/home/presentation/widgets/home_monitor_banner.dart @@ -25,9 +25,14 @@ library; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; @@ -71,12 +76,19 @@ class HomeMonitorBanner extends StatelessWidget { final eew = context.watch>>(); final alerts = eew.state.data ?? const []; - // An alert still in progress outranks a cancelled one; the banner turns - // neutral and says 已取消 only when nothing else is left to warn about. - final alert = alerts.firstWhere( - (alert) => !alert.isCancelled, - orElse: () => alerts.first, - ); + // The same ranking the Home card uses ([rankEews]), at the selected + // township — this banner resolves no GPS fix of its own. An alert still in + // progress outranks a cancelled one, so the banner turns neutral and says + // 已取消 only when nothing else is left to warn about. + final code = context.watch().selectedCode; + final town = code == null + ? null + : context.read().byCode(code); + final alert = primaryEew( + alerts, + now: AppTime.utc, + observer: town == null ? null : LatLng(town.lat, town.lng), + )!; final cancelled = alert.isCancelled; final ink = cancelled ? colors.onSurface : colors.onErrorContainer; diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 4aa19c379..ce858162f 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -14,11 +14,15 @@ import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/geo/location_service.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/models/lat_lng.dart'; import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/speech/speech_service.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; +import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; @@ -85,6 +89,15 @@ class _RtsMonitorPanelState extends State AppLifecycleState? _lifecycleState; bool _demoWarningSubmitted = false; + /// Where the listener is, for choosing which alert to speak: the OS's last + /// cached fix once one has been read, the selected township until then. + /// Speech cannot wait on a live GPS fix, and neither can the choice. + LatLng? _lastFix; + + /// The CWA travel-time table once loaded, so the alert chosen to speak is + /// judged on the same arrival the cards count down to. + SeismicTravelTimeTable? _table; + void _onData() { _syncAnnouncement(); if (_visible && mounted) setState(() {}); @@ -147,6 +160,14 @@ class _RtsMonitorPanelState extends State final notifications = context.read(); if (speech == null || notifications == null) return null; final location = context.read(); + unawaited( + location.lastKnownFix().then((fix) { + if (fix != null) _lastFix = LatLng(fix.lat, fix.lng); + }), + ); + context.read>().then((table) { + _table = table; + }); return MonitorEewAnnouncementController( speech, notifications.foregroundEewGate, @@ -157,12 +178,32 @@ class _RtsMonitorPanelState extends State if (fix == null) { return (scale: alert.info.max.clamp(0, 9), isLocal: false); } + _lastFix = LatLng(fix.lat, fix.lng); final estimate = estimateLocalShaking(alert, LatLng(fix.lat, fix.lng)); return (scale: estimate.scale, isLocal: true); }, + // The same choice the Home card makes: the alert whose S wave is still + // on its way to the listener, then the strongest there, then the newest. + primary: (alerts) => primaryEew( + alerts, + now: AppTime.utc, + observer: _rankObserver(), + table: _table, + )!, ); } + /// [_lastFix], or the selected township's centroid without one. + LatLng? _rankObserver() { + final fix = _lastFix; + if (fix != null || !mounted) return fix; + final code = context.read().selectedCode; + final town = code == null + ? null + : context.read().byCode(code); + return town == null ? null : LatLng(town.lat, town.lng); + } + void _syncVisibility() { final visible = _visibleTab?.isOnScreen(MapPage.tabIndex) ?? true; if (visible == _visible) return; diff --git a/test/features/earthquake/domain/eew_priority_test.dart b/test/features/earthquake/domain/eew_priority_test.dart new file mode 100644 index 000000000..d941d2d6c --- /dev/null +++ b/test/features/earthquake/domain/eew_priority_test.dart @@ -0,0 +1,148 @@ +/// Pins which of several simultaneous alerts the Home card shows and the +/// monitor speaks: in progress before cancelled, still-to-arrive before +/// arrived, stronger before weaker, newer before older. +library; + +import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_priority.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final DateTime _now = DateTime.utc(2026, 10, 16, 3); + +/// Taipei — about 125 km from the Hualien epicentre below. +const LatLng _taipei = LatLng(25.03, 121.52); + +Eew _alert( + String id, { + LatLng epicentre = const LatLng(23.9, 121.6), + Duration age = Duration.zero, + double magnitude = 6.0, + int max = 5, + int status = 0, + int? reportTime, +}) => Eew( + agency: 'cwa', + id: id, + serial: 1, + status: status, + isFinal: false, + reportTime: reportTime, + info: EewInfo( + time: _now.subtract(age).millisecondsSinceEpoch, + longitude: epicentre.longitude, + latitude: epicentre.latitude, + depth: 10, + magnitude: magnitude, + location: id, + max: max, + ), +); + +List _ids(List ranked) => [ + for (final entry in ranked) entry.alert.id, +]; + +void main() { + test('nothing ranks to nothing', () { + expect(rankEews(const [], now: _now), isEmpty); + expect(primaryEew(const [], now: _now), isNull); + }); + + test('a cancelled alert ranks after every alert still in progress, however ' + 'strong it was', () { + final ranked = rankEews([ + _alert('cancelled', max: 7, magnitude: 7, status: 3), + _alert('weak', max: 2, magnitude: 4), + ], now: _now); + expect(_ids(ranked), ['weak', 'cancelled']); + expect(ranked.last.estimate, isNull); + }); + + test('a cancelled alert has no estimate even with an observer', () { + final ranked = rankEews( + [_alert('gone', status: 2)], + now: _now, + observer: _taipei, + ); + expect(ranked.single.estimate, isNull); + expect(ranked.single.arrival, isNull); + }); + + test('with an observer, an S wave still on its way outranks a stronger one ' + 'that has already arrived', () { + final arrived = _alert( + 'arrived', + epicentre: const LatLng(25.0, 121.5), // under Taipei + age: const Duration(seconds: 60), + magnitude: 6.5, + ); + final coming = _alert('coming', magnitude: 5.0); // Hualien, just now + final ranked = rankEews([arrived, coming], now: _now, observer: _taipei); + expect(_ids(ranked), ['coming', 'arrived']); + expect(ranked.first.arrivedAt(_now), isFalse); + expect(ranked.last.arrivedAt(_now), isTrue); + expect( + ranked.last.estimate!.intensity, + greaterThan(ranked.first.estimate!.intensity), + reason: 'the arrived one really was the stronger here', + ); + }); + + test('among alerts still to arrive, the stronger shaking at the observer ' + 'comes first', () { + final near = _alert('near', epicentre: const LatLng(24.9, 121.6)); + final far = _alert('far', epicentre: const LatLng(22.0, 120.8)); + expect(_ids(rankEews([far, near], now: _now, observer: _taipei)), [ + 'near', + 'far', + ]); + }); + + test('without an observer, the agency maximum intensity decides', () { + expect(_ids(rankEews([_alert('four', max: 4), _alert('six', max: 6)])), [ + 'six', + 'four', + ]); + }); + + test('an equal tie goes to the newer report, then keeps feed order', () { + final older = _alert('older', reportTime: 1000); + final newer = _alert('newer', reportTime: 2000); + expect(_ids(rankEews([older, newer], now: _now)), ['newer', 'older']); + // No report time: the newer origin stands in for it. + final early = _alert('early', age: const Duration(seconds: 20)); + final late = _alert('late', age: const Duration(seconds: 5)); + expect(_ids(rankEews([early, late], now: _now)), ['late', 'early']); + // Identical in every respect: the feed's order. + expect(_ids(rankEews([_alert('a'), _alert('b'), _alert('c')], now: _now)), [ + 'a', + 'b', + 'c', + ]); + }); + + test('without a clock, arrival is not weighed', () { + final arrived = _alert( + 'arrived', + epicentre: const LatLng(25.0, 121.5), + age: const Duration(seconds: 60), + ); + final coming = _alert('coming', magnitude: 5.0); + expect(_ids(rankEews([coming, arrived], observer: _taipei)), [ + 'arrived', + 'coming', + ]); + }); + + test('primaryEew is the head of the ranking', () { + expect( + primaryEew([ + _alert('cancelled', status: 3), + _alert('live'), + ], now: _now)!.id, + 'live', + ); + expect(primaryEew([_alert('only', status: 3)])!.id, 'only'); + }); +} diff --git a/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart b/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart index b64bfe64d..8e1e17ff1 100644 --- a/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart +++ b/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart @@ -30,22 +30,23 @@ class _FakeSpeech implements SpeechService { void dispose() {} } -Eew _alert(int serial, {String id = 'event', int status = 0}) => Eew( - agency: 'CWA', - id: id, - serial: serial, - status: status, - isFinal: false, - info: const EewInfo( - time: 0, - longitude: 121, - latitude: 23, - depth: 10, - magnitude: 6, - location: 'test', - max: 6, - ), -); +Eew _alert(int serial, {String id = 'event', int status = 0, int max = 6}) => + Eew( + agency: 'CWA', + id: id, + serial: serial, + status: status, + isFinal: false, + info: EewInfo( + time: 0, + longitude: 121, + latitude: 23, + depth: 10, + magnitude: 6, + location: 'test', + max: max, + ), + ); RealtimeState> _live(Eew alert) => RealtimeState(status: RealtimeStatus.live, data: [alert]); @@ -317,4 +318,67 @@ void main() { controller.dispose(); }); }); + + group('which alert is spoken', () { + test( + 'by default, the strongest of several — not the feed\'s first', + () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (alert) async => (scale: alert.info.max, isLocal: false), + ); + controller.setActive(true); + controller.update( + RealtimeState( + status: RealtimeStatus.live, + data: [ + _alert(1, id: 'weak', max: 3), + _alert(1, id: 'strong'), + ], + ), + languageTag: 'zh-TW', + format: (estimate) => 'max${estimate.scale}', + ); + await _flush(); + expect(speech.spoken, ['zh-TW:max6']); + controller.dispose(); + }, + ); + + test('the caller\'s ranking decides when it supplies one', () async { + final speech = _FakeSpeech(); + final seen = >[]; + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (alert) async => (scale: alert.info.max, isLocal: false), + primary: (alerts) { + seen.add([for (final a in alerts) a.id]); + return alerts.last; + }, + ); + controller.setActive(true); + controller.update( + RealtimeState( + status: RealtimeStatus.live, + data: [ + _alert(1, id: 'gone', status: 3), + _alert(1, id: 'a', max: 6), + _alert(1, id: 'b', max: 2), + ], + ), + languageTag: 'zh-TW', + format: (estimate) => 'max${estimate.scale}', + ); + await _flush(); + // Cancelled alerts never reach the ranking. + expect(seen, [ + ['a', 'b'], + ]); + expect(speech.spoken, ['zh-TW:max2']); + controller.dispose(); + }); + }); } diff --git a/test/features/earthquake/presentation/eew_realtime_controller_test.dart b/test/features/earthquake/presentation/eew_realtime_controller_test.dart index 063fa1224..edca6da46 100644 --- a/test/features/earthquake/presentation/eew_realtime_controller_test.dart +++ b/test/features/earthquake/presentation/eew_realtime_controller_test.dart @@ -137,7 +137,7 @@ void main() { ); test( - 'primaryAlert is the first (most relevant) alert when several are active', + 'primaryAlert keeps the feed order between alerts that rank equally', () async { final first = _eew('first'); final second = _eew('second'); @@ -150,6 +150,20 @@ void main() { }, ); + test('primaryAlert is the most relevant alert, not the feed\'s first — a ' + 'cancelled one last, then the strongest', () async { + final cancelled = _eew('cancelled').copyWith(status: 3); + final weak = _eew('weak'); + final strong = _eew('strong') + .copyWith(info: _eew('strong').info.copyWith(max: 6)); + source.next = Ok([cancelled, weak, strong]); + + await channel.refreshNow(); + await pump(); + + expect(controller.primaryAlert, strong); + }); + test('isStale reflects the channel status once data has aged', () async { source.next = const Ok([]); await channel.refreshNow(); diff --git a/test/features/home/presentation/widgets/home_eew_section_test.dart b/test/features/home/presentation/widgets/home_eew_section_test.dart index efc642103..467625e2d 100644 --- a/test/features/home/presentation/widgets/home_eew_section_test.dart +++ b/test/features/home/presentation/widgets/home_eew_section_test.dart @@ -403,6 +403,26 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + testWidgets('of several alerts, the card shows the most relevant one, not ' + 'the feed\'s first', (tester) async { + final setup = await _liveNotifier([ + _alert().copyWith( + id: 'weak', + info: _alert().info.copyWith(location: '宜蘭縣', max: 2), + ), + _alert().copyWith( + id: 'strong', + info: _alert().info.copyWith(location: '臺東縣', max: 6), + ), + ]); + final store = await _store(); + await tester.pumpWidget(_wrap(setup.notifier, store)); + + expect(find.text('臺東縣'), findsOneWidget); + expect(find.text('宜蘭縣'), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('a fix that arrives after dispose does not rebuild', ( tester, ) async { diff --git a/test/features/home/presentation/widgets/home_monitor_banner_test.dart b/test/features/home/presentation/widgets/home_monitor_banner_test.dart index 5f1f0b037..5a137cdbf 100644 --- a/test/features/home/presentation/widgets/home_monitor_banner_test.dart +++ b/test/features/home/presentation/widgets/home_monitor_banner_test.dart @@ -6,6 +6,9 @@ import 'package:dpip/core/realtime/realtime_config.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_source.dart'; import 'package:dpip/core/realtime/ticker.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/home/presentation/widgets/home_monitor_banner.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -105,6 +108,10 @@ Widget _wrap(RealtimeNotifier> notifier, MapCameraHandoff handoff) => value: notifier, ), ChangeNotifierProvider.value(value: handoff), + ChangeNotifierProvider( + create: (_) => RegionStore(SettingsStore.inMemory({})), + ), + Provider.value(value: const TownDirectory({})), ], child: MaterialApp.router( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -205,4 +212,22 @@ void main() { expect(find.text('臺東縣'), findsNothing); expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); }); + + testWidgets('of several alerts, the banner names the most relevant one', ( + tester, + ) async { + final weak = _alert().copyWith( + id: 'weak', + info: _alert().info.copyWith(location: '宜蘭縣', max: 2), + ); + final strong = _alert().copyWith( + id: 'strong', + info: _alert().info.copyWith(location: '花蓮縣', max: 6), + ); + final notifier = await _notifier([weak, strong]); + await tester.pumpWidget(_wrap(notifier, MapCameraHandoff())); + + expect(find.text('花蓮縣'), findsOneWidget); + expect(find.text('宜蘭縣'), findsNothing); + }); } diff --git a/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart b/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart index 9225ba9f8..16297ff7a 100644 --- a/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart +++ b/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart @@ -22,6 +22,7 @@ import 'package:dpip/features/map/presentation/pages/map_page.dart'; import 'package:dpip/features/map/presentation/widgets/rts_monitor_panel.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/seismic/spoken_intensity.dart'; import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -171,9 +172,17 @@ Widget _wrap( ChangeNotifierProvider>>.value(value: eew), ChangeNotifierProvider.value(value: store), Provider.value(value: const TownDirectory({})), + // A real (if tiny) table: choosing which alert to speak estimates + // each one at the listener's cached fix. Provider>.value( value: Future.value( - const SeismicTravelTimeTable({}), + const SeismicTravelTimeTable({ + 10: [ + (p: 0, r: 0, s: 0), + (p: 10, r: 50, s: 20), + (p: 40, r: 200, s: 80), + ], + }), ), ), Provider.value( @@ -514,4 +523,42 @@ void main() { await tester.pump(); await tester.pumpWidget(const SizedBox()); }); + + testWidgets('of several alerts, the monitor speaks the most relevant one', ( + tester, + ) async { + final settings = SettingsStore.inMemory({}); + await settings.setBool(SettingKeys.eewSpokenAnnouncement, true); + final speech = _Speech(); + final weak = _alert(id: 'weak'); + final strong = _alert(id: 'strong'); + final feeds = await _liveFeeds( + alerts: [ + weak.copyWith(info: weak.info.copyWith(max: 2)), + strong.copyWith(info: strong.info.copyWith(max: 6)), + ], + ); + await tester.pumpWidget( + _wrap( + feeds.rts, + feeds.eew, + await _store(), + speech: speech, + notifications: NotificationService(settings), + speechSettings: EewSpokenAnnouncementSettings(settings), + visibleTab: VisibleTab(MapPage.tabIndex), + ), + ); + await tester.pump(); + + // No cached fix and no township: the max-intensity phrase, for the + // stronger alert even though the feed listed the weaker one first. + final l10n = AppLocalizations.of( + tester.element(find.byType(RtsMonitorPanel)), + ); + expect(speech.spoken, [ + 'en:${l10n.eewSpokenMaxIntensity(spokenIntensityLabel(6, 'en'))}', + ]); + await tester.pumpWidget(const SizedBox()); + }); } From 2f43c329aa04083403db0ff00d0791eca837056d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 07:06:01 +0800 Subject: [PATCH 5/6] fix(eew): say when the earthquake alert feed is down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 地震速報連線中斷時,首頁與強震監視器會顯示「地震速報連線中斷,重新連線中」,不再以空白呈現而被誤認為平安無事 Fix(en-US): Home and the seismic monitor now say when the earthquake alert connection is lost, instead of showing an empty space that reads as all-clear --- .../presentation/widgets/home_content.dart | 3 +- .../widgets/home_eew_section.dart | 20 +- .../widgets/home_monitor_banner.dart | 179 ++++++++++-------- .../widgets/rts_monitor_panel.dart | 100 ++++++---- lib/l10n/app_en.arb | 4 + lib/l10n/app_fil.arb | 3 +- lib/l10n/app_id.arb | 3 +- lib/l10n/app_ja.arb | 3 +- lib/l10n/app_ko.arb | 3 +- lib/l10n/app_th.arb | 3 +- lib/l10n/app_vi.arb | 3 +- lib/l10n/app_yue.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/l10n/app_zh_Hans.arb | 3 +- lib/l10n/app_zh_Hant_HK.arb | 3 +- lib/l10n/app_zh_TW.arb | 3 +- lib/l10n/gen/app_localizations.dart | 6 + lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 4 + lib/l10n/gen/app_localizations_id.dart | 4 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 4 + lib/l10n/gen/app_localizations_vi.dart | 3 + lib/l10n/gen/app_localizations_yue.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 ++ lib/shared/widgets/eew_feed_down_notice.dart | 116 ++++++++++++ .../widgets/home_eew_section_test.dart | 57 +++++- .../widgets/home_monitor_banner_test.dart | 33 ++++ .../widgets/rts_monitor_panel_eew_test.dart | 10 + .../widgets/eew_feed_down_notice_test.dart | 83 ++++++++ 31 files changed, 543 insertions(+), 140 deletions(-) create mode 100644 lib/shared/widgets/eew_feed_down_notice.dart create mode 100644 test/shared/widgets/eew_feed_down_notice_test.dart diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 7422c5804..d76f7b029 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -447,7 +447,8 @@ class HomeContent extends StatelessWidget { if (expanded) ...[ const HomeEewSection(), // The section renders nothing when calm — only reserve - // the gap below it while an alert is actually showing, + // the gap below it while an alert (or the notice that + // the feed is down) is actually showing, // or a calm dashboard gains an extra empty lg here on // top of the one already before this section. if (HomeEewSection.isActive(context)) diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index f63ba82f5..93e9a6bf9 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -9,9 +9,11 @@ /// /// Gated on feed liveness as well as alert presence: a stale/offline feed's /// alerts may no longer describe the current situation, and a safety feed that -/// aged out must never present its last snapshot as now. Calm state (no alert, -/// or an unverified feed) renders nothing at all, so the sheet's ordinary layout -/// is untouched outside an earthquake. +/// aged out must never present its last snapshot as now — so a stale/offline +/// feed shows [EewFeedDownNotice] instead of any alert, and never nothing, +/// which would read as calm. Calm (a live feed with no alert, or one still +/// making its first connection) renders nothing at all, so the sheet's +/// ordinary layout is untouched outside an earthquake. /// /// A cancelled alert is shown as cancelled — a neutral card with no severity /// border, no local estimate and no countdown — and only while nothing still @@ -39,6 +41,7 @@ import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/eew_cancelled_notice.dart'; import 'package:dpip/shared/widgets/eew_estimate_tile.dart'; +import 'package:dpip/shared/widgets/eew_feed_down_notice.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -60,6 +63,7 @@ class HomeEewSection extends StatelessWidget { // answer flips. context.select>, bool>((controller) { final state = controller.state; + if (EewFeedDownNotice.isDown(state.status)) return true; final alerts = state.data; return state.status == RealtimeStatus.live && alerts != null && @@ -71,9 +75,13 @@ class HomeEewSection extends StatelessWidget { final controller = context.watch>>(); final state = controller.state; final alerts = state.data; - // No alert, or a feed that has aged past live — render nothing. This keeps - // the stale case from ever masquerading as a current alert on the home - // surface (the monitor page shows the freshness banner instead). + // A feed that has aged past live never shows its alerts — the stale case + // must not masquerade as a current alert — but it does not go quiet + // either: calm renders nothing, so a silent dead feed would read as calm. + if (EewFeedDownNotice.isDown(state.status)) { + return EewFeedDownNotice(status: state.status); + } + // No alert — render nothing. if (state.status != RealtimeStatus.live || alerts == null || alerts.isEmpty) { diff --git a/lib/features/home/presentation/widgets/home_monitor_banner.dart b/lib/features/home/presentation/widgets/home_monitor_banner.dart index fe87668ee..83aff4767 100644 --- a/lib/features/home/presentation/widgets/home_monitor_banner.dart +++ b/lib/features/home/presentation/widgets/home_monitor_banner.dart @@ -16,6 +16,10 @@ /// happens to sit up here," not a raw coloured bar glued to the top of the /// screen. /// +/// While the EEW feed is stale or offline the slot says so instead +/// ([EewFeedDownNotice]): an empty slot is what calm looks like, and a dead +/// feed must not look like calm. +/// /// A cancelled alert keeps the banner up for as long as it stays in the list, /// but on neutral surface colours with 已取消 in place of the report number: /// the user who saw the warning arrive needs to see it withdrawn, and a @@ -37,6 +41,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/widgets/eew_feed_down_notice.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -54,7 +59,8 @@ class HomeMonitorBanner extends StatelessWidget { /// appear at any moment and the framing must already have room for it. static const double height = 44; - /// Whether an alert is live right now — the same check [build] gates on, + /// Whether the banner shows anything right now — an alert on a live feed, + /// or the notice that the feed is down — the same check [build] gates on, /// exposed so a caller that needs to lay out *around* this banner (Home's /// overlap stack with the gold support bar) reads the identical condition /// instead of re-deriving it and risking the two disagreeing. @@ -63,8 +69,10 @@ class HomeMonitorBanner extends StatelessWidget { // around the banner, and a watch there rebuilt it on every EEW serial // update. The boolean is the same; only a flip rebuilds the caller. context.select>, bool>((eew) { + final status = eew.state.status; + if (EewFeedDownNotice.isDown(status)) return true; final alerts = eew.state.data ?? const []; - return eew.state.status == RealtimeStatus.live && alerts.isNotEmpty; + return status == RealtimeStatus.live && alerts.isNotEmpty; }); @override @@ -75,6 +83,15 @@ class HomeMonitorBanner extends StatelessWidget { if (!isActive(context)) return const SizedBox.shrink(); final eew = context.watch>>(); + final status = eew.state.status; + if (EewFeedDownNotice.isDown(status)) { + // The calm banner slot is empty, and an empty slot reads as "no + // earthquake" — say the feed is down instead. Not tappable: the + // monitor would only say the same thing. + return _slide( + child: EewFeedDownNotice(status: status, elevation: 8, maxLines: 1), + ); + } final alerts = eew.state.data ?? const []; // The same ranking the Home card uses ([rankEews]), at the selected // township — this banner resolves no GPS fix of its own. An alert still in @@ -92,99 +109,105 @@ class HomeMonitorBanner extends StatelessWidget { final cancelled = alert.isCancelled; final ink = cancelled ? colors.onSurface : colors.onErrorContainer; - return IgnorePointer( - ignoring: dismiss > 0.5, - child: Opacity( - opacity: 1 - dismiss, - child: FractionalTranslation( - translation: Offset(0, -dismiss), + return _slide( + child: Material( + color: cancelled + ? colors.surfaceContainerHighest + : colors.errorContainer, + // Higher than the card language elsewhere uses — this one + // genuinely overlaps the gold support bar underneath it (see + // Home's overlap stack), so the shadow needs to read as real + // depth, not just a card outline. + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + borderRadius: AppRadius.medium, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + context.read().request( + BaseMap.taiwanBounds, + layerId: 'monitor', + ); + context.goNamed(AppRoutes.map); + }, child: Padding( padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.xs, + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, ), - child: Material( - color: cancelled - ? colors.surfaceContainerHighest - : colors.errorContainer, - // Higher than the card language elsewhere uses — this one - // genuinely overlaps the gold support bar underneath it (see - // Home's overlap stack), so the shadow needs to read as real - // depth, not just a card outline. - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.35), - borderRadius: AppRadius.medium, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () { - context.read().request( - BaseMap.taiwanBounds, - layerId: 'monitor', - ); - context.goNamed(AppRoutes.map); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, + child: Row( + children: [ + // A circular chip around the icon reads as a badge, not + // a bare glyph floating in the row — the same touch + // the legacy monitor's own EEW pill used. + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ink.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + child: Icon( + cancelled + ? Icons.cancel_outlined + : Icons.warning_amber_rounded, + size: 16, + color: ink, ), - child: Row( + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // A circular chip around the icon reads as a badge, not - // a bare glyph floating in the row — the same touch - // the legacy monitor's own EEW pill used. - Container( - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: ink.withValues(alpha: 0.14), - shape: BoxShape.circle, - ), - child: Icon( - cancelled - ? Icons.cancel_outlined - : Icons.warning_amber_rounded, - size: 16, + Text( + alert.info.location, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( color: ink, + fontWeight: FontWeight.w700, ), ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - alert.info.location, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - color: ink, - fontWeight: FontWeight.w700, - ), - ), - Text( - cancelled - ? l10n.eewCancelled - : l10n.eewSerial(alert.serial), - maxLines: 1, - style: theme.textTheme.labelSmall?.copyWith( - color: ink.withValues(alpha: 0.8), - ), - ), - ], + Text( + cancelled + ? l10n.eewCancelled + : l10n.eewSerial(alert.serial), + maxLines: 1, + style: theme.textTheme.labelSmall?.copyWith( + color: ink.withValues(alpha: 0.8), ), ), - Icon(Icons.chevron_right, size: 20, color: ink), ], ), ), - ), + Icon(Icons.chevron_right, size: 20, color: ink), + ], ), ), ), ), ); } + + /// Slides and fades [child] away with the region bar above it (see + /// [dismiss]) — the alert and the feed-down notice ride it alike. + Widget _slide({required Widget child}) => IgnorePointer( + ignoring: dismiss > 0.5, + child: Opacity( + opacity: 1 - dismiss, + child: FractionalTranslation( + translation: Offset(0, -dismiss), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xs, + ), + child: child, + ), + ), + ), + ); } diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index ce858162f..0460a4ae8 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -31,6 +31,7 @@ import 'package:dpip/features/map/presentation/widgets/monitor_eew_card.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:dpip/shared/widgets/alert_cycle_chip.dart'; +import 'package:dpip/shared/widgets/eew_feed_down_notice.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; import 'package:dpip/shared/seismic/spoken_intensity.dart'; import 'package:flutter/material.dart'; @@ -363,13 +364,15 @@ class _EewAlert extends StatelessWidget { } /// A compact bottom card: status dot + title + the snapshot time, then the live -/// latency in milliseconds (or the feed status word when not live). While an EEW -/// alert is active it turns red-on-`errorContainer` as a whole — the legacy -/// monitor's `MorphingSheet` did the same (`borderColor`/`backgroundColor` on -/// `activeEew.isNotEmpty`, binary rather than scaled by severity) — so the -/// strip reads as urgent even collapsed, not just the card above it. A -/// cancelled alert does not count: the strip goes back to calm the moment -/// nothing still in progress is left. +/// latency in milliseconds (or the feed status word when not live) — and, +/// under that, a line saying the EEW feed is down whenever it is stale or +/// offline, since the dot and the latency describe the RTS feed alone. While +/// an EEW alert is active it turns red-on-`errorContainer` as a whole — the +/// legacy monitor's `MorphingSheet` did the same (`borderColor`/ +/// `backgroundColor` on `activeEew.isNotEmpty`, binary rather than scaled by +/// severity) — so the strip reads as urgent even collapsed, not just the card +/// above it. A cancelled alert does not count: the strip goes back to calm the +/// moment nothing still in progress is left. class _StatusBar extends StatelessWidget { const _StatusBar({required this.state, required this.eew}); @@ -430,6 +433,11 @@ class _StatusBar extends StatelessWidget { }; final onTint = hasActiveEew ? colors.onErrorContainer : null; + // The strip names the RTS feed's state only; the EEW feed has one of its + // own, and with it down the empty space above the strip — no alert card + // — would otherwise read as "no earthquake". + final eewStatus = eew.state.status; + final eewDown = EewFeedDownNotice.isDown(eewStatus); return Container( padding: const EdgeInsets.symmetric( @@ -449,44 +457,54 @@ class _StatusBar extends StatelessWidget { ), ], ), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - LegendDot(color: dot), - const SizedBox(width: AppSpacing.sm), - // Yields to the trailing readouts so a long locale or large text scale - // ellipsises the title instead of overflowing the row. - Expanded( - child: Text( - l10n.mapLayerMonitor, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - color: onTint, + Row( + children: [ + LegendDot(color: dot), + const SizedBox(width: AppSpacing.sm), + // Yields to the trailing readouts so a long locale or large text scale + // ellipsises the title instead of overflowing the row. + Expanded( + child: Text( + l10n.mapLayerMonitor, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: onTint, + ), + ), ), - ), - ), - if (dataTime != null) ...[ - const SizedBox(width: AppSpacing.sm), - Text( - dataTime, - maxLines: 1, - style: theme.textTheme.labelMedium?.copyWith( - color: onTint ?? colors.onSurfaceVariant, - fontFeatures: const [FontFeature.tabularFigures()], + if (dataTime != null) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + dataTime, + maxLines: 1, + style: theme.textTheme.labelMedium?.copyWith( + color: onTint ?? colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + const SizedBox(width: AppSpacing.sm), + Text( + trailing, + maxLines: 1, + style: theme.textTheme.labelMedium?.copyWith( + color: onTint ?? trailingColor, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), - ), - ], - const SizedBox(width: AppSpacing.sm), - Text( - trailing, - maxLines: 1, - style: theme.textTheme.labelMedium?.copyWith( - color: onTint ?? trailingColor, - fontWeight: FontWeight.w600, - fontFeatures: const [FontFeature.tabularFigures()], - ), + ], ), + if (eewDown) ...[ + const SizedBox(height: AppSpacing.xs), + EewFeedDownNotice.inline(status: eewStatus), + ], ], ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eedfb1993..08c603562 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4256,5 +4256,9 @@ "eewCancelledNotice": "The issuing agency cancelled this alert", "@eewCancelledNotice": { "description": "Line on a cancelled earthquake early warning card, in place of the local intensity estimate and S-wave countdown" + }, + "eewFeedDown": "Earthquake alerts disconnected — reconnecting…", + "@eewFeedDown": { + "description": "Shown on Home and in the seismic monitor's status strip while the earthquake early warning feed is disconnected or not updating, so an empty alert area is not read as all-clear" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index e3ef2a701..3ca76d183 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "Mas luma", "locationTrackLegendLatest": "Pinakabagong lokasyon", "eewCancelled": "Kinansela", - "eewCancelledNotice": "Kinansela ng naglabas na ahensya ang alertong ito" + "eewCancelledNotice": "Kinansela ng naglabas na ahensya ang alertong ito", + "eewFeedDown": "Naputol ang koneksyon sa babala sa lindol — kumokonekta muli…" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d50fc8d2e..2f3b7022e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "Lebih lama", "locationTrackLegendLatest": "Posisi terakhir", "eewCancelled": "Dibatalkan", - "eewCancelledNotice": "Lembaga penerbit telah membatalkan peringatan ini" + "eewCancelledNotice": "Lembaga penerbit telah membatalkan peringatan ini", + "eewFeedDown": "Koneksi peringatan gempa terputus — menyambungkan ulang…" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index dec213886..31b7179b8 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "古い", "locationTrackLegendLatest": "最新の位置", "eewCancelled": "取り消し", - "eewCancelledNotice": "発表元がこの速報を取り消しました" + "eewCancelledNotice": "発表元がこの速報を取り消しました", + "eewFeedDown": "緊急地震速報の接続が切れています。再接続中…" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cd4ba395d..9fd6f42ca 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "이전", "locationTrackLegendLatest": "마지막 위치", "eewCancelled": "취소됨", - "eewCancelledNotice": "발표 기관이 이 경보를 취소했습니다" + "eewCancelledNotice": "발표 기관이 이 경보를 취소했습니다", + "eewFeedDown": "지진 속보 연결이 끊겼습니다. 다시 연결하는 중…" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 2077b6d35..008abed30 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "เก่ากว่า", "locationTrackLegendLatest": "ตำแหน่งล่าสุด", "eewCancelled": "ยกเลิกแล้ว", - "eewCancelledNotice": "หน่วยงานผู้ออกประกาศได้ยกเลิกการแจ้งเตือนนี้แล้ว" + "eewCancelledNotice": "หน่วยงานผู้ออกประกาศได้ยกเลิกการแจ้งเตือนนี้แล้ว", + "eewFeedDown": "การเชื่อมต่อการแจ้งเตือนแผ่นดินไหวขาดหาย กำลังเชื่อมต่อใหม่…" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index c32cd19e6..82f31f2cf 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "Cũ hơn", "locationTrackLegendLatest": "Vị trí mới nhất", "eewCancelled": "Đã hủy", - "eewCancelledNotice": "Cơ quan phát hành đã hủy cảnh báo này" + "eewCancelledNotice": "Cơ quan phát hành đã hủy cảnh báo này", + "eewFeedDown": "Mất kết nối cảnh báo động đất — đang kết nối lại…" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 576afec77..ab695873b 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "較舊", "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", - "eewCancelledNotice": "發布單位已取消呢個速報" + "eewCancelledNotice": "發布單位已取消呢個速報", + "eewFeedDown": "地震速報連接中斷,重新連接緊…" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4385e6f66..9c55d0581 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2084,5 +2084,6 @@ "locationTrackLegendOlder": "較舊", "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", - "eewCancelledNotice": "發布單位已取消此速報" + "eewCancelledNotice": "發布單位已取消此速報", + "eewFeedDown": "地震速報連線中斷,重新連線中…" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 81dbf44d3..4f86b9fd0 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "较旧", "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", - "eewCancelledNotice": "发布单位已取消此速报" + "eewCancelledNotice": "发布单位已取消此速报", + "eewFeedDown": "地震速报连接中断,正在重新连接…" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 274399d24..7cb631dfd 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "較舊", "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", - "eewCancelledNotice": "發布單位已取消此速報" + "eewCancelledNotice": "發布單位已取消此速報", + "eewFeedDown": "地震速報連接中斷,重新連接中…" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7e5539efa..7a9bebb7c 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2092,5 +2092,6 @@ "locationTrackLegendOlder": "較舊", "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", - "eewCancelledNotice": "發布單位已取消此速報" + "eewCancelledNotice": "發布單位已取消此速報", + "eewFeedDown": "地震速報連線中斷,重新連線中…" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 74cec06d2..b513cb11f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6659,6 +6659,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The issuing agency cancelled this alert'** String get eewCancelledNotice; + + /// Shown on Home and in the seismic monitor's status strip while the earthquake early warning feed is disconnected or not updating, so an empty alert area is not read as all-clear + /// + /// In en, this message translates to: + /// **'Earthquake alerts disconnected — reconnecting…'** + String get eewFeedDown; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index ac5688703..fc3286b1f 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3523,4 +3523,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eewCancelledNotice => 'The issuing agency cancelled this alert'; + + @override + String get eewFeedDown => 'Earthquake alerts disconnected — reconnecting…'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index efd81bbe1..301e1901d 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3545,4 +3545,8 @@ class AppLocalizationsFil extends AppLocalizations { @override String get eewCancelledNotice => 'Kinansela ng naglabas na ahensya ang alertong ito'; + + @override + String get eewFeedDown => + 'Naputol ang koneksyon sa babala sa lindol — kumokonekta muli…'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f336fc666..7b72068b8 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3538,4 +3538,8 @@ class AppLocalizationsId extends AppLocalizations { @override String get eewCancelledNotice => 'Lembaga penerbit telah membatalkan peringatan ini'; + + @override + String get eewFeedDown => + 'Koneksi peringatan gempa terputus — menyambungkan ulang…'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 6fedc4261..699fd1e7d 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3459,4 +3459,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eewCancelledNotice => '発表元がこの速報を取り消しました'; + + @override + String get eewFeedDown => '緊急地震速報の接続が切れています。再接続中…'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index a0a6b6cd9..9c14d9c5b 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3461,4 +3461,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eewCancelledNotice => '발표 기관이 이 경보를 취소했습니다'; + + @override + String get eewFeedDown => '지진 속보 연결이 끊겼습니다. 다시 연결하는 중…'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 15f9c79bb..c9aae9026 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3517,4 +3517,8 @@ class AppLocalizationsTh extends AppLocalizations { @override String get eewCancelledNotice => 'หน่วยงานผู้ออกประกาศได้ยกเลิกการแจ้งเตือนนี้แล้ว'; + + @override + String get eewFeedDown => + 'การเชื่อมต่อการแจ้งเตือนแผ่นดินไหวขาดหาย กำลังเชื่อมต่อใหม่…'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 7b588fcf4..6f6399c15 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3528,4 +3528,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eewCancelledNotice => 'Cơ quan phát hành đã hủy cảnh báo này'; + + @override + String get eewFeedDown => 'Mất kết nối cảnh báo động đất — đang kết nối lại…'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 8564f5b54..33c94c8b3 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3438,4 +3438,7 @@ class AppLocalizationsYue extends AppLocalizations { @override String get eewCancelledNotice => '發布單位已取消呢個速報'; + + @override + String get eewFeedDown => '地震速報連接中斷,重新連接緊…'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index efbe46cca..e23233900 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3438,6 +3438,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eewCancelledNotice => '發布單位已取消此速報'; + + @override + String get eewFeedDown => '地震速報連線中斷,重新連線中…'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6870,6 +6873,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get eewCancelledNotice => '发布单位已取消此速报'; + + @override + String get eewFeedDown => '地震速报连接中断,正在重新连接…'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -10303,6 +10309,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get eewCancelledNotice => '發布單位已取消此速報'; + + @override + String get eewFeedDown => '地震速報連接中斷,重新連接中…'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -13736,4 +13745,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get eewCancelledNotice => '發布單位已取消此速報'; + + @override + String get eewFeedDown => '地震速報連線中斷,重新連線中…'; } diff --git a/lib/shared/widgets/eew_feed_down_notice.dart b/lib/shared/widgets/eew_feed_down_notice.dart new file mode 100644 index 000000000..86d29eb6b --- /dev/null +++ b/lib/shared/widgets/eew_feed_down_notice.dart @@ -0,0 +1,116 @@ +/// Says that the EEW feed is down, wherever an alert would otherwise appear. +/// +/// Every surface that shows a live alert renders nothing when there is none — +/// and, rightly, nothing for an alert whose feed has gone stale or offline, +/// since an aged alert must never pass for a current one. Together those made +/// a dead feed look exactly like a calm one: the empty space on Home and in the +/// monitor read as "no earthquake" when it only meant "no connection". This is +/// what fills that space instead. +/// +/// The colours follow `RealtimeView`'s freshness banner — tertiary while the +/// feed is merely late, error once it has given up — so a feed that is down +/// reads the same everywhere in the app. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; + +class EewFeedDownNotice extends StatelessWidget { + /// A self-contained pill — Home's banner slot and its alert section. + const EewFeedDownNotice({ + super.key, + required this.status, + this.elevation = 0, + this.maxLines = 2, + }) : inline = false; + + /// Just the icon and the words, in the feed-state colour, for a surface + /// that already has its own background — the monitor's status strip. + const EewFeedDownNotice.inline({ + super.key, + required this.status, + this.maxLines = 2, + }) : inline = true, + elevation = 0; + + /// The EEW feed's status — meaningful only when [isDown]. + final RealtimeStatus status; + + /// The pill's elevation; non-zero where it floats over the map. + final double elevation; + + /// One where the slot has a fixed height (Home's banner), two elsewhere. + final int maxLines; + + final bool inline; + + /// Whether a feed in [status] is down: aged past live, or given up on. A + /// feed still making its first connection is not — every launch passes + /// through that. + static bool isDown(RealtimeStatus status) => + status == RealtimeStatus.stale || status == RealtimeStatus.offline; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final offline = status == RealtimeStatus.offline; + final icon = offline ? Icons.cloud_off_outlined : Icons.schedule_outlined; + final label = AppLocalizations.of(context).eewFeedDown; + + if (inline) { + final ink = offline ? colors.error : colors.tertiary; + return Row( + children: [ + Icon(icon, size: 16, color: ink), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + label, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: ink, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + final background = offline + ? colors.errorContainer + : colors.tertiaryContainer; + final ink = offline ? colors.onErrorContainer : colors.onTertiaryContainer; + return Material( + color: background, + elevation: elevation, + shadowColor: Colors.black.withValues(alpha: 0.35), + borderRadius: AppRadius.medium, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon(icon, size: 18, color: ink), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + label, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith(color: ink), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/features/home/presentation/widgets/home_eew_section_test.dart b/test/features/home/presentation/widgets/home_eew_section_test.dart index 467625e2d..0004cffa5 100644 --- a/test/features/home/presentation/widgets/home_eew_section_test.dart +++ b/test/features/home/presentation/widgets/home_eew_section_test.dart @@ -197,9 +197,8 @@ void main() { expect(find.text('Report 2'), findsNothing); }); - testWidgets('renders nothing when the feed has aged past live', ( - tester, - ) async { + testWidgets('a feed aged past live hides its alert and says the feed is ' + 'down instead of going quiet', (tester) async { final setup = await _liveNotifier([_alert()]); final store = await _store(); // Advance past the staleness threshold with no fresh contact, then @@ -211,6 +210,58 @@ void main() { expect(setup.notifier.state.status.name, 'stale'); expect(find.text('花蓮縣'), findsNothing); + expect( + find.text('Earthquake alerts disconnected — reconnecting…'), + findsOneWidget, + ); + + // Given up on: still said, never blank. + setup.elapsed.advance(const Duration(seconds: 20)); + setup.channel.recomputeStatus(); + await tester.pump(); + await tester.pump(); + expect(setup.notifier.state.status.name, 'offline'); + expect( + find.text('Earthquake alerts disconnected — reconnecting…'), + findsOneWidget, + ); + }); + + testWidgets('a calm feed with nothing to report shows nothing at all', ( + tester, + ) async { + final setup = await _liveNotifier(const []); + final store = await _store(); + await tester.pumpWidget(_wrap(setup.notifier, store)); + expect( + find.text('Earthquake alerts disconnected — reconnecting…'), + findsNothing, + ); + expect(tester.getSize(find.byType(HomeEewSection)), Size.zero); + }); + + testWidgets('a feed down with no alert is still active, so Home leaves ' + 'room for the notice', (tester) async { + final setup = await _liveNotifier(const []); + setup.elapsed.advance(const Duration(seconds: 30)); + setup.channel.recomputeStatus(); + final store = await _store(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: _providers( + setup.notifier, + store, + child: Builder( + builder: (context) => + Text(HomeEewSection.isActive(context) ? 'active' : 'idle'), + ), + ), + ), + ); + expect(find.text('active'), findsOneWidget); }); testWidgets('isActive matches a live alert and a tap opens the monitor', ( diff --git a/test/features/home/presentation/widgets/home_monitor_banner_test.dart b/test/features/home/presentation/widgets/home_monitor_banner_test.dart index 5a137cdbf..eeb6fcbda 100644 --- a/test/features/home/presentation/widgets/home_monitor_banner_test.dart +++ b/test/features/home/presentation/widgets/home_monitor_banner_test.dart @@ -5,6 +5,7 @@ import 'package:dpip/core/realtime/realtime_channel.dart'; import 'package:dpip/core/realtime/realtime_config.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_source.dart'; +import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/realtime/ticker.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -230,4 +231,36 @@ void main() { expect(find.text('花蓮縣'), findsOneWidget); expect(find.text('宜蘭縣'), findsNothing); }); + + testWidgets('a feed aged past live fills the slot with a feed-down notice, ' + 'not its alert and not nothing', (tester) async { + final elapsed = _FakeElapsed(); + final offline = RealtimeChannel>( + source: _StaticSource([_alert()]), + clock: _FakeClock(DateTime.utc(2026, 8, 12, 12)), + elapsed: elapsed, + ticker: _FakeTicker(), + config: RealtimeConfig.eew, + label: 'test-eew-offline', + ); + addTearDown(offline.dispose); + // An alert was up, then nothing for longer than the give-up threshold. + await offline.refreshNow(); + elapsed.value = const Duration(seconds: 30); + offline.recomputeStatus(); + final notifier = RealtimeNotifier>(offline); + expect(notifier.state.status, RealtimeStatus.offline); + + await tester.pumpWidget(_wrap(notifier, MapCameraHandoff())); + final l10n = AppLocalizations.of( + tester.element(find.byType(HomeMonitorBanner)), + ); + expect(find.text(l10n.eewFeedDown), findsOneWidget); + expect(find.text('臺東縣'), findsNothing); + expect(find.byType(InkWell), findsNothing); + expect( + tester.getSize(find.byType(HomeMonitorBanner)).height, + greaterThan(0), + ); + }); } diff --git a/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart b/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart index 16297ff7a..b38934d75 100644 --- a/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart +++ b/test/features/map/presentation/widgets/rts_monitor_panel_eew_test.dart @@ -256,6 +256,11 @@ void main() { expect(find.text('Seismic Monitor'), findsOneWidget); expect(find.text('花蓮縣'), findsNothing); + expect( + find.text('Earthquake alerts disconnected — reconnecting…'), + findsNothing, + reason: 'a live, calm EEW feed is not down', + ); await tester.pumpWidget(const SizedBox()); }); @@ -360,6 +365,11 @@ void main() { expect(feeds.eew.state.status.name, 'stale'); expect(find.text('Seismic Monitor'), findsOneWidget); expect(find.text('花蓮縣'), findsNothing); + // …but the strip says why the space above it is empty. + expect( + find.text('Earthquake alerts disconnected — reconnecting…'), + findsOneWidget, + ); await tester.pumpWidget(const SizedBox()); }); diff --git a/test/shared/widgets/eew_feed_down_notice_test.dart b/test/shared/widgets/eew_feed_down_notice_test.dart new file mode 100644 index 000000000..dc9ff4655 --- /dev/null +++ b/test/shared/widgets/eew_feed_down_notice_test.dart @@ -0,0 +1,83 @@ +/// Pins when the EEW feed counts as down and how each state reads. +library; + +import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/eew_feed_down_notice.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: child), +); + +void main() { + test('stale and offline are down; connecting and live are not', () { + expect(EewFeedDownNotice.isDown(RealtimeStatus.stale), isTrue); + expect(EewFeedDownNotice.isDown(RealtimeStatus.offline), isTrue); + // Every launch passes through connecting — not an outage. + expect(EewFeedDownNotice.isDown(RealtimeStatus.connecting), isFalse); + expect(EewFeedDownNotice.isDown(RealtimeStatus.live), isFalse); + }); + + testWidgets('offline reads on the error tones, stale on the tertiary ones', ( + tester, + ) async { + await tester.pumpWidget( + _host(const EewFeedDownNotice(status: RealtimeStatus.offline)), + ); + final colors = Theme.of(tester.element(find.byType(EewFeedDownNotice))) + .colorScheme; + final l10n = AppLocalizations.of( + tester.element(find.byType(EewFeedDownNotice)), + ); + expect(find.text(l10n.eewFeedDown), findsOneWidget); + expect(find.byIcon(Icons.cloud_off_outlined), findsOneWidget); + expect( + tester + .widget( + find.descendant( + of: find.byType(EewFeedDownNotice), + matching: find.byType(Material), + ), + ) + .color, + colors.errorContainer, + ); + + await tester.pumpWidget( + _host(const EewFeedDownNotice(status: RealtimeStatus.stale)), + ); + expect( + tester + .widget( + find.descendant( + of: find.byType(EewFeedDownNotice), + matching: find.byType(Material), + ), + ) + .color, + colors.tertiaryContainer, + ); + }); + + testWidgets('the inline form draws no surface of its own', (tester) async { + await tester.pumpWidget( + _host(const EewFeedDownNotice.inline(status: RealtimeStatus.offline)), + ); + expect( + find.descendant( + of: find.byType(EewFeedDownNotice), + matching: find.byType(Material), + ), + findsNothing, + ); + final l10n = AppLocalizations.of( + tester.element(find.byType(EewFeedDownNotice)), + ); + expect(find.text(l10n.eewFeedDown), findsOneWidget); + }); +} From 2b3bcd66706e1b83c2fe61244216d645441b76a1 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 07:18:43 +0800 Subject: [PATCH 6/6] fix(eew): count the S-wave down to the exact moment it arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 震波倒數改以毫秒精度計算並對齊時鐘整秒更新,「已抵達」會在預估抵達的瞬間切換,不再提早或延遲最多一秒 Fix(en-US): the S-wave countdown is now computed to the millisecond and ticks on the clock's whole seconds, so "Arrived" switches at the estimated moment instead of up to a second early or late --- .../earthquake/domain/eew_local_estimate.dart | 81 ++++++++++-- .../earthquake/domain/eew_priority.dart | 15 +-- .../presentation/widgets/eew_card.dart | 30 +++-- .../widgets/home_eew_section.dart | 30 +++-- .../widgets/monitor_eew_card.dart | 27 ++-- lib/shared/widgets/second_ticker.dart | 63 +++++++++- .../earthquake/domain/eew_priority_test.dart | 2 +- .../earthquake/eew_local_estimate_test.dart | 110 +++++++++++++++-- .../presentation/widgets/eew_card_test.dart | 31 +++++ test/shared/widgets/second_ticker_test.dart | 116 ++++++++++++++++++ 10 files changed, 444 insertions(+), 61 deletions(-) diff --git a/lib/features/earthquake/domain/eew_local_estimate.dart b/lib/features/earthquake/domain/eew_local_estimate.dart index cf97c2fff..21da22ab9 100644 --- a/lib/features/earthquake/domain/eew_local_estimate.dart +++ b/lib/features/earthquake/domain/eew_local_estimate.dart @@ -9,14 +9,14 @@ import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; /// Derived purely from [EewEstimator] so the alert card's numbers are the same /// golden-pinned math the map's hazard overlays use — never a second, /// hand-rolled approximation. [distanceKm] is hypocentral (surface + depth); -/// [sArrivalSeconds] is the estimated S-wave travel time from the origin, so -/// arrival = origin + this many seconds. +/// [sArrival] is the estimated S-wave travel time from the origin, so +/// arrival = origin + [sArrival] (see [SWaveCountdown]). class EewLocalEstimate { const EewLocalEstimate({ required this.distanceKm, required this.intensity, required this.scale, - required this.sArrivalSeconds, + required this.sArrival, }); /// Hypocentral distance in kilometres. @@ -28,8 +28,68 @@ class EewLocalEstimate { /// Discrete CWA felt intensity (0–9, see [Intensity.toScale]). final int scale; - /// Estimated S-wave travel time in seconds from the earthquake origin. - final int sArrivalSeconds; + /// Estimated S-wave travel time from the earthquake origin, to the + /// millisecond. + /// + /// Never rounded to a whole second: it used to be, and the cards then + /// truncated what was left with `inSeconds` on a tick that was not aligned + /// to anything, so "arrived" could show up to a second early or late + /// depending on where the rounding and the tick happened to fall. Rounding + /// is the display's business, once, at the edge ([SWaveCountdown]). + final Duration sArrival; +} + +/// One observer's S-wave arrival as a countdown — the single place the +/// "arrived" instant and the seconds shown are decided, so every card counts +/// the same way. +class SWaveCountdown { + const SWaveCountdown(this.arrival); + + /// The countdown to [estimate]'s arrival for [eew]. + factory SWaveCountdown.of(Eew eew, EewLocalEstimate estimate) => + SWaveCountdown( + DateTime.fromMillisecondsSinceEpoch( + eew.info.time, + isUtc: true, + ).add(estimate.sArrival), + ); + + /// When the S wave reaches the observer. + final DateTime arrival; + + /// Whether the S wave has arrived at [now] — true from the computed instant + /// itself onwards, never a moment later. + bool arrivedAt(DateTime now) => !now.isBefore(arrival); + + /// Whole seconds left at [now], rounded **up**: a countdown reads 1 until + /// the instant of arrival and 0 only from it. Rounding down would read 0 for + /// the last second while the wave was still on its way. + int secondsLeftAt(DateTime now) { + final left = arrival.difference(now).inMicroseconds; + if (left <= 0) return 0; + return (left + _microsPerSecond - 1) ~/ _microsPerSecond; + } + + /// How long after [now] [secondsLeftAt] next changes — the instant a + /// display has to redraw to stay exact — or null once arrived, when it + /// never changes again. + Duration? untilNextChangeAt(DateTime now) { + final left = arrival.difference(now).inMicroseconds; + if (left <= 0) return null; + final intoSecond = left % _microsPerSecond; + return Duration( + microseconds: intoSecond == 0 ? _microsPerSecond : intoSecond, + ); + } + + static const int _microsPerSecond = 1000000; + + @override + bool operator ==(Object other) => + other is SWaveCountdown && other.arrival == arrival; + + @override + int get hashCode => arrival.hashCode; } /// Estimates local shaking for [eew] at [user]. @@ -39,7 +99,8 @@ class EewLocalEstimate { /// and skip the estimate entirely when no location is resolvable (全國 or a /// 所在地 with no GPS fix) rather than inventing a default. /// -/// [sArrivalSeconds] comes from the **CWA travel-time table** ([table], the +/// [EewLocalEstimate.sArrival] comes from the **CWA travel-time table** +/// ([table], the /// same source the map's wave circles use) when it has resolved — the legacy /// app's `sWaveTimeByDistance` query, matched on **epicentral** distance so /// "arrived" and the wave front on the map agree. Until the table loads (a @@ -93,13 +154,13 @@ EewLocalEstimate _estimate( // distance — the same table the map's wave circles use, so "arrived" and the // wave front on the map agree (legacy's `sWaveTimeByDistance`). final epicentral = eew.info.latlng.distanceTo(user) / 1000; - final sArrivalSeconds = table == null - ? EewEstimator.waveTime(eew.info.depth, epicentral).s.round() - : (table.sWaveTime(eew.info.depth, epicentral) / 1000).round(); + final sArrivalMs = table == null + ? EewEstimator.waveTime(eew.info.depth, epicentral).s * 1000 + : table.sWaveTime(eew.info.depth, epicentral); return EewLocalEstimate( distanceKm: location.dist, intensity: location.i, scale: Intensity.toScale(location.i), - sArrivalSeconds: sArrivalSeconds, + sArrival: Duration(milliseconds: sArrivalMs.round()), ); } diff --git a/lib/features/earthquake/domain/eew_priority.dart b/lib/features/earthquake/domain/eew_priority.dart index e923e32d2..62bb34532 100644 --- a/lib/features/earthquake/domain/eew_priority.dart +++ b/lib/features/earthquake/domain/eew_priority.dart @@ -23,22 +23,15 @@ class RankedEew { /// always null for a cancelled alert, which has none. final EewLocalEstimate? estimate; - /// When the S wave reaches the observer, or null without an [estimate]. - DateTime? get arrival { + /// The S wave's arrival at the observer, or null without an [estimate]. + SWaveCountdown? get countdown { final estimate = this.estimate; - if (estimate == null) return null; - return DateTime.fromMillisecondsSinceEpoch( - alert.info.time, - isUtc: true, - ).add(Duration(seconds: estimate.sArrivalSeconds)); + return estimate == null ? null : SWaveCountdown.of(alert, estimate); } /// Whether the S wave has reached the observer by [now]. False when that is /// unknown. - bool arrivedAt(DateTime now) { - final arrival = this.arrival; - return arrival != null && !now.isBefore(arrival); - } + bool arrivedAt(DateTime now) => countdown?.arrivedAt(now) ?? false; } /// [alerts] most-relevant first, for an observer at [observer] (null when diff --git a/lib/features/earthquake/presentation/widgets/eew_card.dart b/lib/features/earthquake/presentation/widgets/eew_card.dart index 73329bcaa..ac4ddd38d 100644 --- a/lib/features/earthquake/presentation/widgets/eew_card.dart +++ b/lib/features/earthquake/presentation/widgets/eew_card.dart @@ -117,6 +117,18 @@ class _EewCardContentState extends State with SecondTicker { /// centroid. Falls back to the centroid when no fix is available. LatLng? _fix; + /// The countdown the last build showed — what the ticks are aligned to. + SWaveCountdown? _countdown; + + @override + DateTime secondTickerNow() => widget.clock?.call() ?? AppTime.utc; + + /// Redraw exactly when the seconds shown change — on whole seconds before + /// the arrival, not of the clock — so "arrived" lands on the instant. + @override + Duration secondTickerDelay(DateTime now) => + _countdown?.untilNextChangeAt(now) ?? super.secondTickerDelay(now); + @override void initState() { super.initState(); @@ -159,15 +171,15 @@ class _EewCardContentState extends State with SecondTicker { final originUtc = DateTime.fromMillisecondsSinceEpoch(info.time); final maxIntensity = Intensity.displayForReport(info.max, originUtc); - final now = widget.clock?.call() ?? AppTime.utc; - final remaining = estimate == null + final now = secondTickerNow(); + final countdown = estimate == null ? null - : originUtc - .add(Duration(seconds: estimate.sArrivalSeconds)) - .difference(now) - .inSeconds; - - final arrived = remaining != null && remaining <= 0; + : SWaveCountdown.of(widget.eew, estimate); + if (countdown != _countdown) { + _countdown = countdown; + realignSecondTicker(); + } + final arrived = countdown != null && countdown.arrivedAt(now); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -271,7 +283,7 @@ class _EewCardContentState extends State with SecondTicker { label: l10n.eewSWave, value: arrived ? l10n.eewArrived - : l10n.eewCountdown(remaining ?? 0), + : l10n.eewCountdown(countdown?.secondsLeftAt(now) ?? 0), background: EewEstimateTile.alertRed(), foreground: Colors.white, ), diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index 93e9a6bf9..cad68ce73 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -33,6 +33,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; import 'package:dpip/features/earthquake/domain/eew_priority.dart'; import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; @@ -146,6 +147,15 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { /// the user actually is, not the selected township's centroid. LatLng? _fix; + /// The countdown the last build showed — what the ticks are aligned to. + SWaveCountdown? _countdown; + + /// Redraw exactly when the seconds shown change — on whole seconds before + /// the arrival, not of the clock — so "arrived" lands on the instant. + @override + Duration secondTickerDelay(DateTime now) => + _countdown?.untilNextChangeAt(now) ?? super.secondTickerDelay(now); + @override void initState() { super.initState(); @@ -179,7 +189,7 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { // has none — computing one would put a number on screen for an earthquake // the agency has just said is not happening — and ranks last, so it is // shown only when nothing still in progress is left. - final now = AppTime.utc; + final now = secondTickerNow(); final primary = rankEews( widget.alerts, now: now, @@ -193,14 +203,12 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { final originUtc = DateTime.fromMillisecondsSinceEpoch(info.time); final maxIntensity = Intensity.displayForReport(info.max, originUtc); - final remaining = estimate == null - ? null - : originUtc - .add(Duration(seconds: estimate.sArrivalSeconds)) - .difference(now) - .inSeconds; - - final arrived = remaining != null && remaining <= 0; + final countdown = primary.countdown; + if (countdown != _countdown) { + _countdown = countdown; + realignSecondTicker(); + } + final arrived = countdown != null && countdown.arrivedAt(now); return Card( margin: EdgeInsets.zero, @@ -303,7 +311,9 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { label: l10n.eewSWave, value: arrived ? l10n.eewArrived - : l10n.eewCountdown(remaining ?? 0), + : l10n.eewCountdown( + countdown?.secondsLeftAt(now) ?? 0, + ), background: EewEstimateTile.alertRed(), foreground: Colors.white, ), diff --git a/lib/features/map/presentation/widgets/monitor_eew_card.dart b/lib/features/map/presentation/widgets/monitor_eew_card.dart index d57b2e706..71a685a21 100644 --- a/lib/features/map/presentation/widgets/monitor_eew_card.dart +++ b/lib/features/map/presentation/widgets/monitor_eew_card.dart @@ -96,6 +96,15 @@ class _MonitorEewCardState extends State with SecondTicker { /// the user actually is, not the selected township's centroid. LatLng? _fix; + /// The countdown the last build showed — what the ticks are aligned to. + SWaveCountdown? _countdown; + + /// Redraw exactly when the seconds shown change — on whole seconds before + /// the arrival, not of the clock — so "arrived" lands on the instant. + @override + Duration secondTickerDelay(DateTime now) => + _countdown?.untilNextChangeAt(now) ?? super.secondTickerDelay(now); + @override void initState() { super.initState(); @@ -156,13 +165,15 @@ class _MonitorEewCardState extends State with SecondTicker { final originUtc = DateTime.fromMillisecondsSinceEpoch(info.time); final maxIntensity = Intensity.displayForReport(info.max, originUtc); - final remaining = estimate == null + final now = secondTickerNow(); + final countdown = estimate == null ? null - : originUtc - .add(Duration(seconds: estimate.sArrivalSeconds)) - .difference(AppTime.utc) - .inSeconds; - final arrived = remaining != null && remaining <= 0; + : SWaveCountdown.of(widget.alert, estimate); + if (countdown != _countdown) { + _countdown = countdown; + realignSecondTicker(); + } + final arrived = countdown != null && countdown.arrivedAt(now); return Card( margin: EdgeInsets.zero, @@ -246,7 +257,9 @@ class _MonitorEewCardState extends State with SecondTicker { label: l10n.eewSWave, value: arrived ? l10n.eewArrived - : l10n.eewCountdown(remaining ?? 0), + : l10n.eewCountdown( + countdown?.secondsLeftAt(now) ?? 0, + ), background: EewEstimateTile.alertRed(), foreground: Colors.white, ), diff --git a/lib/shared/widgets/second_ticker.dart b/lib/shared/widgets/second_ticker.dart index 87f4c9457..aabfe141d 100644 --- a/lib/shared/widgets/second_ticker.dart +++ b/lib/shared/widgets/second_ticker.dart @@ -17,10 +17,20 @@ /// On every visible edge it fires one immediate [setState], so a countdown /// snaps straight to the correct `AppTime` value instead of showing a /// seconds-old number until the next tick. +/// +/// Ticks land on whole seconds of the calibrated clock ([AppTime]), not one +/// second after whenever the widget happened to mount. A periodic timer +/// started at mount put every card on its own random phase: a number derived +/// from the clock could sit up to a second behind it, and two cards showing +/// the same countdown disagreed by one. A host whose value changes on some +/// other second — an S-wave countdown changes on whole seconds *before its +/// arrival* — moves the ticks there with [secondTickerDelay], and calls +/// [realignSecondTicker] when that changes. library; import 'dart:async'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:flutter/widgets.dart'; mixin SecondTicker on State { @@ -33,22 +43,63 @@ mixin SecondTicker on State { @protected bool get secondTickerActive => true; + /// The clock the ticks are aligned to — the calibrated one by default; a + /// host showing another timeline (a replay) returns that one's. + @protected + DateTime secondTickerNow() => AppTime.utc; + + /// How long after [now] the next tick lands: by default the next whole + /// second of [secondTickerNow]. + @protected + Duration secondTickerDelay(DateTime now) { + final intoSecond = now.microsecondsSinceEpoch % _microsPerSecond; + return Duration(microseconds: _microsPerSecond - intoSecond); + } + + /// Re-aims the pending tick after whatever [secondTickerDelay] reads has + /// changed — safe to call from `build`: it moves a timer, nothing more. + @protected + void realignSecondTicker() { + if (_secondTimer == null) return; + _secondTimer!.cancel(); + _armSecondTimer(); + } + /// Re-evaluates the gates and starts/stops the timer accordingly. @protected void syncSecondTicker() { - final want = mounted && _foreground && secondTickerActive; - if (want && _secondTimer == null) { + if (_wantSecondTicks && _secondTimer == null) { // Snap to the present first: the card may have been hidden for minutes. setState(() {}); - _secondTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - } else if (!want) { + _armSecondTimer(); + } else if (!_wantSecondTicks) { _secondTimer?.cancel(); _secondTimer = null; } } + bool get _wantSecondTicks => mounted && _foreground && secondTickerActive; + + /// One-shot, re-armed on every tick rather than periodic: each delay is + /// measured from the clock afresh, so the ticks cannot drift off the + /// boundary however late a timer fires, and a clock resync is followed + /// within one tick. + void _armSecondTimer() { + var delay = secondTickerDelay(secondTickerNow()); + // A boundary a hair away would otherwise fire back to back while the + // clock catches up with the timer. + if (delay < _minDelay) delay = _minDelay; + _secondTimer = Timer(delay, () { + _secondTimer = null; + if (!mounted) return; + setState(() {}); + if (_wantSecondTicks) _armSecondTimer(); + }); + } + + static const int _microsPerSecond = 1000000; + static const Duration _minDelay = Duration(milliseconds: 1); + @override void initState() { super.initState(); diff --git a/test/features/earthquake/domain/eew_priority_test.dart b/test/features/earthquake/domain/eew_priority_test.dart index d941d2d6c..25cd7684b 100644 --- a/test/features/earthquake/domain/eew_priority_test.dart +++ b/test/features/earthquake/domain/eew_priority_test.dart @@ -66,7 +66,7 @@ void main() { observer: _taipei, ); expect(ranked.single.estimate, isNull); - expect(ranked.single.arrival, isNull); + expect(ranked.single.countdown, isNull); }); test('with an observer, an S wave still on its way outranks a stronger one ' diff --git a/test/features/earthquake/eew_local_estimate_test.dart b/test/features/earthquake/eew_local_estimate_test.dart index 7330eca25..759a054e3 100644 --- a/test/features/earthquake/eew_local_estimate_test.dart +++ b/test/features/earthquake/eew_local_estimate_test.dart @@ -43,7 +43,7 @@ void main() { expect(estimate.scale, Intensity.toScale(estimate.intensity)); expect(estimate.scale, 2); // 1.95 → CWA 2級 expect(estimate.scale, 2); - expect(estimate.sArrivalSeconds, greaterThan(0)); + expect(estimate.sArrival, greaterThan(Duration.zero)); final again = estimateLocalShaking(eew(), const LatLng(25.03, 121.56)); expect(identical(again, estimate), isTrue); }, @@ -56,8 +56,10 @@ void main() { expect(estimate.scale, 0); expect( - estimate.sArrivalSeconds, - EewEstimator.waveTime(20, epicentral).s.round(), + estimate.sArrival, + Duration( + milliseconds: (EewEstimator.waveTime(20, epicentral).s * 1000).round(), + ), ); }); @@ -95,17 +97,111 @@ void main() { table: _table, ); expect( - estimate.sArrivalSeconds, - (_table.sWaveTime(depth, epicentral) / 1000).round(), + estimate.sArrival, + Duration(milliseconds: _table.sWaveTime(depth, epicentral).round()), ); // The distance is the epicentral one the table's R column is defined on — // feeding hypocentral instead lands on a different interpolated row. expect( - (_table.sWaveTime(depth, hypocentral) / 1000).round(), - isNot(estimate.sArrivalSeconds), + Duration(milliseconds: _table.sWaveTime(depth, hypocentral).round()), + isNot(estimate.sArrival), ); }, ); + + test( + 'the arrival keeps its milliseconds instead of rounding to a second', + () { + // A table whose S time to the observer lands mid-second: 12.34 s. + const table = SeismicTravelTimeTable({ + 10: [(p: 0, r: 0, s: 0), (p: 7.0, r: 40, s: 12.34)], + }); + final estimate = estimateLocalShaking( + eew(), + LatLng(epicenter.latitude + 20 / 111.19492664455873, 121.5), + table: table, + ); + expect(estimate.sArrival.inMilliseconds, closeTo(6170, 20)); + expect(estimate.sArrival.inMilliseconds % 1000, isNot(0)); + }, + ); + + group('SWaveCountdown', () { + final arrival = DateTime.utc(2026, 10, 16, 3, 0, 12, 340); + final countdown = SWaveCountdown(arrival); + + test('arrived switches exactly at the computed instant, never later', () { + expect( + countdown.arrivedAt(arrival.subtract(const Duration(microseconds: 1))), + isFalse, + ); + expect(countdown.arrivedAt(arrival), isTrue); + expect( + countdown.arrivedAt(arrival.add(const Duration(hours: 1))), + isTrue, + ); + }); + + test('seconds left round up — 1 until arrival, never 0 while inbound', () { + expect( + countdown.secondsLeftAt( + arrival.subtract(const Duration(milliseconds: 1)), + ), + 1, + ); + expect( + countdown.secondsLeftAt(arrival.subtract(const Duration(seconds: 1))), + 1, + ); + expect( + countdown.secondsLeftAt( + arrival.subtract(const Duration(seconds: 1, milliseconds: 1)), + ), + 2, + ); + expect( + countdown.secondsLeftAt(arrival.subtract(const Duration(seconds: 10))), + 10, + ); + expect(countdown.secondsLeftAt(arrival), 0); + expect( + countdown.secondsLeftAt(arrival.add(const Duration(seconds: 3))), + 0, + ); + }); + + test('the next change lands on a whole second before the arrival', () { + // 4.66 s out: the shown 5 becomes 4 in 0.66 s, at arrival − 4 s. + final now = arrival.subtract(const Duration(milliseconds: 4660)); + final next = countdown.untilNextChangeAt(now)!; + expect(next, const Duration(milliseconds: 660)); + expect(countdown.secondsLeftAt(now), 5); + expect(countdown.secondsLeftAt(now.add(next)), 4); + // On a boundary itself, the next change is a full second away. + final onBoundary = arrival.subtract(const Duration(seconds: 3)); + expect( + countdown.untilNextChangeAt(onBoundary), + const Duration(seconds: 1), + ); + // The last change is "arrived" itself. + final last = arrival.subtract(const Duration(milliseconds: 250)); + expect(last.add(countdown.untilNextChangeAt(last)!), arrival); + expect(countdown.untilNextChangeAt(arrival), isNull); + }); + + test('of() adds the travel time to the origin', () { + final estimate = EewLocalEstimate( + distanceKm: 0, + intensity: 0, + scale: 0, + sArrival: const Duration(milliseconds: 12340), + ); + expect( + SWaveCountdown.of(eew(), estimate).arrival, + DateTime.fromMillisecondsSinceEpoch(1786362612340, isUtc: true), + ); + }); + }); } /// A CWA-style travel-time table for depths 10/30 km spanning Taiwan's extent. diff --git a/test/features/earthquake/presentation/widgets/eew_card_test.dart b/test/features/earthquake/presentation/widgets/eew_card_test.dart index 182bb0ff8..da5feaf3d 100644 --- a/test/features/earthquake/presentation/widgets/eew_card_test.dart +++ b/test/features/earthquake/presentation/widgets/eew_card_test.dart @@ -12,6 +12,8 @@ import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/eew_estimate_tile.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; +import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -248,4 +250,33 @@ void main() { expect(find.text('Report 4'), findsOneWidget); await tester.pumpWidget(const SizedBox()); }); + + testWidgets('"arrived" switches at the computed millisecond, and the ' + 'seconds before it round up', (tester) async { + final store = await _store(); + final alert = _pastAlert(); + final estimate = estimateLocalShaking( + alert, + const LatLng(24.1, 121.6), + table: _table, + ); + final arrival = SWaveCountdown.of(alert, estimate).arrival; + + Future at(DateTime now) async { + await tester.pumpWidget(_wrap(store, eew: alert, clock: () => now)); + await tester.pump(); // the table resolves + } + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + await at(arrival.subtract(const Duration(milliseconds: 1))); + expect(find.text(l10n.eewCountdown(1)), findsOneWidget); + expect(find.text(l10n.eewArrived), findsNothing); + + await at(arrival.subtract(const Duration(milliseconds: 1200))); + expect(find.text(l10n.eewCountdown(2)), findsOneWidget); + + await at(arrival); + expect(find.text(l10n.eewArrived), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); } diff --git a/test/shared/widgets/second_ticker_test.dart b/test/shared/widgets/second_ticker_test.dart index a67938145..7f8952996 100644 --- a/test/shared/widgets/second_ticker_test.dart +++ b/test/shared/widgets/second_ticker_test.dart @@ -51,6 +51,74 @@ void main() { // resume the timer. expect(host.builds, hidden); }); + + testWidgets('ticks land on whole seconds of the clock, not of the mount', ( + tester, + ) async { + final stamps = []; + // Mount at an arbitrary fraction of a second. + await tester.pump(const Duration(milliseconds: 370)); + await tester.pumpWidget( + MaterialApp( + home: _AlignedHost( + now: () => tester.binding.clock.now().toUtc(), + onTick: stamps.add, + ), + ), + ); + expect( + stamps.single.microsecondsSinceEpoch % 1000000, + isNot(0), + reason: + 'the mount itself must be off the second for this to test ' + 'anything — a periodic timer from an on-second mount would pass too', + ); + stamps.clear(); + await tester.pump(const Duration(milliseconds: 3500)); + + expect(stamps.length, greaterThanOrEqualTo(3)); + for (final stamp in stamps) { + expect( + stamp.microsecondsSinceEpoch % 1000000, + 0, + reason: 'tick at $stamp is off the second', + ); + } + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a host can move the ticks to its own phase and re-aim them', ( + tester, + ) async { + final stamps = []; + final key = GlobalKey<_AlignedHostState>(); + await tester.pumpWidget( + MaterialApp( + home: _AlignedHost( + key: key, + now: () => tester.binding.clock.now().toUtc(), + onTick: stamps.add, + phase: const Duration(milliseconds: 340), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 1)); + stamps.clear(); + await tester.pump(const Duration(milliseconds: 2500)); + expect(stamps, isNotEmpty); + for (final stamp in stamps) { + expect(stamp.microsecondsSinceEpoch % 1000000, 340000); + } + + key.currentState!.movePhase(const Duration(milliseconds: 800)); + stamps.clear(); + await tester.pump(const Duration(milliseconds: 2500)); + expect(stamps, isNotEmpty); + for (final stamp in stamps) { + expect(stamp.microsecondsSinceEpoch % 1000000, 800000); + } + await tester.pumpWidget(const SizedBox()); + }); } class _Host extends StatefulWidget { @@ -82,3 +150,51 @@ class _HostState extends State<_Host> with SecondTicker { return Text('ticks $builds'); } } + +/// Records the clock reading of every tick, on a phase it can move. +class _AlignedHost extends StatefulWidget { + const _AlignedHost({ + super.key, + required this.now, + required this.onTick, + this.phase, + }); + + final DateTime Function() now; + final ValueChanged onTick; + final Duration? phase; + + @override + State<_AlignedHost> createState() => _AlignedHostState(); +} + +class _AlignedHostState extends State<_AlignedHost> with SecondTicker { + late Duration? _phase = widget.phase; + + void movePhase(Duration phase) { + _phase = phase; + realignSecondTicker(); + } + + @override + DateTime secondTickerNow() => widget.now(); + + @override + Duration secondTickerDelay(DateTime now) { + final phase = _phase; + if (phase == null) return super.secondTickerDelay(now); + final into = (now.microsecondsSinceEpoch - phase.inMicroseconds) % 1000000; + return Duration(microseconds: 1000000 - into); + } + + /// Stamped where the tick lands, not at the frame that follows it — a + /// test pump renders one frame at its end, however many ticks it spans. + @override + void setState(VoidCallback fn) { + widget.onTick(secondTickerNow()); + super.setState(fn); + } + + @override + Widget build(BuildContext context) => const SizedBox(); +}