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/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 | 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/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 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/4] 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 From c537a540f2cc607e0f368a5bacf86170341170cf Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:00:37 +0800 Subject: [PATCH 4/4] refactor(eew): speak through an app-owned channel, not flutter_tts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flutter_tts` ships no Swift Package Manager support, and the iOS build has none of the CocoaPods setup it therefore needs (README → 參與開發: "iOS 已改用 Swift Package Manager,不需要 CocoaPods"). Flutter says so during every build and adds that it "will become an error in a future version" — until then it quietly reintroduces a Podfile through a transitive dependency, which is the half of that warning nobody reads. So the two calls it was providing are owned here, as the eleventh iOS plugin and the thirteenth Android channel: `AVSpeechSynthesizer` on one side, `android.speech.tts.TextToSpeech` on the other, thirty lines each. `SpeechService` was already an interface with the package behind a single implementation, so the announcement controller, the notification gate and their tests are untouched. Both sides answer a `speak` exactly once and answer it normally when a later phrase supersedes it: latest-report-wins is this caller's contract, so a cut-off phrase is the expected path rather than an error. The Android half replies on the main thread — `UtteranceProgressListener` runs on a binder thread, and a `MethodChannel.Result` answered from there is a crash rather than a warning — and holds the first call until the engine finishes binding, because the announcement that matters most is the first one. --- .../kotlin/com/exptech/dpip/MainActivity.kt | 3 + .../kotlin/com/exptech/dpip/SpeechChannel.kt | 173 ++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 1 + ios/Runner/SpeechPlugin.swift | 108 +++++++++++ lib/core/speech/speech_service.dart | 58 +++--- pubspec.lock | 12 +- pubspec.yaml | 1 - 8 files changed, 315 insertions(+), 45 deletions(-) create mode 100644 android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt create mode 100644 ios/Runner/SpeechPlugin.swift diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt index 6b4660184..6695281c7 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt @@ -62,5 +62,8 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, PlainChannelsChannel.NAME) .setMethodCallHandler(PlainChannelsChannel(applicationContext)) + + MethodChannel(messenger, SpeechChannel.NAME) + .setMethodCallHandler(SpeechChannel(applicationContext)) } } diff --git a/android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt new file mode 100644 index 000000000..61dc00ee3 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt @@ -0,0 +1,173 @@ +package com.exptech.dpip + +import android.content.Context +import android.media.AudioManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger + +/** + * Speaks a short phrase and reports when it has finished, for the foreground + * EEW announcement that must complete before the warning sound plays. + * + * `android.speech.tts.TextToSpeech` directly rather than a package: the only + * pub package with this API ships no Swift Package Manager support, which the + * iOS half of this app needs, so both halves are owned here instead. + * + * One utterance at a time. A `speak` while another is in flight flushes it, and + * the flushed call still returns normally — the caller's contract is + * latest-report-wins, so a superseded phrase is the expected path rather than + * an error. Every `speak` replies exactly once, which the channel requires, and + * every reply is posted to the main thread: `UtteranceProgressListener` fires + * on a binder thread, and a `MethodChannel.Result` answered from there is a + * crash rather than a warning. + */ +class SpeechChannel(context: Context) : MethodChannel.MethodCallHandler { + + companion object { + const val NAME = "com.exptech.dpip/speech" + } + + private val main = Handler(Looper.getMainLooper()) + private val ids = AtomicInteger() + + /** The reply owed to the utterance in flight, or null when none is. */ + private var pending: MethodChannel.Result? = null + private var pendingId: String? = null + + /** + * The engine's init result, or null while it is still starting. + * + * `TextToSpeech` binds to the system engine asynchronously and rejects + * everything until it is connected. The first announcement is the one that + * matters most, so calls that arrive before then wait here rather than + * being refused. + */ + private var initStatus: Int? = null + private val waiting = ArrayDeque<(Boolean) -> Unit>() + + private val tts = + TextToSpeech(context.applicationContext) { status -> + main.post { + initStatus = status + val ready = status == TextToSpeech.SUCCESS + val queued = waiting.toList() + waiting.clear() + queued.forEach { it(ready) } + } + } + + init { + tts.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) = Unit + + override fun onDone(utteranceId: String?) = settle(utteranceId) + + // Abstract in the Java base class, so it has to be here even + // though the two-argument form below replaced it. + @Suppress("OVERRIDE_DEPRECATION") + override fun onError(utteranceId: String?) = settle(utteranceId) + + override fun onError(utteranceId: String?, errorCode: Int) = settle(utteranceId) + + override fun onStop(utteranceId: String?, interrupted: Boolean) = + settle(utteranceId) + } + ) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "speak" -> { + val text = call.argument("text") + val language = call.argument("language") + if (text == null || language == null) { + result.error("BAD_ARGUMENTS", "speak needs text and language", null) + return + } + whenReady { ready -> + if (!ready) { + result.error("UNAVAILABLE", "No system speech engine", null) + return@whenReady + } + speak(text, language, result) + } + } + + "stop" -> { + tts.stop() + settleNow(null) + result.success(null) + } + + else -> result.notImplemented() + } + } + + private fun speak(text: String, language: String, result: MethodChannel.Result) { + // Supersede first, so the previous call is answered before this one can + // take its place — QUEUE_FLUSH's onStop would otherwise settle *this* + // reply against the outgoing utterance's id. + settleNow(null) + + // A language the device has no voice data for leaves whatever the + // engine defaults to in place: a phrase in the wrong accent still + // carries the intensity, and silence does not. + tts.setLanguage(Locale.forLanguageTag(language)) + + val id = ids.incrementAndGet().toString() + // Loudest within the user's media volume, on the stream the warning + // sound uses. Changing the device volume would be intrusive and would + // outlast the warning, so that stays theirs. + val params = + Bundle().apply { + putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, 1.0f) + putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC) + } + + pending = result + pendingId = id + if (tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, id) != TextToSpeech.SUCCESS) { + pending = null + pendingId = null + result.error("REJECTED", "System TTS refused the utterance", null) + } + } + + /** + * Answers the utterance in flight, if [utteranceId] is still the current + * one. Hops to the main thread first, because the progress listener that + * calls it does not run there. + */ + private fun settle(utteranceId: String?) { + main.post { settleNow(utteranceId) } + } + + /** + * [settle] without the hop, for the two callers that are already on the + * main thread and must answer *before* they install a new reply — a posted + * settle would run after that and cancel the wrong one. + */ + private fun settleNow(utteranceId: String?) { + if (utteranceId != null && utteranceId != pendingId) return + pending?.success(null) + pending = null + pendingId = null + } + + private fun whenReady(action: (Boolean) -> Unit) { + val status = initStatus + if (status == null) { + waiting.add(action) + return + } + action(status == TextToSpeech.SUCCESS) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a4738f9d6..a642d8e68 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; }; AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.swift */; }; AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; }; + AA0000000000000000001002 /* SpeechPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000001001 /* SpeechPlugin.swift */; }; AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; }; AE92AD9862B7A721B0924557 /* eew.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7A6E88CB92902C0CACB07792 /* eew.aiff */; }; CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000B002 /* MapCachePlugin.swift */; }; @@ -97,6 +98,7 @@ AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = ""; }; AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; }; + AA0000000000000000001001 /* SpeechPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechPlugin.swift; sourceTree = ""; }; AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; }; B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = ""; }; CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = ""; }; @@ -182,6 +184,7 @@ AA0000000000000000000E01 /* ScreenWakePlugin.swift */, AA0000000000000000000G01 /* LocationTrackStore.swift */, AA0000000000000000000F01 /* ApnsTokenPlugin.swift */, + AA0000000000000000001001 /* SpeechPlugin.swift */, AA0000000000000000000D01 /* DeviceInfoPlugin.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -378,6 +381,7 @@ AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */, AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */, AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */, + AA0000000000000000001002 /* SpeechPlugin.swift in Sources */, AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c315634bb..59faea566 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -34,6 +34,7 @@ import UserNotifications StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!) ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!) ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!) + SpeechPlugin.register(with: registry.registrar(forPlugin: "SpeechPlugin")!) BackgroundLocationPlugin.register( with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!) BackgroundExecutionPlugin.register( diff --git a/ios/Runner/SpeechPlugin.swift b/ios/Runner/SpeechPlugin.swift new file mode 100644 index 000000000..bf821fc7e --- /dev/null +++ b/ios/Runner/SpeechPlugin.swift @@ -0,0 +1,108 @@ +import AVFoundation +import Flutter + +/// Speaks a short phrase and reports when it has finished, for the foreground +/// EEW announcement that must complete before the warning sound plays. +/// +/// `AVSpeechSynthesizer` directly rather than a package: the only pub package +/// with this API ships no Swift Package Manager support, and this app builds +/// without CocoaPods. +/// +/// One utterance at a time. A `speak` while another is in flight cancels it, +/// and the cancelled call still returns normally — the caller's contract is +/// latest-report-wins, so a superseded phrase is the expected path rather than +/// an error. Every `speak` replies exactly once, which the channel requires. +public class SpeechPlugin: NSObject, FlutterPlugin, AVSpeechSynthesizerDelegate { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.exptech.dpip/speech", + binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(SpeechPlugin(), channel: channel) + } + + private let synthesizer = AVSpeechSynthesizer() + private var pending: FlutterResult? + + override init() { + super.init() + synthesizer.delegate = self + } + + public func handle( + _ call: FlutterMethodCall, result: @escaping FlutterResult + ) { + switch call.method { + case "speak": + guard let arguments = call.arguments as? [String: Any], + let text = arguments["text"] as? String, + let language = arguments["language"] as? String + else { + result( + FlutterError( + code: "BAD_ARGUMENTS", message: "speak needs text and language", details: nil)) + return + } + speak(text, language: language, result: result) + case "stop": + synthesizer.stopSpeaking(at: .immediate) + settle() + deactivateSession() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func speak(_ text: String, language: String, result: @escaping FlutterResult) { + // Supersede first, so the previous call is answered before this one can + // take its place — `didCancel` for it would otherwise settle *this* result. + synthesizer.stopSpeaking(at: .immediate) + settle() + + // The default category follows the Silent switch. A foreground disaster + // announcement has to stay audible there too; `.playback` with + // `.voicePrompt` and `duckOthers` keeps it intelligible without taking + // ownership of another app's audio for longer than the phrase lasts. + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers]) + try? session.setActive(true) + + let utterance = AVSpeechUtterance(string: text) + // A language the device has no voice for leaves the system default in + // place: a phrase in the wrong accent still carries the intensity, and + // silence does not. + if let voice = AVSpeechSynthesisVoice(language: language) { + utterance.voice = voice + } + // Loudest within the user's media volume. Changing the device volume would + // be intrusive and would outlast the warning, so that stays theirs. + utterance.volume = 1.0 + + pending = result + synthesizer.speak(utterance) + } + + private func settle() { + guard let result = pending else { return } + pending = nil + result(nil) + } + + private func deactivateSession() { + try? AVAudioSession.sharedInstance().setActive( + false, options: [.notifyOthersOnDeactivation]) + } + + public func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance + ) { + settle() + deactivateSession() + } + + public func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance + ) { + settle() + } +} diff --git a/lib/core/speech/speech_service.dart b/lib/core/speech/speech_service.dart index 3c1a26a80..629b6c481 100644 --- a/lib/core/speech/speech_service.dart +++ b/lib/core/speech/speech_service.dart @@ -3,8 +3,7 @@ library; import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_tts/flutter_tts.dart'; +import 'package:flutter/services.dart'; /// Speaks short phrases through the platform speech engine. abstract interface class SpeechService { @@ -18,45 +17,36 @@ abstract interface class SpeechService { void dispose(); } -/// Android `TextToSpeech` / iOS `AVSpeechSynthesizer` implementation. +/// Android `TextToSpeech` / iOS `AVSpeechSynthesizer`, over an app-owned +/// platform channel. +/// +/// A channel rather than a package, like the rest of the native surface: the +/// only TTS package on pub with the API this needs (`flutter_tts`) ships no +/// Swift Package Manager support, and this project builds iOS without +/// CocoaPods (README → 參與開發). Adopting it would have pulled a Podfile back +/// in through a transitive dependency, for two calls whose native side is +/// thirty lines each. class SystemSpeechService implements SpeechService { - SystemSpeechService({FlutterTts? engine}) : _engine = engine ?? FlutterTts(); + SystemSpeechService({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(channelName); - final FlutterTts _engine; - bool _configured = false; + /// The channel `SpeechChannel.kt` and `SpeechPlugin.swift` answer on. + static const String channelName = 'com.exptech.dpip/speech'; - 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; - } + final MethodChannel _channel; + /// Speaks [text], completing when the utterance finishes. + /// + /// A phrase superseded by a later [speak] — or cut short by [stop] — also + /// completes normally rather than throwing: latest-report-wins is the + /// expected path here, not a failure. Only an engine that is absent or + /// refuses the utterance raises. @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'); - } + Future speak(String text, {required String languageTag}) => _channel + .invokeMethod('speak', {'text': text, 'language': languageTag}); @override - Future stop() async { - await _engine.stop(); - } + Future stop() => _channel.invokeMethod('stop'); @override void dispose() { diff --git a/pubspec.lock b/pubspec.lock index 8a5798aa1..e82963440 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: archive - sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.1.0" args: dependency: transitive description: @@ -415,14 +415,6 @@ 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 23dbf39c6..135fc1f79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,6 @@ 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