diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78f72ec6c..ddb552c1d 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()) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f92c76c8b..5be68ce27 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -102,6 +102,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 70395389c..f0419835e 100644 --- a/lib/core/build/demo_flags.dart +++ b/lib/core/build/demo_flags.dart @@ -15,6 +15,9 @@ const String _monitorDemoRaw = String.fromEnvironment('DPIP_DEMO_MONITOR'); const String _monitorDemoSevereRaw = String.fromEnvironment( 'DPIP_DEMO_MONITOR_SEVERE', ); +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 @@ -33,3 +36,11 @@ const bool kMonitorDemoEnabled = 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 2957707d4..a33584c31 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -33,6 +33,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'; @@ -70,6 +71,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 768b49106..98b8553cd 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -6,6 +6,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_tap.dart'; import 'package:dpip/core/notifications/notification_taps.dart'; import 'package:dpip/core/settings/setting_keys.dart'; @@ -31,10 +32,17 @@ 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(); final SettingsStore _settings; + /// Sequences foreground EEW speech before the notification channel sound. + /// Background and terminated delivery bypass this object entirely. + final ForegroundEewAnnouncementGate foregroundEewGate; + /// The last push token, or null before registration. String? get token => _settings.getString(SettingKeys.pushToken); @@ -311,12 +319,9 @@ class NotificationService { final messaging = FirebaseMessaging.instance; FirebaseMessaging.onBackgroundMessage(onBackgroundMessage); - FirebaseMessaging.onMessage.listen((message) { - final content = contentFromMessage(message); - if (content != null) { - AwesomeNotifications().createNotification(content: content); - } - }); + FirebaseMessaging.onMessage.listen( + (message) => unawaited(_showForegroundMessage(message)), + ); FirebaseMessaging.onMessageOpenedApp.listen((m) => _routeTap(m.data)); final initial = await messaging.getInitialMessage(); @@ -341,6 +346,51 @@ class NotificationService { unawaited(_fetchToken()); } + Future _showForegroundMessage(RemoteMessage message) async { + final content = contentFromMessage(message); + if (content == null) return; + + Future display() => + AwesomeNotifications().createNotification(content: content); + if (content.channelKey?.startsWith('eew') ?? false) { + await foregroundEewGate.submit(display); + } else { + await display(); + } + } + + /// 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. 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 0d53c5053..273a0eb37 100644 --- a/lib/features/earthquake/data/monitor_demo.dart +++ b/lib/features/earthquake/data/monitor_demo.dart @@ -115,12 +115,14 @@ abstract final class MonitorDemo { } /// 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 b5c292dbd..55f618aab 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -1,10 +1,8 @@ /// 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'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/features/disaster_map/domain/disaster_map_repository.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; @@ -110,10 +108,9 @@ class _MapPageState extends State { @override Widget build(BuildContext context) { - // In demo mode the monitor is what there is to see — open straight on it. - final initial = kMonitorDemoEnabled - ? DefaultMapLayer.monitor - : context.watch().layer; + // Demo data must not silently change the user's active layer: speech and + // its warning sound are scoped to a monitor the user is actually viewing. + final initial = context.watch().layer; return MapScaffold( key: ValueKey(initial.id), layers: _layers, 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 f00ea179e..5d071563d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4014,5 +4014,15 @@ "description": "Shown when a debug dump could not be uploaded" }, "statusLegendUnprobed": "Not yet probed", - "statusLegendUnsupported": "Not offered" + "statusLegendUnsupported": "Not offered", + "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 08e3f17d1..5eff2cbf8 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "Kopyahin ulit", "dumpUploadFailed": "Nabigong mag-upload", "statusLegendUnprobed": "Hindi pa nasuri", - "statusLegendUnsupported": "Hindi suportado" + "statusLegendUnsupported": "Hindi suportado", + "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 3b5cd1971..5aa6c585d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "Salin lagi", "dumpUploadFailed": "Gagal mengunggah", "statusLegendUnprobed": "Belum diperiksa", - "statusLegendUnsupported": "Tidak tersedia" + "statusLegendUnsupported": "Tidak tersedia", + "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 48ff4d993..9554ea6fb 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "もう一度コピー", "dumpUploadFailed": "アップロードに失敗しました", "statusLegendUnprobed": "未探知", - "statusLegendUnsupported": "非対応" + "statusLegendUnsupported": "非対応", + "eewSpokenLocalIntensity": "現在地の予想震度、{intensity}。", + "eewSpokenMaxIntensity": "予想最大震度、{intensity}。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d9169d25f..15d42ded6 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "다시 복사", "dumpUploadFailed": "업로드하지 못했습니다", "statusLegendUnprobed": "탐지 안 됨", - "statusLegendUnsupported": "미지원" + "statusLegendUnsupported": "미지원", + "eewSpokenLocalIntensity": "현재 위치 예상 진도, {intensity}.", + "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 863f1be14..1cc37edb7 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "คัดลอกอีกครั้ง", "dumpUploadFailed": "อัปโหลดไม่สำเร็จ", "statusLegendUnprobed": "ยังไม่ตรวจ", - "statusLegendUnsupported": "ไม่รองรับ" + "statusLegendUnsupported": "ไม่รองรับ", + "eewSpokenLocalIntensity": "คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: {intensity}", + "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 03ff82721..01c7bf655 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "Sao chép lại", "dumpUploadFailed": "Tải lên thất bại", "statusLegendUnprobed": "Chưa dò", - "statusLegendUnsupported": "Không có" + "statusLegendUnsupported": "Không có", + "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_zh.arb b/lib/l10n/app_zh.arb index ddf2e8db7..f68c64221 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上傳失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "不支援" + "statusLegendUnsupported": "不支援", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index c77708bc4..a777625aa 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "再复制一次", "dumpUploadFailed": "上传失败,请稍后再试", "statusLegendUnprobed": "未探测", - "statusLegendUnsupported": "不支持" + "statusLegendUnsupported": "不支持", + "eewSpokenLocalIntensity": "所在地预估烈度,{intensity}。", + "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 4d9cdceae..3da7c5d19 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上載失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "不支援" + "statusLegendUnsupported": "不支援", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index a30032501..d5369f18c 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1950,5 +1950,7 @@ "dumpCopyAgain": "再複製一次", "dumpUploadFailed": "上傳失敗,請稍後再試", "statusLegendUnprobed": "未探測", - "statusLegendUnsupported": "不支援" + "statusLegendUnsupported": "不支援", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index b210c586e..8c6186ce9 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6092,6 +6092,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Not offered'** String get statusLegendUnsupported; + + /// 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 3e229f818..2bd64f614 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3197,4 +3197,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get statusLegendUnsupported => 'Not offered'; + + @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 34cd61f71..424630a4a 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3212,4 +3212,14 @@ class AppLocalizationsFil extends AppLocalizations { @override String get statusLegendUnsupported => 'Hindi suportado'; + + @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 04b7c006e..b140b53ac 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3204,4 +3204,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get statusLegendUnsupported => 'Tidak tersedia'; + + @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 49c37372c..957e88e61 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3147,4 +3147,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get statusLegendUnsupported => '非対応'; + + @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 2eae10861..da788fd3c 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3157,4 +3157,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get statusLegendUnsupported => '미지원'; + + @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 4fbe9796b..ae8d85171 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3191,4 +3191,14 @@ class AppLocalizationsTh extends AppLocalizations { @override String get statusLegendUnsupported => 'ไม่รองรับ'; + + @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 6b1945751..786235e1b 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3199,4 +3199,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String get statusLegendUnsupported => 'Không có'; + + @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_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 67bd728f4..60fb4fe37 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3135,6 +3135,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get statusLegendUnsupported => '不支援'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6267,6 +6277,16 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get statusLegendUnsupported => '不支持'; + + @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`). @@ -9399,6 +9419,16 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get statusLegendUnsupported => '不支援'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12531,4 +12561,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get statusLegendUnsupported => '不支援'; + + @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 9d6337db4..8f636b90e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -439,6 +439,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 @@ -568,10 +576,10 @@ packages: dependency: transitive description: name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" http: dependency: transitive description: @@ -794,10 +802,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.19.0" mime: dependency: transitive description: @@ -810,10 +818,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 url: "https://pub.dev" source: hosted - version: "0.19.2" + version: "0.19.3" nested: dependency: transitive description: @@ -1034,10 +1042,10 @@ packages: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.1" rxdart: dependency: transitive description: @@ -1135,10 +1143,10 @@ packages: dependency: "direct dev" description: name: sqflite_common_ffi - sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f" + sha256: d5564f1308cafbf064be498f2beb1e993e742985a7cca9e2e228d6cbccd84a05 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.2+1" sqflite_darwin: dependency: transitive description: @@ -1199,10 +1207,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" url: "https://pub.dev" source: hosted - version: "3.4.1+1" + version: "3.4.1+2" talker: dependency: transitive description: @@ -1327,10 +1335,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 16010ef0d..90cf38038 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,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'); + }); +}