From 6f981cec436f0b6479b6ec2710e1efae43afd513 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:13:11 +0800 Subject: [PATCH 1/3] feat(eew): announce predicted intensity before warning sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 強震監視器會先朗讀地震預估震度,再播放警示音 New(en-US): the seismic monitor announces predicted intensity before the warning sound --- android/app/src/main/AndroidManifest.xml | 4 + lib/core/build/demo_flags.dart | 11 ++ lib/core/di/core_providers.dart | 5 + .../foreground_eew_announcement_gate.dart | 102 ++++++++++ .../notifications/notification_service.dart | 73 +++++++- lib/core/speech/speech_service.dart | 65 +++++++ .../earthquake/data/monitor_demo.dart | 8 +- .../monitor_eew_announcement_controller.dart | 127 +++++++++++++ .../map/presentation/pages/map_page.dart | 9 +- .../widgets/rts_monitor_panel.dart | 126 ++++++++++++- lib/l10n/app_en.arb | 10 + 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 | 10 + lib/l10n/gen/app_localizations_fil.dart | 10 + lib/l10n/gen/app_localizations_id.dart | 10 + lib/l10n/gen/app_localizations_ja.dart | 10 + lib/l10n/gen/app_localizations_ko.dart | 10 + lib/l10n/gen/app_localizations_th.dart | 10 + lib/l10n/gen/app_localizations_vi.dart | 10 + lib/l10n/gen/app_localizations_yue.dart | 10 + lib/l10n/gen/app_localizations_zh.dart | 40 ++++ lib/shared/seismic/spoken_intensity.dart | 47 +++++ pubspec.lock | 12 +- pubspec.yaml | 1 + ...foreground_eew_announcement_gate_test.dart | 76 ++++++++ ...itor_eew_announcement_controller_test.dart | 176 ++++++++++++++++++ .../shared/seismic/spoken_intensity_test.dart | 19 ++ 38 files changed, 1018 insertions(+), 29 deletions(-) create mode 100644 lib/core/notifications/foreground_eew_announcement_gate.dart create mode 100644 lib/core/speech/speech_service.dart create mode 100644 lib/features/map/presentation/monitor_eew_announcement_controller.dart create mode 100644 lib/shared/seismic/spoken_intensity.dart create mode 100644 test/core/notifications/foreground_eew_announcement_gate_test.dart create mode 100644 test/features/map/presentation/monitor_eew_announcement_controller_test.dart create mode 100644 test/shared/seismic/spoken_intensity_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 88027c5ab..eb40eec1c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -150,6 +150,10 @@ In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> + + + + diff --git a/lib/core/build/demo_flags.dart b/lib/core/build/demo_flags.dart index b1dd11fb0..cc4e67a2f 100644 --- a/lib/core/build/demo_flags.dart +++ b/lib/core/build/demo_flags.dart @@ -18,6 +18,9 @@ const String _monitorDemoSevereRaw = String.fromEnvironment( const String _startupEewDemoRaw = String.fromEnvironment( 'DPIP_DEMO_STARTUP_EEW', ); +const String _monitorDemoSoundRaw = String.fromEnvironment( + 'DPIP_DEMO_MONITOR_SOUND', +); /// Whether the 強震監視器 demo feeds are on: debug builds launched with /// `--dart-define=DPIP_DEMO_MONITOR=true` (or `=1`). The flag is forced off @@ -48,3 +51,11 @@ const bool kStartupEewDemoEnabled = const bool kMonitorDemoSevereEnabled = (_monitorDemoSevereRaw == 'true' || _monitorDemoSevereRaw == '1') && kDebugMode; + +/// Whether the monitor demo submits one foreground notification through the +/// real EEW announcement gate. Kept separate because the original alarm sound +/// is deliberately disruptive. It is inert outside a debug monitor demo. +const bool kMonitorDemoSoundEnabled = + kMonitorDemoEnabled && + (_monitorDemoSoundRaw == 'true' || _monitorDemoSoundRaw == '1') && + kDebugMode; diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index e7d0ca954..3057f276f 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -35,6 +35,7 @@ import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/color_vision_controller.dart'; import 'package:dpip/core/settings/display_settings.dart'; import 'package:dpip/core/settings/theme_controller.dart'; +import 'package:dpip/core/speech/speech_service.dart'; import 'package:dpip/shared/map/map_tile_cache.dart'; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; @@ -78,6 +79,10 @@ List coreProviders(SharedDeps deps) => [ ChangeNotifierProvider.value(value: deps.permissionHealth), Provider.value(value: deps.realtimeService), Provider.value(value: deps.notificationService), + Provider( + create: (_) => SystemSpeechService(), + dispose: (_, speech) => speech.dispose(), + ), Provider.value(value: deps.meshtastic), ChangeNotifierProvider.value(value: deps.meshLink), ChangeNotifierProvider.value(value: deps.meshAlerts), diff --git a/lib/core/notifications/foreground_eew_announcement_gate.dart b/lib/core/notifications/foreground_eew_announcement_gate.dart new file mode 100644 index 000000000..631f96ede --- /dev/null +++ b/lib/core/notifications/foreground_eew_announcement_gate.dart @@ -0,0 +1,102 @@ +/// Coordinates foreground EEW speech with the notification that plays its +/// configured warning sound. +library; + +import 'dart:async'; + +/// Holds the newest foreground EEW notification while an announcement is +/// speaking, then releases it when the newest announcement completes. +/// +/// Background delivery never passes through this gate. A bounded timeout is a +/// safety fallback: a broken or unavailable TTS engine must not suppress the +/// warning notification indefinitely. +class ForegroundEewAnnouncementGate { + // The monitor controller gives system TTS eight seconds to finish. Keep the + // independent notification fallback beyond that bound so a slow but healthy + // voice cannot overlap the alarm; the fallback still prevents a wedged + // engine from suppressing the warning indefinitely. + ForegroundEewAnnouncementGate({this.maxHold = const Duration(seconds: 10)}); + + final Duration maxHold; + + bool _active = false; + bool _announcing = false; + int _generation = 0; + Future Function()? _pending; + Timer? _timer; + + /// Whether the visible monitor currently owns foreground EEW sequencing. + bool get active => _active; + + /// Enables or disables sequencing. Disabling immediately releases anything + /// pending so leaving the monitor can never swallow a warning. + void setActive(bool value) { + if (_active == value) return; + _active = value; + if (!value) { + _generation++; + _announcing = false; + unawaited(_release()); + } + } + + /// Marks a new report as the announcement that must finish before warning + /// sound playback. The returned generation identifies that exact report. + int beginAnnouncement() { + _announcing = true; + final generation = ++_generation; + // A notification retained for the previous serial now belongs to the + // latest speech sequence. Give that sequence its own full safety window. + if (_pending != null) { + _timer?.cancel(); + _timer = Timer(maxHold, () => unawaited(_release())); + } + return generation; + } + + /// Displays immediately unless the monitor is active and an announcement is + /// in flight. At most the newest notification is retained during rapid EEW + /// report updates, matching the UI and spoken latest-report policy. + Future submit(Future Function() display) async { + if (!_active || !_announcing) { + await display(); + return; + } + + _pending = display; + _timer?.cancel(); + _timer = Timer(maxHold, () => unawaited(_release())); + } + + /// Releases the pending warning only when [generation] still represents the + /// newest report. Completion from interrupted speech is ignored. + Future completeAnnouncement(int generation) async { + if (generation != _generation) return; + _announcing = false; + await _release(); + } + + /// Abandons the current speech wait and releases its pending warning. + void cancelAnnouncement() { + _generation++; + _announcing = false; + unawaited(_release()); + } + + Future _release() async { + _timer?.cancel(); + _timer = null; + _announcing = false; + final display = _pending; + _pending = null; + if (display != null) await display(); + } + + /// Cancels timers. Call only when the owning notification service is torn + /// down; ordinary monitor deactivation must use [setActive] so it flushes. + void dispose() { + _timer?.cancel(); + _timer = null; + _pending = null; + } +} diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index c04a35af5..106427fbe 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -8,6 +8,7 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/permissions/permission_outcome.dart'; import 'package:dpip/core/permissions/system_settings.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; +import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; import 'package:dpip/core/notifications/notification_samples.dart'; import 'package:dpip/core/notifications/notification_taps.dart'; import 'package:dpip/core/notifications/plain_channels.dart'; @@ -34,10 +35,31 @@ const String _fallbackChannelKey = 'announcement-general-v2'; /// [NotificationTaps]. A `notification`-payload message is displayed by the OS /// directly (its tap arrives via `onMessageOpenedApp`). class NotificationService { - NotificationService(this._settings); + NotificationService( + this._settings, { + ForegroundEewAnnouncementGate? foregroundEewGate, + }) : foregroundEewGate = + foregroundEewGate ?? ForegroundEewAnnouncementGate() { + _foregroundEewGate = this.foregroundEewGate; + } final SettingsStore _settings; + /// Sequences foreground EEW speech before the notification channel sound. + /// Background and terminated delivery bypass this object entirely. + final ForegroundEewAnnouncementGate foregroundEewGate; + + /// The same gate, reachable from [onFcmSilentData]. + /// + /// That function is a top-level entry point — awesome_notifications_fcm + /// calls it with no service instance to reach — so the gate the visible + /// monitor speaks through has to be published somewhere it can see. It stays + /// null on the background isolate, which never runs this constructor and + /// where nothing is speaking; the foreground branch there displays + /// immediately when it is null, so a missing instance can only ever mean the + /// warning arrives sooner, never later. + static ForegroundEewAnnouncementGate? _foregroundEewGate; + /// The last push token, or null before registration. String? get token => _settings.getString(SettingKeys.pushToken); @@ -441,6 +463,38 @@ class NotificationService { ); } + /// Submits a debug monitor warning through the same foreground EEW gate as + /// an FCM message. The caller is compile-time gated by the demo sound flag; + /// this guard also makes an accidental release call inert. + Future showDebugEewWarning({ + required String title, + required String body, + }) async { + if (!kDebugMode) return; + await foregroundEewGate.submit(() async { + final created = await AwesomeNotifications().createNotification( + content: NotificationContent( + id: 570057, + channelKey: 'eew_alert-important-v2', + title: title, + body: body, + wakeUpScreen: true, + category: NotificationCategory.Alarm, + payload: const { + 'channel': 'eew_alert-important-v2', + 'id': 'demo-monitor-sound', + }, + ), + ); + if (!created) { + Log.warning( + 'monitor demo warning was rejected — notification permission or ' + 'channel settings may be disabled', + ); + } + }); + } + /// Fetches the push token and persists it as [SettingKeys.pushToken] — /// the identifier every backend registration call (`/v2/location`, /// `/v2/notify`) keys on. @@ -723,5 +777,20 @@ Future onFcmSilentData(FcmSilentData silentData) async { final content = contentFromData(data.cast()); if (content == null) return; - await AwesomeNotifications().createNotification(content: content); + + Future display() => + AwesomeNotifications().createNotification(content: content); + + // A foreground EEW is the one case that waits: the visible monitor may be + // speaking the estimated intensity, and the channel's warning sound must not + // talk over it. Every other lifecycle and every other channel displays + // straight away, and the gate's own timeout bounds this one. + final gate = NotificationService._foregroundEewGate; + if (gate != null && + silentData.createdLifeCycle == NotificationLifeCycle.Foreground && + (content.channelKey?.startsWith('eew') ?? false)) { + await gate.submit(display); + return; + } + await display(); } diff --git a/lib/core/speech/speech_service.dart b/lib/core/speech/speech_service.dart new file mode 100644 index 000000000..3c1a26a80 --- /dev/null +++ b/lib/core/speech/speech_service.dart @@ -0,0 +1,65 @@ +/// System text-to-speech abstraction used by foreground safety announcements. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_tts/flutter_tts.dart'; + +/// Speaks short phrases through the platform speech engine. +abstract interface class SpeechService { + /// Stops any current phrase and speaks [text] to completion. + Future speak(String text, {required String languageTag}); + + /// Stops the current phrase, if any. + Future stop(); + + /// Releases transient speech state owned by this service. + void dispose(); +} + +/// Android `TextToSpeech` / iOS `AVSpeechSynthesizer` implementation. +class SystemSpeechService implements SpeechService { + SystemSpeechService({FlutterTts? engine}) : _engine = engine ?? FlutterTts(); + + final FlutterTts _engine; + bool _configured = false; + + Future _configure() async { + if (_configured) return; + await _engine.awaitSpeakCompletion(true); + if (defaultTargetPlatform == TargetPlatform.iOS) { + // The plugin's default iOS category follows the Silent switch. A + // foreground disaster announcement must remain audible there as well; + // voicePrompt + duckOthers keeps it intelligible without permanently + // taking ownership of another app's audio session. + await _engine.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [ + IosTextToSpeechAudioCategoryOptions.duckOthers, + ], IosTextToSpeechAudioMode.voicePrompt); + } + // Maximise the utterance within the user's selected media-volume level. + // Changing the device's stream volume would be intrusive and would persist + // after the warning, so that remains under the user's control. + await _engine.setVolume(1.0); + _configured = true; + } + + @override + Future speak(String text, {required String languageTag}) async { + await _configure(); + await _engine.stop(); + await _engine.setLanguage(languageTag); + final result = await _engine.speak(text); + if (result != 1) throw StateError('System TTS rejected speech'); + } + + @override + Future stop() async { + await _engine.stop(); + } + + @override + void dispose() { + unawaited(stop()); + } +} diff --git a/lib/features/earthquake/data/monitor_demo.dart b/lib/features/earthquake/data/monitor_demo.dart index ae4569088..674d31dd1 100644 --- a/lib/features/earthquake/data/monitor_demo.dart +++ b/lib/features/earthquake/data/monitor_demo.dart @@ -161,12 +161,14 @@ class StartupEewDemoSource extends RealtimeSource> { } /// Polls as an always-live EEW alert for [MonitorDemo]'s event, bumping the -/// serial every couple of seconds so the feed visibly updates and the monitor -/// cards re-render while the wavefront keeps expanding. +/// serial every twelve seconds so the feed visibly updates while leaving even +/// the slower Google zh-TW voice enough time to finish. A two-second demo +/// cadence kept interrupting the phrase at its comma; six seconds still cut +/// the final word after accounting for that engine's startup latency. class DemoEewSource extends RealtimeSource> { DemoEewSource(this._reports) { _alerts = [_build(1)]; - _tick = Timer.periodic(const Duration(seconds: 2), (_) { + _tick = Timer.periodic(const Duration(seconds: 12), (_) { _alerts = [_build(++_serial)]; }); unawaited(_loadReport()); diff --git a/lib/features/map/presentation/monitor_eew_announcement_controller.dart b/lib/features/map/presentation/monitor_eew_announcement_controller.dart new file mode 100644 index 000000000..bd8bd2630 --- /dev/null +++ b/lib/features/map/presentation/monitor_eew_announcement_controller.dart @@ -0,0 +1,127 @@ +/// Latest-report-wins speech state machine for the visible seismic monitor. +library; + +import 'dart:async'; + +import 'package:dpip/core/logging/log.dart'; +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'; + +/// A shaking scale together with whether it is local or the max fallback. +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. +/// +/// Every accepted update stops the previous utterance immediately. Async +/// estimate/speech completions carry a generation, so an obsolete report can +/// neither speak late nor release the warning sound for a newer report. +class MonitorEewAnnouncementController { + MonitorEewAnnouncementController( + this._speech, + this._gate, + this._estimate, { + // 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 + // engine without overriding the user's system speech rate. Notification + // 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), + }); + + final SpeechService _speech; + final ForegroundEewAnnouncementGate _gate; + final Future Function(Eew alert) _estimate; + final Duration speechTimeout; + + final Map _seenSerials = {}; + bool _active = false; + bool _hasCurrentAlert = false; + int _generation = 0; + + /// Activates announcements only for the foreground, visible monitor. + void setActive(bool value) { + if (_active == value) return; + _active = value; + _generation++; + _gate.setActive(value); + if (!value) { + _hasCurrentAlert = false; + unawaited(_speech.stop()); + } + } + + /// Consumes a feed snapshot. Stale/offline/calm snapshots stop speech; live + /// duplicates and older serials are ignored. + 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()); + return; + } + + final alert = alerts.first; + final previous = _seenSerials[alert.id]; + if (previous != null && alert.serial <= previous) return; + _seenSerials[alert.id] = alert.serial; + _hasCurrentAlert = true; + + final generation = ++_generation; + final gateGeneration = _gate.beginAnnouncement(); + unawaited( + _announce(alert, generation, gateGeneration, languageTag, format), + ); + } + + Future _announce( + Eew alert, + int generation, + int gateGeneration, + String languageTag, + EewSpeechFormatter format, + ) async { + try { + await _speech.stop(); + final estimate = await _estimate(alert); + if (!_active || generation != _generation) return; + await _speech + .speak(format(estimate), languageTag: languageTag) + .timeout(speechTimeout); + } catch (error, stackTrace) { + // stop() completing the superseded speak future with a non-success result + // is the expected latest-report-wins path, not a TTS engine failure. + if (!_active || generation != _generation) return; + Log.handle(error, stackTrace, 'foreground EEW speech'); + await _speech.stop(); + } finally { + if (_active && generation == _generation) { + await _gate.completeAnnouncement(gateGeneration); + } + } + } + + /// Stops speech and releases any foreground warning retained by the gate. + void dispose() { + _active = false; + _hasCurrentAlert = false; + _generation++; + _gate.setActive(false); + unawaited(_speech.stop()); + } +} diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 9f1cbe997..a75ff290b 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -1,7 +1,6 @@ /// Full-screen map tab — assembles overlay layers for [MapScaffold]. library; -import 'package:dpip/core/build/demo_flags.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/settings/default_map_layer.dart'; @@ -129,10 +128,10 @@ class _MapPageState extends State { @override Widget build(BuildContext context) { final visibility = context.watch(); - // In demo mode the monitor is what there is to see — open straight on it. - final preferred = kMonitorDemoEnabled - ? DefaultMapLayer.monitor - : context.watch().layer; + // The monitor demo no longer opens straight onto the monitor: speech and + // its warning sound are scoped to a monitor the user is actually viewing, + // so demo data must not silently change the active layer. + final preferred = context.watch().layer; // Open on the preferred layer unless it (and only it) is hidden; hidden // layers are otherwise offered like any other. final initial = _layers.firstWhere( diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 0d08fae33..e0a755158 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -5,21 +5,32 @@ /// [MapLayer.buildLegend]. library; +import 'dart:async'; + import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/build/demo_flags.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/geo/location_service.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/rts.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; +import 'package:dpip/features/map/presentation/monitor_eew_announcement_controller.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/alert_cycle_chip.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:dpip/shared/seismic/spoken_intensity.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; /// The RTS layer's overlay, laid over the full map (via the scaffold's /// `buildSheet` slot): the active EEW alert card above a freshness strip at @@ -54,7 +65,8 @@ class RtsMonitorPanel extends StatefulWidget { State createState() => _RtsMonitorPanelState(); } -class _RtsMonitorPanelState extends State { +class _RtsMonitorPanelState extends State + with WidgetsBindingObserver { /// Whether the map tab is the shell's visible one. The RTS feed keeps /// notifying at ~1 Hz behind other tabs (the polling itself must continue — /// it is a safety feed), but rebuilding a hidden panel for every poll is @@ -62,14 +74,22 @@ class _RtsMonitorPanelState extends State { /// up in one build on return. bool _visible = true; VisibleTab? _visibleTab; + MonitorEewAnnouncementController? _announcement; + AppLocalizations? _l10n; + String _languageTag = 'zh-TW'; + AppLifecycleState? _lifecycleState; + bool _demoWarningSubmitted = false; void _onData() { + _syncAnnouncement(); if (_visible && mounted) setState(() {}); } @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + _lifecycleState = WidgetsBinding.instance.lifecycleState; widget.feed.addListener(_onData); widget.eew.addListener(_onData); widget.eewIndex.addListener(_onData); @@ -90,33 +110,127 @@ class _RtsMonitorPanelState extends State { oldWidget.eewIndex.removeListener(_onData); widget.eewIndex.addListener(_onData); } + _syncAnnouncement(); } @override void didChangeDependencies() { super.didChangeDependencies(); + _l10n = AppLocalizations.of(context); + _languageTag = Localizations.localeOf(context).toLanguageTag(); + _announcement ??= _createAnnouncementController(); final visibleTab = VisibleTabScope.of(context); - if (identical(visibleTab, _visibleTab)) return; - _visibleTab?.removeListener(_syncVisibility); - _visibleTab = visibleTab; - visibleTab?.addListener(_syncVisibility); - _syncVisibility(); + if (!identical(visibleTab, _visibleTab)) { + _visibleTab?.removeListener(_syncVisibility); + _visibleTab = visibleTab; + visibleTab?.addListener(_syncVisibility); + _syncVisibility(); + } + _syncAnnouncement(); + } + + MonitorEewAnnouncementController? _createAnnouncementController() { + // Nullable reads keep this leaf widget independently testable; the app's + // core provider list always supplies both services. + final speech = context.read(); + final notifications = context.read(); + if (speech == null || notifications == null) return null; + final location = context.read(); + return MonitorEewAnnouncementController( + speech, + notifications.foregroundEewGate, + (alert) async { + // A warning cannot wait on a live GPS timeout. Use the OS's recent + // cached fix; when none is fresh enough, announce the EEW max instead. + final fix = await location.lastKnownFix(); + if (fix == null) { + return (scale: alert.info.max.clamp(0, 9), isLocal: false); + } + final estimate = estimateLocalShaking(alert, LatLng(fix.lat, fix.lng)); + return (scale: estimate.scale, isLocal: true); + }, + ); } void _syncVisibility() { final visible = _visibleTab?.isOnScreen(MapPage.tabIndex) ?? true; if (visible == _visible) return; _visible = visible; + _syncAnnouncement(); // Coming back: one build to catch up on everything missed while hidden. if (visible && mounted) setState(() {}); } + /// Sound must use a stricter visibility check than rendering. This widget + /// can be mounted before the shell installs [VisibleTabScope], and treating + /// that transient state as visible would announce an alert from a map branch + /// the user has not opened yet. + bool get _isMonitorOnScreen => + _visibleTab?.isOnScreen(MapPage.tabIndex) ?? false; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _lifecycleState = state; + _syncAnnouncement(); + } + + void _syncAnnouncement() { + final controller = _announcement; + final l10n = _l10n; + if (controller == null || l10n == null) return; + final foreground = + _lifecycleState == null || _lifecycleState == AppLifecycleState.resumed; + controller.setActive(_isMonitorOnScreen && foreground); + controller.update( + widget.eew.state, + languageTag: _languageTag, + format: (estimate) { + final intensity = spokenIntensityLabel(estimate.scale, _languageTag); + return estimate.isLocal + ? l10n.eewSpokenLocalIntensity(intensity) + : l10n.eewSpokenMaxIntensity(intensity); + }, + ); + _submitDemoWarning(l10n); + } + + void _submitDemoWarning(AppLocalizations l10n) { + final foreground = + _lifecycleState == null || _lifecycleState == AppLifecycleState.resumed; + if (!kMonitorDemoSoundEnabled || + _demoWarningSubmitted || + !_isMonitorOnScreen || + !foreground) { + return; + } + final state = widget.eew.state; + final alerts = state.data; + if (state.status != RealtimeStatus.live || + alerts == null || + alerts.isEmpty) { + return; + } + _demoWarningSubmitted = true; + final intensity = spokenIntensityLabel( + alerts.first.info.max.clamp(0, 9), + _languageTag, + ); + unawaited( + context.read().showDebugEewWarning( + title: l10n.mapLayerMonitor, + body: l10n.eewSpokenMaxIntensity(intensity), + ), + ); + } + @override void dispose() { widget.feed.removeListener(_onData); widget.eew.removeListener(_onData); widget.eewIndex.removeListener(_onData); _visibleTab?.removeListener(_syncVisibility); + WidgetsBinding.instance.removeObserver(this); + _announcement?.dispose(); super.dispose(); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c99e90d94..7117c7add 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3958,5 +3958,15 @@ "bugTrackerStaff": "Staff", "@bugTrackerStaff": { "description": "Badge beside triage-team names on bug threads" + }, + "eewSpokenLocalIntensity": "Estimated intensity at your location: {intensity}.", + "@eewSpokenLocalIntensity": { + "description": "Short foreground TTS phrase before an EEW warning sound", + "placeholders": {"intensity": {"type": "String"}} + }, + "eewSpokenMaxIntensity": "Estimated maximum intensity: {intensity}.", + "@eewSpokenMaxIntensity": { + "description": "TTS fallback when the device location is unavailable", + "placeholders": {"intensity": {"type": "String"}} } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 2a1746f16..744f48b69 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Makilahok sa talakayan sa Discord", "bugTrackerSortLast": "Pinakabagong aktibidad", "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan", - "bugTrackerStaff": "Kawani" + "bugTrackerStaff": "Kawani", + "eewSpokenLocalIntensity": "Tinatayang intensidad sa iyong lokasyon: {intensity}.", + "eewSpokenMaxIntensity": "Tinatayang pinakamataas na intensidad: {intensity}." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 689b14fa2..38c8355c2 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Ikuti diskusi di Discord", "bugTrackerSortLast": "Aktivitas terbaru", "bugTrackerSortMostDiscussed": "Paling banyak dibahas", - "bugTrackerStaff": "Staf" + "bugTrackerStaff": "Staf", + "eewSpokenLocalIntensity": "Perkiraan intensitas di lokasi Anda: {intensity}.", + "eewSpokenMaxIntensity": "Perkiraan intensitas maksimum: {intensity}." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2f939c045..d8b1c6bb7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Discord で議論に参加する", "bugTrackerSortLast": "最新の返信", "bugTrackerSortMostDiscussed": "返信が多い順", - "bugTrackerStaff": "スタッフ" + "bugTrackerStaff": "スタッフ", + "eewSpokenLocalIntensity": "現在地の予想震度、{intensity}。", + "eewSpokenMaxIntensity": "予想最大震度、{intensity}。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3a6f36861..41640e4f4 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Discord에서 논의에 참여하기", "bugTrackerSortLast": "최근 활동", "bugTrackerSortMostDiscussed": "답글 많은 순", - "bugTrackerStaff": "스태프" + "bugTrackerStaff": "스태프", + "eewSpokenLocalIntensity": "현재 위치 예상 진도, {intensity}.", + "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 4cabac60c..01e8b4461 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "ร่วมพูดคุยที่ Discord", "bugTrackerSortLast": "ล่าสุด", "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด", - "bugTrackerStaff": "ทีมงาน" + "bugTrackerStaff": "ทีมงาน", + "eewSpokenLocalIntensity": "คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: {intensity}", + "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 585fd637b..06c96ae23 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Tham gia thảo luận trên Discord", "bugTrackerSortLast": "Hoạt động mới nhất", "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất", - "bugTrackerStaff": "Nhân sự" + "bugTrackerStaff": "Nhân sự", + "eewSpokenLocalIntensity": "Cường độ dự kiến tại vị trí của bạn: {intensity}.", + "eewSpokenMaxIntensity": "Cường độ tối đa dự kiến: {intensity}." } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 23614e1a5..c9bb04df7 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "去 Discord 一齊傾", "bugTrackerSortLast": "最後傾偈", "bugTrackerSortMostDiscussed": "最多討論", - "bugTrackerStaff": "工作人員" + "bugTrackerStaff": "工作人員", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d8cfad62d..95838e573 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1974,5 +1974,7 @@ "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", "bugTrackerSortMostDiscussed": "最多讨论", - "bugTrackerStaff": "工作人员" + "bugTrackerStaff": "工作人员", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 82b149b76..545eeedd5 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", "bugTrackerSortMostDiscussed": "最多讨论", - "bugTrackerStaff": "工作人员" + "bugTrackerStaff": "工作人员", + "eewSpokenLocalIntensity": "所在地预估烈度,{intensity}。", + "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 397163df6..4df46480e 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", "bugTrackerSortMostDiscussed": "最多討論", - "bugTrackerStaff": "工作人員" + "bugTrackerStaff": "工作人員", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 29c787c8c..d62a70b39 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", "bugTrackerSortMostDiscussed": "最多討論", - "bugTrackerStaff": "工作人員" + "bugTrackerStaff": "工作人員", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3d3cf2cbb..5b09af43a 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6274,6 +6274,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Staff'** String get bugTrackerStaff; + + /// Short foreground TTS phrase before an EEW warning sound + /// + /// In en, this message translates to: + /// **'Estimated intensity at your location: {intensity}.'** + String eewSpokenLocalIntensity(String intensity); + + /// TTS fallback when the device location is unavailable + /// + /// In en, this message translates to: + /// **'Estimated maximum intensity: {intensity}.'** + String eewSpokenMaxIntensity(String intensity); } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 074c99832..3bb99d1d2 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3300,4 +3300,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bugTrackerStaff => 'Staff'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Estimated intensity at your location: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Estimated maximum intensity: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 580efa4bb..296649036 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3318,4 +3318,14 @@ class AppLocalizationsFil extends AppLocalizations { @override String get bugTrackerStaff => 'Kawani'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Tinatayang intensidad sa iyong lokasyon: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Tinatayang pinakamataas na intensidad: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 0cb43d608..b4b6392c0 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3311,4 +3311,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get bugTrackerStaff => 'Staf'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Perkiraan intensitas di lokasi Anda: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Perkiraan intensitas maksimum: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index f7f7124dd..89dddb373 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3239,4 +3239,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get bugTrackerStaff => 'スタッフ'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '現在地の予想震度、$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '予想最大震度、$intensity。'; + } } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 1ed48100d..e2502f19c 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3239,4 +3239,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get bugTrackerStaff => '스태프'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '현재 위치 예상 진도, $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '예상 최대 진도, $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 48c1bb12f..d476aeb17 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3293,4 +3293,14 @@ class AppLocalizationsTh extends AppLocalizations { @override String get bugTrackerStaff => 'ทีมงาน'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: $intensity'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'คาดการณ์ความรุนแรงสูงสุด: $intensity'; + } } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 9a1cb1b7a..bd731d071 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3301,4 +3301,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String get bugTrackerStaff => 'Nhân sự'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Cường độ dự kiến tại vị trí của bạn: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Cường độ tối đa dự kiến: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index df9333e1a..089cd25c8 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3222,4 +3222,14 @@ class AppLocalizationsYue extends AppLocalizations { @override String get bugTrackerStaff => '工作人員'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 1d6e62400..c85533754 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3222,6 +3222,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bugTrackerStaff => '工作人员'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6441,6 +6451,16 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get bugTrackerStaff => '工作人员'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地预估烈度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '预估最大烈度,$intensity。'; + } } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9660,6 +9680,16 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get bugTrackerStaff => '工作人員'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12879,4 +12909,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get bugTrackerStaff => '工作人員'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } diff --git a/lib/shared/seismic/spoken_intensity.dart b/lib/shared/seismic/spoken_intensity.dart new file mode 100644 index 000000000..b508e55b6 --- /dev/null +++ b/lib/shared/seismic/spoken_intensity.dart @@ -0,0 +1,47 @@ +/// Locale-aware words for speaking Taiwan's ten-step intensity scale. +library; + +/// Returns a TTS-friendly label for a discrete CWA intensity [scale]. +/// +/// Symbols such as `5⁻` are intentionally avoided: platform speech engines +/// pronounce superscript signs inconsistently. Chinese, Japanese, and Korean +/// get their conventional weak/strong words; the Chinese split levels keep a +/// trailing `等級` because Google zh-TW can swallow a sentence-final `強` even +/// though it reports the utterance as completed. Other locales get unambiguous +/// English words inside their localized sentence. +String spokenIntensityLabel(int scale, String languageTag) { + final level = scale.clamp(0, 9); + final language = languageTag.toLowerCase(); + if (language.startsWith('zh')) { + return const [ + '零級', + '一級', + '二級', + '三級', + '四級', + '五弱等級', + '五強等級', + '六弱等級', + '六強等級', + '七級', + ][level]; + } + if (language.startsWith('ja')) { + return const ['0', '1', '2', '3', '4', '5弱', '5強', '6弱', '6強', '7'][level]; + } + if (language.startsWith('ko')) { + return const ['0', '1', '2', '3', '4', '5약', '5강', '6약', '6강', '7'][level]; + } + return const [ + 'zero', + 'one', + 'two', + 'three', + 'four', + 'five lower', + 'five upper', + 'six lower', + 'six upper', + 'seven', + ][level]; +} diff --git a/pubspec.lock b/pubspec.lock index e82963440..8a5798aa1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: archive - sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" args: dependency: transitive description: @@ -415,6 +415,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775 + url: "https://pub.dev" + source: hosted + version: "4.2.5" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 135fc1f79..23dbf39c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: flutter_localizations: sdk: flutter flutter_markdown_plus: ^1.0.5 + flutter_tts: ^4.2.5 # Direct because a MarkdownElementBuilder's signature takes an md.Element # and flutter_markdown_plus does not re-export the package that defines it. markdown: ^7.3.1 diff --git a/test/core/notifications/foreground_eew_announcement_gate_test.dart b/test/core/notifications/foreground_eew_announcement_gate_test.dart new file mode 100644 index 000000000..d1615e291 --- /dev/null +++ b/test/core/notifications/foreground_eew_announcement_gate_test.dart @@ -0,0 +1,76 @@ +/// Tests foreground EEW notification sequencing and its safety fallback. +library; + +import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'holds only the newest notification until latest speech completes', + () async { + final gate = ForegroundEewAnnouncementGate(); + var displayed = []; + gate.setActive(true); + final first = gate.beginAnnouncement(); + + await gate.submit(() async => displayed.add('first')); + final second = gate.beginAnnouncement(); + await gate.submit(() async => displayed.add('second')); + + await gate.completeAnnouncement(first); + expect( + displayed, + isEmpty, + reason: 'obsolete speech cannot release sound', + ); + await gate.completeAnnouncement(second); + expect(displayed, ['second']); + }, + ); + + test('inactive gate displays immediately', () async { + final gate = ForegroundEewAnnouncementGate(); + var displayed = false; + + await gate.submit(() async => displayed = true); + + expect(displayed, isTrue); + }); + + test('default fallback does not overlap the eight-second speech budget', () { + fakeAsync((async) { + final gate = ForegroundEewAnnouncementGate(); + var displayed = false; + gate.setActive(true); + gate.beginAnnouncement(); + gate.submit(() async => displayed = true); + + async.elapse(const Duration(seconds: 8)); + async.flushMicrotasks(); + expect(displayed, isFalse); + + async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(displayed, isTrue); + }); + }); + + test('timeout releases a warning when speech never completes', () { + fakeAsync((async) { + final gate = ForegroundEewAnnouncementGate( + maxHold: const Duration(seconds: 2), + ); + var displayed = false; + gate.setActive(true); + gate.beginAnnouncement(); + gate.submit(() async => displayed = true); + + async.elapse(const Duration(seconds: 1)); + expect(displayed, isFalse); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(displayed, isTrue); + }); + }); +} diff --git a/test/features/map/presentation/monitor_eew_announcement_controller_test.dart b/test/features/map/presentation/monitor_eew_announcement_controller_test.dart new file mode 100644 index 000000000..5208019e3 --- /dev/null +++ b/test/features/map/presentation/monitor_eew_announcement_controller_test.dart @@ -0,0 +1,176 @@ +/// Tests latest-report-wins EEW speech on the visible seismic monitor. +library; + +import 'dart:async'; + +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/map/presentation/monitor_eew_announcement_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeSpeech implements SpeechService { + final List spoken = []; + final List> completions = []; + int stops = 0; + + @override + Future speak(String text, {required String languageTag}) { + spoken.add('$languageTag:$text'); + final completion = Completer(); + completions.add(completion); + return completion.future; + } + + @override + Future stop() async => stops++; + + @override + void dispose() {} +} + +Eew _alert(int serial) => Eew( + agency: 'CWA', + id: 'event', + serial: serial, + status: 0, + isFinal: false, + info: const EewInfo( + time: 0, + longitude: 121, + latitude: 23, + depth: 10, + magnitude: 6, + location: 'test', + max: 6, + ), +); + +RealtimeState> _live(Eew alert) => + RealtimeState(status: RealtimeStatus.live, data: [alert]); + +Future _flush() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +void main() { + test( + 'new serial interrupts old speech and only latest releases sound', + () async { + final speech = _FakeSpeech(); + final gate = ForegroundEewAnnouncementGate(); + final controller = MonitorEewAnnouncementController( + speech, + gate, + (alert) async => (scale: alert.serial, isLocal: true), + ); + controller.setActive(true); + controller.update( + _live(_alert(1)), + languageTag: 'zh-TW', + format: (estimate) => '震度${estimate.scale}', + ); + await _flush(); + expect(speech.spoken, ['zh-TW:震度1']); + + var notifications = 0; + await gate.submit(() async => notifications++); + controller.update( + _live(_alert(2)), + languageTag: 'zh-TW', + format: (estimate) => '震度${estimate.scale}', + ); + await _flush(); + expect(speech.spoken, ['zh-TW:震度1', 'zh-TW:震度2']); + expect(speech.stops, greaterThanOrEqualTo(2)); + + speech.completions.first.complete(); + await _flush(); + expect(notifications, 0); + + speech.completions.last.complete(); + await _flush(); + expect(notifications, 1); + controller.dispose(); + }, + ); + + test('duplicate and older serials are not spoken again', () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + for (final serial in [2, 2, 1]) { + controller.update( + _live(_alert(serial)), + languageTag: 'zh-TW', + format: (_) => '所在地預估震度,四級。', + ); + } + await _flush(); + + expect(speech.spoken, hasLength(1)); + speech.completions.single.complete(); + controller.dispose(); + }); + + test('stale feed stops speech and releases the pending 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); + + controller.update( + RealtimeState>(status: RealtimeStatus.stale, data: [_alert(1)]), + languageTag: 'zh-TW', + format: (_) => 'unused', + ); + await _flush(); + + expect(displayed, isTrue); + expect(speech.stops, greaterThanOrEqualTo(2)); + controller.dispose(); + }); + + test( + 'repeated calm feed ticks do not call the platform every second', + () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + const calm = RealtimeState>( + status: RealtimeStatus.live, + data: [], + ); + + for (var i = 0; i < 3; i++) { + controller.update(calm, languageTag: 'zh-TW', format: (_) => 'unused'); + } + await _flush(); + + expect(speech.stops, 0); + controller.dispose(); + }, + ); +} diff --git a/test/shared/seismic/spoken_intensity_test.dart b/test/shared/seismic/spoken_intensity_test.dart new file mode 100644 index 000000000..659e2dd52 --- /dev/null +++ b/test/shared/seismic/spoken_intensity_test.dart @@ -0,0 +1,19 @@ +/// Tests speech-safe labels for the split CWA intensity scale. +library; + +import 'package:dpip/shared/seismic/spoken_intensity.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Traditional Chinese speaks weak and strong words', () { + expect(spokenIntensityLabel(5, 'zh-TW'), '五弱等級'); + expect(spokenIntensityLabel(6, 'zh-TW'), '五強等級'); + expect(spokenIntensityLabel(7, 'zh-TW'), '六弱等級'); + expect(spokenIntensityLabel(8, 'zh-TW'), '六強等級'); + }); + + test('out-of-range values are clamped', () { + expect(spokenIntensityLabel(-1, 'en'), 'zero'); + expect(spokenIntensityLabel(10, 'en'), 'seven'); + }); +} From 2864ddcfda856f5cb7d31569d8e13f2e1fd74c08 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:14:34 +0800 Subject: [PATCH 2/3] build(android): include x86_64 in debug builds Platform: android --- android/app/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1cc1be24d..7c08a9c36 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -86,6 +86,13 @@ android { } buildTypes { + debug { + // defaultConfig keeps release artifacts arm64-only, but Android + // emulators on Intel/AMD hosts need Flutter's x86_64 engine. + ndk { + abiFilters.add("x86_64") + } + } release { signingConfig = if (keystorePropertiesFile.exists()) { From 1f489c448b65dd62c0bc9e939a44ac7dfc9c21c5 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:05:01 +0800 Subject: [PATCH 3/3] ci(android): build the pull-request APK without the release keystore --- .github/workflows/android.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4724976d5..6e5039ade 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -90,19 +90,19 @@ jobs: bash tool/dev/deps.sh bash tool/dev/codegen.sh - - name: Decode keystore - run: | - echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/my-release-key.jks - - - name: Create key.properties - run: | - cat > android/key.properties << EOF - storePassword=${{ secrets.KEYSTORE_PASSWORD }} - keyPassword=${{ secrets.KEY_PASSWORD }} - keyAlias=${{ secrets.KEY_ALIAS }} - storeFile=my-release-key.jks - EOF - + # No keystore, and deliberately so. This job builds the artifact nobody + # installs — release.yml signs and uploads the one that ships — so the + # release keystore buys nothing here, and asking for it is what breaks + # the job outright: a pull request opened from a fork is handed no + # repository secrets at all, so `secrets.KEYSTORE_BASE64` is the empty + # string, android/key.properties is written with four empty values, and + # Gradle fails inside packageRelease with a keystore error that says + # nothing about forks. Every other check on such a pull request passes, + # including the iOS build, which never codesigns. + # + # android/app/build.gradle.kts already falls back to the debug signing + # config when android/key.properties is absent, so what comes out is the + # same release build, debug-signed. - name: Build Release APK run: bash tool/dev/build.sh android