From 254feefd53bf8f0ec0eb80cdd0d5192e6463ec66 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/4] 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 | 13 +- pubspec.yaml | 15 ++ ...foreground_eew_announcement_gate_test.dart | 76 ++++++++ ...itor_eew_announcement_controller_test.dart | 176 ++++++++++++++++++ .../shared/seismic/spoken_intensity_test.dart | 19 ++ 38 files changed, 1033 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..e00d86104 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,15 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + path: "." + ref: a9cd6eda7feacd78c3fc288272a27edfd614fc30 + resolved-ref: a9cd6eda7feacd78c3fc288272a27edfd614fc30 + url: "https://github.com/ExpTechTW/flutter_tts.git" + source: git + version: "4.3.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 135fc1f79..8ab1989bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,21 @@ dependencies: flutter_localizations: sdk: flutter flutter_markdown_plus: ^1.0.5 + # ExpTechTW's fork, not pub.dev: the published package ships no Swift Package + # Manager support, and this app's iOS side has no CocoaPods setup (README → + # 參與開發). Flutter warns on every build and says it "will become an error in + # a future version" — and until then it quietly writes a Podfile back into the + # project through the dependency, which is the half of that warning nobody + # reads. + # + # The fork carries dlutton/flutter_tts#651 (@shivanshu877) and nothing else. + # Upstream has had three functionally identical SPM pull requests open since + # May with none merged, so waiting on it is not a plan. Pinned to the commit + # on the fork's master, like the maplibre fork below. + flutter_tts: + git: + url: https://github.com/ExpTechTW/flutter_tts.git + ref: a9cd6eda7feacd78c3fc288272a27edfd614fc30 # 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 38fbb425a512f4578fc69d9b9f9326a84ced9d27 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/4] 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 fff17ed6b9f2744ccc2ce758b322fb59c935162d 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/4] ci(android): build the pull-request APK without the release keystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pull request opened from a fork is handed no repository secrets, so `secrets.KEYSTORE_BASE64` and its three companions arrive as the empty string. The job still decoded them: it wrote a zero-byte android/app/my-release-key.jks and an android/key.properties whose four values were blank, and Gradle then died inside packageRelease on a keystore it could not read. Nothing in that failure names a fork, and every other check on the same commit passes — the iOS build included, because it never codesigns. This job was never the one that signs. The header of this file already says so: release.yml builds the signed, uploadable artifact on a push to main, and what comes out of here is the copy nobody installs. So the keystore is dropped rather than guarded. android/app/build.gradle.kts already falls back to the debug signing config when android/key.properties is absent, which makes the pull-request APK the same release build, debug-signed. --- .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 From 12b57209cecc8079b4b3cd2d3dd662a7dea67365 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:59:15 +0800 Subject: [PATCH 4/4] feat(eew): let the user turn off the spoken intensity announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 「更多 → 通知」可以關閉強震監視器朗讀預估震度的語音 New(en-US): the monitor's spoken intensity announcement can be switched off under More → Notifications New(ja-JP): 強震モニタの予想震度の読み上げを「その他 → 通知」でオフにできます --- .../eew_spoken_announcement_settings.dart | 27 +++++++++++ lib/core/settings/setting_keys.dart | 7 +++ .../earthquake/earthquake_providers.dart | 5 ++ .../widgets/rts_monitor_panel.dart | 19 +++++++- .../more/presentation/pages/more_page.dart | 36 ++++++++++++++ lib/l10n/app_en.arb | 20 +++++++- 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 | 7 +++ lib/l10n/gen/app_localizations_fil.dart | 7 +++ lib/l10n/gen/app_localizations_id.dart | 7 +++ lib/l10n/gen/app_localizations_ja.dart | 7 +++ lib/l10n/gen/app_localizations_ko.dart | 7 +++ lib/l10n/gen/app_localizations_th.dart | 7 +++ lib/l10n/gen/app_localizations_vi.dart | 7 +++ lib/l10n/gen/app_localizations_yue.dart | 6 +++ lib/l10n/gen/app_localizations_zh.dart | 24 ++++++++++ ...eew_spoken_announcement_settings_test.dart | 47 +++++++++++++++++++ test/features/more/more_page_test.dart | 31 ++++++++++++ 29 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 lib/core/settings/eew_spoken_announcement_settings.dart create mode 100644 test/core/settings/eew_spoken_announcement_settings_test.dart diff --git a/lib/core/settings/eew_spoken_announcement_settings.dart b/lib/core/settings/eew_spoken_announcement_settings.dart new file mode 100644 index 000000000..11c62eeec --- /dev/null +++ b/lib/core/settings/eew_spoken_announcement_settings.dart @@ -0,0 +1,27 @@ +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter/foundation.dart'; + +/// Whether the visible seismic monitor speaks the estimated intensity before +/// the EEW warning sound plays, persisted via [SettingsStore]. **On** by +/// default: the announcement is what buys the seconds between the alert and +/// the shaking, so a user who wants silence opts out rather than in. +/// +/// Turning it off never delays a warning. The monitor drops its announcement +/// controller to inactive, which releases anything the foreground gate is +/// holding, so the channel's own sound plays exactly as it did before this +/// feature existed. +class EewSpokenAnnouncementSettings extends ChangeNotifier { + EewSpokenAnnouncementSettings(this._settings); + + final SettingsStore _settings; + + /// Whether the foreground monitor may speak. + bool get enabled => + _settings.getBool(SettingKeys.eewSpokenAnnouncement) ?? true; + + Future setEnabled(bool value) async { + await _settings.setBool(SettingKeys.eewSpokenAnnouncement, value); + notifyListeners(); + } +} diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index 618dd0857..aea772505 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -237,6 +237,13 @@ abstract final class SettingKeys { 'earthquake.eewCwaOnly', ); + /// Whether the visible seismic monitor speaks the estimated intensity before + /// the EEW warning sound. Defaults to true. See + /// `EewSpokenAnnouncementSettings`. + static const SettingKey eewSpokenAnnouncement = SettingKey._( + 'earthquake.eewSpokenAnnouncement', + ); + /// Selected LB / Core API region. See `RegionSelection`. /// /// Colon-form kept as-is (pre-existing storage address). diff --git a/lib/features/earthquake/earthquake_providers.dart b/lib/features/earthquake/earthquake_providers.dart index 73fb2caf0..ff5c679dd 100644 --- a/lib/features/earthquake/earthquake_providers.dart +++ b/lib/features/earthquake/earthquake_providers.dart @@ -1,6 +1,7 @@ import 'package:dpip/core/build/demo_flags.dart'; import 'package:dpip/core/di/shared_deps.dart'; import 'package:dpip/core/settings/eew_cwa_only_settings.dart'; +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; import 'package:dpip/core/realtime/elapsed.dart'; import 'package:dpip/core/realtime/realtime_channel.dart'; import 'package:dpip/core/realtime/realtime_config.dart'; @@ -37,6 +38,7 @@ import 'package:provider/single_child_widget.dart'; List earthquakeProviders(SharedDeps deps) { final api = EarthquakeApi(deps.apiClient); final eewCwaOnly = EewCwaOnlySettings(deps.settings); + final eewSpokenAnnouncement = EewSpokenAnnouncementSettings(deps.settings); final repository = EewRepositoryImpl(api, cwaOnly: () => eewCwaOnly.enabled); final reports = ReportRepositoryImpl(api); final tremStations = TremStationRepositoryImpl(deps.apiClient); @@ -92,6 +94,9 @@ List earthquakeProviders(SharedDeps deps) { return [ Provider.value(value: repository), ChangeNotifierProvider.value(value: eewCwaOnly), + ChangeNotifierProvider.value( + value: eewSpokenAnnouncement, + ), Provider.value(value: reports), ChangeNotifierProvider.value(value: eewController), ChangeNotifierProvider.value(value: rtsController), diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index e0a755158..46bf8ff99 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -21,6 +21,7 @@ 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/core/settings/eew_spoken_announcement_settings.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'; @@ -75,6 +76,10 @@ class _RtsMonitorPanelState extends State bool _visible = true; VisibleTab? _visibleTab; MonitorEewAnnouncementController? _announcement; + + /// The user's on/off switch for the announcement, watched so flipping it + /// takes effect on the alert already on screen rather than the next one. + EewSpokenAnnouncementSettings? _speechSettings; AppLocalizations? _l10n; String _languageTag = 'zh-TW'; AppLifecycleState? _lifecycleState; @@ -126,6 +131,12 @@ class _RtsMonitorPanelState extends State visibleTab?.addListener(_syncVisibility); _syncVisibility(); } + final speechSettings = context.read(); + if (!identical(speechSettings, _speechSettings)) { + _speechSettings?.removeListener(_syncAnnouncement); + _speechSettings = speechSettings; + speechSettings?.addListener(_syncAnnouncement); + } _syncAnnouncement(); } @@ -180,7 +191,12 @@ class _RtsMonitorPanelState extends State if (controller == null || l10n == null) return; final foreground = _lifecycleState == null || _lifecycleState == AppLifecycleState.resumed; - controller.setActive(_isMonitorOnScreen && foreground); + // Absent provider means a test that supplied neither — announce, matching + // the default. Switching off deactivates the controller, which stops any + // phrase in flight and releases the notification the gate was holding, so + // the warning sound is never delayed by a setting the user just turned off. + final speechEnabled = _speechSettings?.enabled ?? true; + controller.setActive(speechEnabled && _isMonitorOnScreen && foreground); controller.update( widget.eew.state, languageTag: _languageTag, @@ -229,6 +245,7 @@ class _RtsMonitorPanelState extends State widget.eew.removeListener(_onData); widget.eewIndex.removeListener(_onData); _visibleTab?.removeListener(_syncVisibility); + _speechSettings?.removeListener(_syncAnnouncement); WidgetsBinding.instance.removeObserver(this); _announcement?.dispose(); super.dispose(); diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 0bdf16b85..4771dc6de 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -11,6 +11,7 @@ import 'package:dpip/features/bug_tracker/bug_tracker_counter.dart'; import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/eew_cwa_only_settings.dart'; +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/version/app_build.dart'; @@ -86,6 +87,12 @@ class MorePage extends StatelessWidget { ), onTap: () => context.pushNamed(AppRoutes.permissions), ), + // In the notification group rather than under 顯示: what this + // switches is the order of two *sounds*, not anything drawn. + // Below 權限檢查, which has to stay next to the notification + // settings — it is the row people reach for when an alert did + // not arrive. + const _SpokenAnnouncementTile(), // What the system says actually went out — a status page, kept // in the notification group because that is where you look when // an alert did not arrive. @@ -473,6 +480,35 @@ class _MoreTile extends StatelessWidget { } } +/// The monitor's spoken-intensity switch. +/// +/// A row that acts rather than navigates, so it carries its own trailing +/// [Switch] instead of `_MoreTile`'s chevron, and the whole row toggles — a +/// switch you can only hit by aiming at the switch is a smaller target than the +/// row it sits in. +class _SpokenAnnouncementTile extends StatelessWidget { + const _SpokenAnnouncementTile(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final settings = context.watch(); + final enabled = settings.enabled; + return _MoreTile( + icon: enabled + ? Icons.record_voice_over_outlined + : Icons.voice_over_off_outlined, + title: l10n.eewSpokenAnnouncementTitle, + subtitle: l10n.eewSpokenAnnouncementDescription, + trailing: Switch( + value: enabled, + onChanged: (value) => settings.setEnabled(value), + ), + onTap: () => settings.setEnabled(!enabled), + ); + } +} + class _SavedRegionsTile extends StatefulWidget { const _SavedRegionsTile(); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7117c7add..b0bb8156a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3962,11 +3962,27 @@ "eewSpokenLocalIntensity": "Estimated intensity at your location: {intensity}.", "@eewSpokenLocalIntensity": { "description": "Short foreground TTS phrase before an EEW warning sound", - "placeholders": {"intensity": {"type": "String"}} + "placeholders": { + "intensity": { + "type": "String" + } + } }, "eewSpokenMaxIntensity": "Estimated maximum intensity: {intensity}.", "@eewSpokenMaxIntensity": { "description": "TTS fallback when the device location is unavailable", - "placeholders": {"intensity": {"type": "String"}} + "placeholders": { + "intensity": { + "type": "String" + } + } + }, + "eewSpokenAnnouncementTitle": "Speak estimated intensity", + "eewSpokenAnnouncementDescription": "When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.", + "@eewSpokenAnnouncementTitle": { + "description": "Settings row: toggles the monitor's spoken intensity announcement" + }, + "@eewSpokenAnnouncementDescription": { + "description": "Explains what the spoken-announcement toggle does and when it speaks" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 744f48b69..02e6014d1 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan", "bugTrackerStaff": "Kawani", "eewSpokenLocalIntensity": "Tinatayang intensidad sa iyong lokasyon: {intensity}.", - "eewSpokenMaxIntensity": "Tinatayang pinakamataas na intensidad: {intensity}." + "eewSpokenMaxIntensity": "Tinatayang pinakamataas na intensidad: {intensity}.", + "eewSpokenAnnouncementTitle": "Basahin ang tinatayang intensidad", + "eewSpokenAnnouncementDescription": "Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 38c8355c2..0b4c57c32 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "Paling banyak dibahas", "bugTrackerStaff": "Staf", "eewSpokenLocalIntensity": "Perkiraan intensitas di lokasi Anda: {intensity}.", - "eewSpokenMaxIntensity": "Perkiraan intensitas maksimum: {intensity}." + "eewSpokenMaxIntensity": "Perkiraan intensitas maksimum: {intensity}.", + "eewSpokenAnnouncementTitle": "Bacakan intensitas perkiraan", + "eewSpokenAnnouncementDescription": "Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d8b1c6bb7..cfe219f27 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "返信が多い順", "bugTrackerStaff": "スタッフ", "eewSpokenLocalIntensity": "現在地の予想震度、{intensity}。", - "eewSpokenMaxIntensity": "予想最大震度、{intensity}。" + "eewSpokenMaxIntensity": "予想最大震度、{intensity}。", + "eewSpokenAnnouncementTitle": "予想震度を読み上げる", + "eewSpokenAnnouncementDescription": "強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 41640e4f4..a5f5e97e3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "답글 많은 순", "bugTrackerStaff": "스태프", "eewSpokenLocalIntensity": "현재 위치 예상 진도, {intensity}.", - "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}." + "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}.", + "eewSpokenAnnouncementTitle": "예상 진도 음성 안내", + "eewSpokenAnnouncementDescription": "지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 01e8b4461..b6c184c6d 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด", "bugTrackerStaff": "ทีมงาน", "eewSpokenLocalIntensity": "คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: {intensity}", - "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}" + "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}", + "eewSpokenAnnouncementTitle": "อ่านออกเสียงความรุนแรงที่คาดการณ์", + "eewSpokenAnnouncementDescription": "เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 06c96ae23..0e25756c1 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất", "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}." + "eewSpokenMaxIntensity": "Cường độ tối đa dự kiến: {intensity}.", + "eewSpokenAnnouncementTitle": "Đọc cường độ dự kiến", + "eewSpokenAnnouncementDescription": "Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động." } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index c9bb04df7..8df7c7431 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "最多討論", "bugTrackerStaff": "工作人員", "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", - "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", + "eewSpokenAnnouncementTitle": "朗讀預估震度", + "eewSpokenAnnouncementDescription": "開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 95838e573..06cd6a355 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1976,5 +1976,7 @@ "bugTrackerSortMostDiscussed": "最多讨论", "bugTrackerStaff": "工作人员", "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", - "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", + "eewSpokenAnnouncementTitle": "朗读预估烈度", + "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 545eeedd5..318a6dff7 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "最多讨论", "bugTrackerStaff": "工作人员", "eewSpokenLocalIntensity": "所在地预估烈度,{intensity}。", - "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。" + "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。", + "eewSpokenAnnouncementTitle": "朗读预估烈度", + "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 4df46480e..d55860c70 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "最多討論", "bugTrackerStaff": "工作人員", "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", - "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", + "eewSpokenAnnouncementTitle": "朗讀預估震度", + "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d62a70b39..712899bbf 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1984,5 +1984,7 @@ "bugTrackerSortMostDiscussed": "最多討論", "bugTrackerStaff": "工作人員", "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", - "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", + "eewSpokenAnnouncementTitle": "朗讀預估震度", + "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 5b09af43a..3061da64b 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6286,6 +6286,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Estimated maximum intensity: {intensity}.'** String eewSpokenMaxIntensity(String intensity); + + /// Settings row: toggles the monitor's spoken intensity announcement + /// + /// In en, this message translates to: + /// **'Speak estimated intensity'** + String get eewSpokenAnnouncementTitle; + + /// Explains what the spoken-announcement toggle does and when it speaks + /// + /// In en, this message translates to: + /// **'When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.'** + String get eewSpokenAnnouncementDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 3bb99d1d2..3b682ae57 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3310,4 +3310,11 @@ class AppLocalizationsEn extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return 'Estimated maximum intensity: $intensity.'; } + + @override + String get eewSpokenAnnouncementTitle => 'Speak estimated intensity'; + + @override + String get eewSpokenAnnouncementDescription => + 'When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 296649036..65604e5bc 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3328,4 +3328,11 @@ class AppLocalizationsFil extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return 'Tinatayang pinakamataas na intensidad: $intensity.'; } + + @override + String get eewSpokenAnnouncementTitle => 'Basahin ang tinatayang intensidad'; + + @override + String get eewSpokenAnnouncementDescription => + 'Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala.'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index b4b6392c0..bfe1d624f 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3321,4 +3321,11 @@ class AppLocalizationsId extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return 'Perkiraan intensitas maksimum: $intensity.'; } + + @override + String get eewSpokenAnnouncementTitle => 'Bacakan intensitas perkiraan'; + + @override + String get eewSpokenAnnouncementDescription => + 'Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar.'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 89dddb373..b2b84b383 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3249,4 +3249,11 @@ class AppLocalizationsJa extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return '予想最大震度、$intensity。'; } + + @override + String get eewSpokenAnnouncementTitle => '予想震度を読み上げる'; + + @override + String get eewSpokenAnnouncementDescription => + '強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index e2502f19c..c9f516da2 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3249,4 +3249,11 @@ class AppLocalizationsKo extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return '예상 최대 진도, $intensity.'; } + + @override + String get eewSpokenAnnouncementTitle => '예상 진도 음성 안내'; + + @override + String get eewSpokenAnnouncementDescription => + '지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다.'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index d476aeb17..4346de7fa 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3303,4 +3303,11 @@ class AppLocalizationsTh extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return 'คาดการณ์ความรุนแรงสูงสุด: $intensity'; } + + @override + String get eewSpokenAnnouncementTitle => 'อ่านออกเสียงความรุนแรงที่คาดการณ์'; + + @override + String get eewSpokenAnnouncementDescription => + 'เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index bd731d071..9817eef11 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3311,4 +3311,11 @@ class AppLocalizationsVi extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return 'Cường độ tối đa dự kiến: $intensity.'; } + + @override + String get eewSpokenAnnouncementTitle => 'Đọc cường độ dự kiến'; + + @override + String get eewSpokenAnnouncementDescription => + 'Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động.'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 089cd25c8..5a5af9485 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3232,4 +3232,10 @@ class AppLocalizationsYue extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return '預估最大震度,$intensity。'; } + + @override + String get eewSpokenAnnouncementTitle => '朗讀預估震度'; + + @override + String get eewSpokenAnnouncementDescription => '開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index c85533754..e158b2952 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3232,6 +3232,12 @@ class AppLocalizationsZh extends AppLocalizations { String eewSpokenMaxIntensity(String intensity) { return '預估最大震度,$intensity。'; } + + @override + String get eewSpokenAnnouncementTitle => '朗读预估烈度'; + + @override + String get eewSpokenAnnouncementDescription => '打开强震监视器时,先用语音朗读预估烈度,再播放警示音。'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6461,6 +6467,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String eewSpokenMaxIntensity(String intensity) { return '预估最大烈度,$intensity。'; } + + @override + String get eewSpokenAnnouncementTitle => '朗读预估烈度'; + + @override + String get eewSpokenAnnouncementDescription => '打开强震监视器时,先用语音朗读预估烈度,再播放警示音。'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9690,6 +9702,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String eewSpokenMaxIntensity(String intensity) { return '預估最大震度,$intensity。'; } + + @override + String get eewSpokenAnnouncementTitle => '朗讀預估震度'; + + @override + String get eewSpokenAnnouncementDescription => '開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12919,4 +12937,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String eewSpokenMaxIntensity(String intensity) { return '預估最大震度,$intensity。'; } + + @override + String get eewSpokenAnnouncementTitle => '朗讀預估震度'; + + @override + String get eewSpokenAnnouncementDescription => '開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。'; } diff --git a/test/core/settings/eew_spoken_announcement_settings_test.dart b/test/core/settings/eew_spoken_announcement_settings_test.dart new file mode 100644 index 000000000..3ae298847 --- /dev/null +++ b/test/core/settings/eew_spoken_announcement_settings_test.dart @@ -0,0 +1,47 @@ +/// The monitor's spoken-announcement switch. +/// +/// The default is the whole point of these tests: an EEW announcement that +/// silently defaults to off is a feature nobody ever hears, and the failure +/// looks exactly like a broken TTS engine. +library; + +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('defaults to on when nothing was ever saved', () { + final settings = EewSpokenAnnouncementSettings(SettingsStore.inMemory()); + expect(settings.enabled, isTrue); + }); + + test('reads back what was saved, in both directions', () async { + final store = SettingsStore.inMemory(); + final settings = EewSpokenAnnouncementSettings(store); + + await settings.setEnabled(false); + expect(settings.enabled, isFalse); + expect(store.getBool(SettingKeys.eewSpokenAnnouncement), isFalse); + + await settings.setEnabled(true); + expect(settings.enabled, isTrue); + }); + + test('a saved value survives a new instance over the same store', () async { + final store = SettingsStore.inMemory(); + await EewSpokenAnnouncementSettings(store).setEnabled(false); + expect(EewSpokenAnnouncementSettings(store).enabled, isFalse); + }); + + test('notifies listeners so the monitor re-reads it mid-alert', () async { + final settings = EewSpokenAnnouncementSettings(SettingsStore.inMemory()); + var notifications = 0; + settings.addListener(() => notifications++); + + await settings.setEnabled(false); + await settings.setEnabled(true); + + expect(notifications, 2); + }); +} diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index b58ce9124..3163f754c 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -20,6 +20,7 @@ import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/permissions/permission_health.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/eew_cwa_only_settings.dart'; +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/settings_store.dart'; @@ -159,6 +160,9 @@ Future _pump( create: (_) => DefaultMapLayerController(settings), ), ChangeNotifierProvider(create: (_) => EewCwaOnlySettings(settings)), + ChangeNotifierProvider( + create: (_) => EewSpokenAnnouncementSettings(settings), + ), ChangeNotifierProvider(create: (_) => ExperimentalSettings(settings)), ChangeNotifierProvider(create: (_) => RegionStore(settings)), Provider(create: (_) => const TownDirectory({})), @@ -306,6 +310,33 @@ void main() { expect(beta.dy, lessThan(partner.dy)); }); + testWidgets( + 'the spoken-announcement row starts on and the whole row toggles', + (tester) async { + await _pump(tester, _router([])); + const label = 'Speak estimated intensity'; + Switch speechSwitch() => tester.widget( + find.descendant( + of: find.widgetWithText(ListTile, label), + matching: find.byType(Switch), + ), + ); + + // Defaults to on: an announcement nobody opted into is the point. + expect(speechSwitch().value, isTrue); + + // The tap lands on the row, not the switch — a control you can only hit by + // aiming at the switch is a much smaller target than the row it sits in. + await tester.tap(find.widgetWithText(ListTile, label)); + await tester.pump(const Duration(milliseconds: 100)); + expect(speechSwitch().value, isFalse); + + await tester.tap(find.widgetWithText(ListTile, label)); + await tester.pump(const Duration(milliseconds: 100)); + expect(speechSwitch().value, isTrue); + }, + ); + testWidgets('permission check sits with the notification settings', ( tester, ) async {