diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 261686268..c6c23331c 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -32,6 +32,7 @@ import 'package:dpip/features/settings/presentation/pages/display_page.dart'; import 'package:dpip/features/settings/presentation/pages/experimental_page.dart'; import 'package:dpip/features/settings/presentation/pages/default_map_layer_page.dart'; import 'package:dpip/features/settings/presentation/pages/eew_source_page.dart'; +import 'package:dpip/features/settings/presentation/pages/spoken_intensity_page.dart'; import 'package:dpip/features/settings/presentation/pages/language_page.dart'; import 'package:dpip/features/settings/presentation/pages/permissions_page.dart'; import 'package:dpip/features/sponsor/presentation/pages/sponsor_page.dart'; @@ -240,6 +241,11 @@ final GoRouter appRouter = GoRouter( name: AppRoutes.eewSource, builder: (_, _) => const EewSourcePage(), ), + GoRoute( + path: AppRoutes.spokenIntensityPath, + name: AppRoutes.spokenIntensity, + builder: (_, _) => const SpokenIntensityPage(), + ), GoRoute( path: AppRoutes.regionManagePath, name: AppRoutes.regionManage, diff --git a/lib/core/settings/eew_spoken_announcement_settings.dart b/lib/core/settings/eew_spoken_announcement_settings.dart index 11c62eeec..faf2cae10 100644 --- a/lib/core/settings/eew_spoken_announcement_settings.dart +++ b/lib/core/settings/eew_spoken_announcement_settings.dart @@ -2,10 +2,11 @@ import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:flutter/foundation.dart'; -/// Whether the visible seismic monitor speaks the estimated intensity before -/// the EEW warning sound plays, persisted via [SettingsStore]. **On** by -/// default: the announcement is what buys the seconds between the alert and -/// the shaking, so a user who wants silence opts out rather than in. +/// Whether the seismic monitor speaks the estimated intensity before the EEW +/// warning sound plays, persisted via [SettingsStore]. **Off** by default: +/// speech is an accessibility aid, and one that delays the warning sound by as +/// long as the phrase takes — nobody should be given that trade without asking +/// for it. /// /// Turning it off never delays a warning. The monitor drops its announcement /// controller to inactive, which releases anything the foreground gate is @@ -16,9 +17,9 @@ class EewSpokenAnnouncementSettings extends ChangeNotifier { final SettingsStore _settings; - /// Whether the foreground monitor may speak. + /// Whether the monitor — live or replay — may speak. bool get enabled => - _settings.getBool(SettingKeys.eewSpokenAnnouncement) ?? true; + _settings.getBool(SettingKeys.eewSpokenAnnouncement) ?? false; Future setEnabled(bool value) async { await _settings.setBool(SettingKeys.eewSpokenAnnouncement, value); diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index 55532ab7d..3e7f598c0 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -253,8 +253,8 @@ abstract final class SettingKeys { 'earthquake.eewCwaOnly', ); - /// Whether the visible seismic monitor speaks the estimated intensity before - /// the EEW warning sound. Defaults to true. See + /// Whether the seismic monitor speaks the estimated intensity before the EEW + /// warning sound. Defaults to false — it delays the sound. See /// `EewSpokenAnnouncementSettings`. static const SettingKey eewSpokenAnnouncement = SettingKey._( 'earthquake.eewSpokenAnnouncement', diff --git a/lib/features/map/presentation/monitor_eew_announcement_controller.dart b/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart similarity index 90% rename from lib/features/map/presentation/monitor_eew_announcement_controller.dart rename to lib/features/earthquake/domain/monitor_eew_announcement_controller.dart index bd8bd2630..ed37f2555 100644 --- a/lib/features/map/presentation/monitor_eew_announcement_controller.dart +++ b/lib/features/earthquake/domain/monitor_eew_announcement_controller.dart @@ -1,4 +1,10 @@ -/// Latest-report-wins speech state machine for the visible seismic monitor. +/// Latest-report-wins speech state machine for a seismic monitor. +/// +/// In `domain/` rather than beside the panel that first used it: the live +/// monitor lives in the map feature and the 重播 page in this one, and the +/// layering gate forbids either feature from importing the other's +/// presentation. Nothing here is presentation anyway — no Flutter import, no +/// widget, no build; it is the announcement policy, driven by a feed snapshot. library; import 'dart:async'; diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index aff27e056..5f6492e16 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -29,6 +29,13 @@ import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/realtime/replay_clock.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/eew_estimator.dart'; +import 'package:dpip/shared/seismic/spoken_intensity.dart'; +import 'package:dpip/features/earthquake/domain/monitor_eew_announcement_controller.dart'; +import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; +import 'package:dpip/core/speech/speech_service.dart'; +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; +import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; +import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/shared/seismic/intensity_circle_renderer.dart'; import 'package:dpip/features/earthquake/domain/rts_box_grid.dart'; @@ -102,6 +109,24 @@ class _ReportReplayPageState extends State { /// advances it through the alert set (modulo the count in the builder). int _eewIndex = 0; + /// Speaks each new report's estimated intensity while the replay runs, the + /// same way the live monitor does — a replay that stayed silent would not be + /// a replay of what the user would have heard. + MonitorEewAnnouncementController? _announcement; + EewSpokenAnnouncementSettings? _speechSettings; + AppLocalizations? _l10n; + String _languageTag = 'zh-Hant'; + + /// False while nobody is looking — backgrounded or on another tab — so the + /// announcement idles together with the polling (see [_applyActivity]). + /// + /// Starts false rather than true: [ActiveWhileVisible] only reports after + /// the first frame, and [didChangeDependencies] runs before it. Guessing + /// "visible" in that gap is the mistake `RtsMonitorPanel._isMonitorOnScreen` + /// already guards against — sound needs a stricter check than rendering, so + /// an unanswered visibility question has to mean silence. + bool _resumed = false; + @override void initState() { super.initState(); @@ -118,6 +143,7 @@ class _ReportReplayPageState extends State { widget.replayTimestamp, cwaOnly: () => cwaOnly.enabled, )..start(); + _session.eew.addListener(_syncAnnouncement); _startTicker(); } @@ -136,6 +162,9 @@ class _ReportReplayPageState extends State { /// go_router freezes the exit transition the moment the branch deactivates, /// so `dispose` does not run until the user comes *back*. Until then the /// replay would keep polling twice a second for a page nobody can see. + /// + /// The spoken announcement follows the same signal: a replay nobody is + /// looking at must not keep talking either. void _applyActivity(bool active) { if (active) { _startTicker(); @@ -145,6 +174,8 @@ class _ReportReplayPageState extends State { _ticker = null; _session.pause(); } + _resumed = active; + _syncAnnouncement(); } void _startTicker() { @@ -154,10 +185,77 @@ class _ReportReplayPageState extends State { ); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _l10n = AppLocalizations.of(context); + _languageTag = Localizations.localeOf(context).toLanguageTag(); + _announcement ??= _createAnnouncementController(); + final speechSettings = context.read(); + if (!identical(speechSettings, _speechSettings)) { + _speechSettings?.removeListener(_syncAnnouncement); + _speechSettings = speechSettings; + speechSettings?.addListener(_syncAnnouncement); + } + _syncAnnouncement(); + } + + MonitorEewAnnouncementController? _createAnnouncementController() { + // Nullable reads keep the page testable without the app's provider list — + // both of them, not just the speech one. A test that supplies a + // SpeechService is exactly the test that wants to reach this code, and a + // non-null read here would throw on it unless it also stood up a + // LocationService it has no interest in. + final speech = context.read(); + if (speech == null) return null; + final location = context.read(); + return MonitorEewAnnouncementController( + speech, + // A gate of this page's own, never NotificationService's. A replay + // produces no notification to sequence, and borrowing the shared one + // would let a phrase about a historical earthquake hold back the sound + // of a real alert that arrives while the replay is playing. + ForegroundEewAnnouncementGate(), + (alert) async { + // No service or no cached fix both mean the same thing here: nothing + // to estimate a local intensity from, so announce the alert's max. + final fix = await location?.lastKnownFix(); + if (fix == null) { + return (scale: alert.info.max.clamp(0, 9), isLocal: false); + } + final estimate = estimateLocalShaking( + alert, + geo.LatLng(fix.lat, fix.lng), + ); + return (scale: estimate.scale, isLocal: true); + }, + ); + } + + void _syncAnnouncement() { + final controller = _announcement; + final l10n = _l10n; + if (controller == null || l10n == null) return; + controller.setActive((_speechSettings?.enabled ?? false) && _resumed); + controller.update( + _session.eew.state, + languageTag: _languageTag, + format: (estimate) { + final intensity = spokenIntensityLabel(estimate.scale, _languageTag); + return estimate.isLocal + ? l10n.eewSpokenLocalIntensity(intensity) + : l10n.eewSpokenMaxIntensity(intensity); + }, + ); + } + @override void dispose() { _ticker?.cancel(); _tick.dispose(); + _session.eew.removeListener(_syncAnnouncement); + _speechSettings?.removeListener(_syncAnnouncement); + _announcement?.dispose(); _session.dispose(); super.dispose(); } diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 46bf8ff99..35937d47f 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -22,7 +22,7 @@ import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; -import 'package:dpip/features/map/presentation/monitor_eew_announcement_controller.dart'; +import 'package:dpip/features/earthquake/domain/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'; @@ -191,11 +191,11 @@ class _RtsMonitorPanelState extends State if (controller == null || l10n == null) return; final foreground = _lifecycleState == null || _lifecycleState == AppLifecycleState.resumed; - // Absent provider means a test that supplied neither — announce, matching + // Absent provider means a test that supplied none — stay silent, matching // the default. Switching off deactivates the controller, which stops any // phrase in flight and releases the notification the gate was holding, so // the warning sound is never delayed by a setting the user just turned off. - final speechEnabled = _speechSettings?.enabled ?? true; + final speechEnabled = _speechSettings?.enabled ?? false; controller.setActive(speechEnabled && _isMonitorOnScreen && foreground); controller.update( widget.eew.state, diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 4771dc6de..df09eeef2 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -39,6 +39,9 @@ class MorePage extends StatelessWidget { final l10n = AppLocalizations.of(context); final mapLayer = context.watch().layer; final eewCwaOnly = context.watch().enabled; + final spokenIntensity = context + .watch() + .enabled; return Scaffold( body: SafeArea( bottom: false, @@ -87,12 +90,6 @@ class MorePage extends StatelessWidget { ), onTap: () => context.pushNamed(AppRoutes.permissions), ), - // In the notification group rather than under 顯示: what this - // switches is the order of two *sounds*, not anything drawn. - // Below 權限檢查, which has to stay next to the notification - // settings — it is the row people reach for when an alert did - // not arrive. - const _SpokenAnnouncementTile(), // What the system says actually went out — a status page, kept // in the notification group because that is where you look when // an alert did not arrive. @@ -133,6 +130,26 @@ class MorePage extends StatelessWidget { ), ], ), + // Its own section rather than a row under 通知 or 顯示: what it + // switches is neither a notification's delivery nor anything drawn, + // and the settings it belongs beside — colour vision, contrast, + // text size — are currently inside the Display page. This section + // is where they would move if that page is ever split up. + SectionHeader(l10n.moreSectionAccessibility), + _MoreGroup( + children: [ + _MoreTile( + icon: spokenIntensity + ? Icons.record_voice_over_outlined + : Icons.voice_over_off_outlined, + title: l10n.eewSpokenAnnouncementTitle, + subtitle: spokenIntensity + ? l10n.eewSpokenAnnouncementOn + : l10n.eewSpokenAnnouncementOff, + onTap: () => context.pushNamed(AppRoutes.spokenIntensity), + ), + ], + ), // Its own section rather than a row under 進階: the LoRa mesh is the // app's off-grid reception path, not a developer curiosity, and the // radio it pairs with is a physical thing the user owns and manages. @@ -480,35 +497,6 @@ class _MoreTile extends StatelessWidget { } } -/// The monitor's spoken-intensity switch. -/// -/// A row that acts rather than navigates, so it carries its own trailing -/// [Switch] instead of `_MoreTile`'s chevron, and the whole row toggles — a -/// switch you can only hit by aiming at the switch is a smaller target than the -/// row it sits in. -class _SpokenAnnouncementTile extends StatelessWidget { - const _SpokenAnnouncementTile(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final settings = context.watch(); - final enabled = settings.enabled; - return _MoreTile( - icon: enabled - ? Icons.record_voice_over_outlined - : Icons.voice_over_off_outlined, - title: l10n.eewSpokenAnnouncementTitle, - subtitle: l10n.eewSpokenAnnouncementDescription, - trailing: Switch( - value: enabled, - onChanged: (value) => settings.setEnabled(value), - ), - onTap: () => settings.setEnabled(!enabled), - ); - } -} - class _SavedRegionsTile extends StatefulWidget { const _SavedRegionsTile(); diff --git a/lib/features/settings/presentation/pages/spoken_intensity_page.dart b/lib/features/settings/presentation/pages/spoken_intensity_page.dart new file mode 100644 index 000000000..db393d49b --- /dev/null +++ b/lib/features/settings/presentation/pages/spoken_intensity_page.dart @@ -0,0 +1,187 @@ +/// Settings: whether the seismic monitor speaks the estimated intensity before +/// the EEW warning sound. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// One switch, not a two-card choice. +/// +/// The first cut mirrored `EewSourcePage` with an "on" card and an "off" card. +/// Review (#568) pointed out that a page holding a single on/off setting is a +/// switch's job, and two cards made it look like it offered more than it does. +/// The one thing the cards did better — giving the cost somewhere to be said — +/// is kept as the note under the switch. Speech delays the warning sound by +/// however long the phrase takes, and a user has to read that *before* turning +/// this on, so the note is visible whichever way the switch sits rather than +/// only in the "on" state. +class SpokenIntensityPage extends StatelessWidget { + const SpokenIntensityPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final settings = context.watch(); + final enabled = settings.enabled; + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Scaffold( + appBar: AppBar(title: Text(l10n.eewSpokenAnnouncementTitle)), + body: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + Text( + l10n.eewSpokenAnnouncementDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.xl), + _SwitchCard( + icon: enabled + ? Icons.record_voice_over_outlined + : Icons.voice_over_off_outlined, + title: Text(l10n.eewSpokenAnnouncementTitle), + // The state in words, not just the switch's position — the same + // line the More menu shows under this row, so the two agree. + subtitle: Text( + enabled + ? l10n.eewSpokenAnnouncementOn + : l10n.eewSpokenAnnouncementOff, + ), + value: enabled, + onChanged: settings.setEnabled, + ), + const SizedBox(height: AppSpacing.md), + _DelayNote(Text(l10n.eewSpokenAnnouncementOnDescription)), + ], + ), + ); + } +} + +/// The setting itself: icon, title, current state and a trailing [Switch]. +/// +/// The whole card toggles, not just the switch — a switch you can only hit by +/// aiming at the switch is a smaller target than the card it sits in. +class _SwitchCard extends StatelessWidget { + const _SwitchCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final Widget title; + final Widget subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Semantics( + toggled: value, + child: Material( + color: value + ? colors.primaryContainer.withValues(alpha: 0.55) + : colors.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.medium, + side: BorderSide( + color: value ? colors.primary : colors.outlineVariant, + width: value ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: value + ? colors.primary + : colors.surfaceContainerHighest, + borderRadius: AppRadius.medium, + ), + child: Icon( + icon, + color: value ? colors.onPrimary : colors.onSurfaceVariant, + ), + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + style: theme.textTheme.titleMedium, + child: title, + ), + const SizedBox(height: AppSpacing.xs), + DefaultTextStyle.merge( + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + child: subtitle, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + ); + } +} + +/// The cost of turning the setting on, kept in view in both states. +class _DelayNote extends StatelessWidget { + const _DelayNote(this.text); + + final Widget text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 18, color: colors.onSurfaceVariant), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: DefaultTextStyle.merge( + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + child: text, + ), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bcbfafba5..ff2717cbc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4016,5 +4016,21 @@ "radarWindOverlaySubtitle": "Overlays the station wind direction and speed recorded at the same time as the radar frame you are looking at.", "@radarWindOverlaySubtitle": { "description": "Tooltip for the wind toggle in the radar overlay menu." + }, + "moreSectionAccessibility": "Accessibility", + "@moreSectionAccessibility": { + "description": "More menu section header for accessibility settings" + }, + "eewSpokenAnnouncementOn": "On", + "eewSpokenAnnouncementOnDescription": "The estimated intensity is read aloud first, then the warning sound plays — which delays it by however long the phrase takes.", + "eewSpokenAnnouncementOff": "Off", + "@eewSpokenAnnouncementOn": { + "description": "Option label: the monitor speaks before the warning sound" + }, + "@eewSpokenAnnouncementOnDescription": { + "description": "Explains that speech delays the warning sound by the length of the phrase" + }, + "@eewSpokenAnnouncementOff": { + "description": "Option label: no announcement, warning sound only" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index a23101097..e7c2fbe45 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "Ipinapatong ang mga kidlat na naitala sa parehong oras ng radar na tinitingnan mo.", "radarWindOverlay": "Ipakita ang hangin", "radarWindOverlayHint": "Hangin ng istasyon sa oras ng frame na nakikita", - "radarWindOverlaySubtitle": "Ipinapatong ang direksyon at bilis ng hangin mula sa mga istasyon na naitala sa parehong oras ng radar na tinitingnan mo." + "radarWindOverlaySubtitle": "Ipinapatong ang direksyon at bilis ng hangin mula sa mga istasyon na naitala sa parehong oras ng radar na tinitingnan mo.", + "moreSectionAccessibility": "Accessibility", + "eewSpokenAnnouncementOn": "Naka-on", + "eewSpokenAnnouncementOnDescription": "Babasahin muna nang malakas ang tinatayang intensidad bago tumunog ang babala — kaya naaantala ito nang kasinghaba ng pangungusap.", + "eewSpokenAnnouncementOff": "Naka-off" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index a52c268ea..1891faa8f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "Menampilkan sambaran petir yang tercatat pada waktu yang sama dengan citra radar yang sedang dilihat.", "radarWindOverlay": "Tampilkan angin", "radarWindOverlayHint": "Angin stasiun pada waktu bingkai yang tampil", - "radarWindOverlaySubtitle": "Menampilkan arah dan kecepatan angin dari stasiun pengamatan pada waktu yang sama dengan citra radar yang sedang dilihat." + "radarWindOverlaySubtitle": "Menampilkan arah dan kecepatan angin dari stasiun pengamatan pada waktu yang sama dengan citra radar yang sedang dilihat.", + "moreSectionAccessibility": "Aksesibilitas", + "eewSpokenAnnouncementOn": "Aktif", + "eewSpokenAnnouncementOnDescription": "Intensitas perkiraan dibacakan lebih dulu, lalu suara peringatan diputar — sehingga suara itu tertunda selama kalimatnya.", + "eewSpokenAnnouncementOff": "Nonaktif" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 36ac0e8c6..76e6c4087 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の落雷を重ねて表示します。", "radarWindOverlay": "風向を表示", "radarWindOverlayHint": "表示中のエコーと同時刻の観測風向", - "radarWindOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の観測所の風向・風速を重ねて表示します。" + "radarWindOverlaySubtitle": "表示中のレーダーエコーと同じ時刻の観測所の風向・風速を重ねて表示します。", + "moreSectionAccessibility": "アクセシビリティ", + "eewSpokenAnnouncementOn": "オン", + "eewSpokenAnnouncementOnDescription": "予想震度を音声で読み上げてから警報音を鳴らします。その分、警報音は読み上げの長さだけ遅れます。", + "eewSpokenAnnouncementOff": "オフ" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 66d634dd5..041452fc1 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 낙뢰를 겹쳐서 표시합니다.", "radarWindOverlay": "바람 표시", "radarWindOverlayHint": "화면에 표시된 시각의 관측 바람", - "radarWindOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 관측소 풍향·풍속을 겹쳐서 표시합니다." + "radarWindOverlaySubtitle": "현재 보고 있는 레이더 영상과 같은 시각의 관측소 풍향·풍속을 겹쳐서 표시합니다.", + "moreSectionAccessibility": "접근성", + "eewSpokenAnnouncementOn": "켜기", + "eewSpokenAnnouncementOnDescription": "예상 진도를 음성으로 먼저 안내한 뒤 경보음이 울립니다. 그만큼 경보음이 늦어집니다.", + "eewSpokenAnnouncementOff": "끄기" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 7c8855b3f..282ddcd74 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "ซ้อนตำแหน่งฟ้าผ่าที่บันทึกในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง", "radarWindOverlay": "แสดงลม", "radarWindOverlayHint": "ลมจากสถานีในเวลาเดียวกับภาพที่แสดง", - "radarWindOverlaySubtitle": "ซ้อนทิศทางและความเร็วลมจากสถานีตรวจวัดในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง" + "radarWindOverlaySubtitle": "ซ้อนทิศทางและความเร็วลมจากสถานีตรวจวัดในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง", + "moreSectionAccessibility": "การช่วยการเข้าถึง", + "eewSpokenAnnouncementOn": "เปิด", + "eewSpokenAnnouncementOnDescription": "อ่านออกเสียงความรุนแรงที่คาดการณ์ก่อน แล้วจึงส่งเสียงเตือน ซึ่งทำให้เสียงเตือนช้าลงตามความยาวของประโยค", + "eewSpokenAnnouncementOff": "ปิด" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 23725ad4e..af85c0d6e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "Chồng các cú sét được ghi nhận cùng thời điểm với ảnh radar đang hiển thị.", "radarWindOverlay": "Hiện gió", "radarWindOverlayHint": "Gió trạm cùng thời điểm với ảnh đang xem", - "radarWindOverlaySubtitle": "Chồng hướng và tốc độ gió từ các trạm quan trắc cùng thời điểm với ảnh radar đang hiển thị." + "radarWindOverlaySubtitle": "Chồng hướng và tốc độ gió từ các trạm quan trắc cùng thời điểm với ảnh radar đang hiển thị.", + "moreSectionAccessibility": "Trợ năng", + "eewSpokenAnnouncementOn": "Bật", + "eewSpokenAnnouncementOnDescription": "Cường độ dự kiến được đọc lên trước, sau đó mới phát âm báo động — nên âm báo động chậm lại đúng bằng thời lượng câu đọc.", + "eewSpokenAnnouncementOff": "Tắt" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 7fafcd6b7..c9f5d563a 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅閃電落雷。", "radarWindOverlay": "顯示風向", "radarWindOverlayHint": "顯示同畫面回波同一時間嘅測站風向", - "radarWindOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅測站風向同風速。" + "radarWindOverlaySubtitle": "喺而家嘅雷達回波上面疊加同一時間嘅測站風向同風速。", + "moreSectionAccessibility": "無障礙", + "eewSpokenAnnouncementOn": "開啟", + "eewSpokenAnnouncementOnDescription": "會先讀出預估震度,之後先播警示音——即係警示音會遲咗一句嘢咁耐。", + "eewSpokenAnnouncementOff": "關閉" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c6beaf1a9..71f40bb98 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1986,5 +1986,9 @@ "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。", "radarWindOverlay": "显示风向", "radarWindOverlayHint": "显示与画面回波同一时间的测站风向", - "radarWindOverlaySubtitle": "在当前的雷达回波上叠加同一时间的测站风向与风速。" + "radarWindOverlaySubtitle": "在当前的雷达回波上叠加同一时间的测站风向与风速。", + "moreSectionAccessibility": "无障碍", + "eewSpokenAnnouncementOn": "开启", + "eewSpokenAnnouncementOnDescription": "先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。", + "eewSpokenAnnouncementOff": "关闭" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index c883aa69f..d5c2a6fa4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "在当前的雷达回波上叠加同一时间的闪电落雷。", "radarWindOverlay": "显示风向", "radarWindOverlayHint": "显示与画面回波同一时间的测站风向", - "radarWindOverlaySubtitle": "在当前的雷达回波上叠加同一时间的测站风向与风速。" + "radarWindOverlaySubtitle": "在当前的雷达回波上叠加同一时间的测站风向与风速。", + "moreSectionAccessibility": "无障碍", + "eewSpokenAnnouncementOn": "开启", + "eewSpokenAnnouncementOnDescription": "先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。", + "eewSpokenAnnouncementOff": "关闭" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index c36eb1c22..8301f622f 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅閃電落雷。", "radarWindOverlay": "顯示風向", "radarWindOverlayHint": "顯示與畫面回波同一時間嘅測站風向", - "radarWindOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅測站風向同風速。" + "radarWindOverlaySubtitle": "喺目前嘅雷達回波上疊加同一時間嘅測站風向同風速。", + "moreSectionAccessibility": "無障礙", + "eewSpokenAnnouncementOn": "開啟", + "eewSpokenAnnouncementOnDescription": "先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。", + "eewSpokenAnnouncementOff": "關閉" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 19da8014a..7e29d7870 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1994,5 +1994,9 @@ "radarLightningOverlaySubtitle": "在目前的雷達回波上疊加同一時間的閃電落雷。", "radarWindOverlay": "顯示風向", "radarWindOverlayHint": "顯示與畫面回波同時間的測站風向", - "radarWindOverlaySubtitle": "在目前的雷達回波上疊加同一時間的測站風向與風速。" + "radarWindOverlaySubtitle": "在目前的雷達回波上疊加同一時間的測站風向與風速。", + "moreSectionAccessibility": "無障礙", + "eewSpokenAnnouncementOn": "開啟", + "eewSpokenAnnouncementOnDescription": "先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。", + "eewSpokenAnnouncementOff": "關閉" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 047e8372a..38693660f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6346,6 +6346,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Overlays the station wind direction and speed recorded at the same time as the radar frame you are looking at.'** String get radarWindOverlaySubtitle; + + /// More menu section header for accessibility settings + /// + /// In en, this message translates to: + /// **'Accessibility'** + String get moreSectionAccessibility; + + /// Option label: the monitor speaks before the warning sound + /// + /// In en, this message translates to: + /// **'On'** + String get eewSpokenAnnouncementOn; + + /// Explains that speech delays the warning sound by the length of the phrase + /// + /// In en, this message translates to: + /// **'The estimated intensity is read aloud first, then the warning sound plays — which delays it by however long the phrase takes.'** + String get eewSpokenAnnouncementOnDescription; + + /// Option label: no announcement, warning sound only + /// + /// In en, this message translates to: + /// **'Off'** + String get eewSpokenAnnouncementOff; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index b5b6c0b9d..1b4639eae 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3343,4 +3343,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get radarWindOverlaySubtitle => 'Overlays the station wind direction and speed recorded at the same time as the radar frame you are looking at.'; + + @override + String get moreSectionAccessibility => 'Accessibility'; + + @override + String get eewSpokenAnnouncementOn => 'On'; + + @override + String get eewSpokenAnnouncementOnDescription => + 'The estimated intensity is read aloud first, then the warning sound plays — which delays it by however long the phrase takes.'; + + @override + String get eewSpokenAnnouncementOff => 'Off'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 77c05f493..88a3af940 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3362,4 +3362,17 @@ class AppLocalizationsFil extends AppLocalizations { @override String get radarWindOverlaySubtitle => 'Ipinapatong ang direksyon at bilis ng hangin mula sa mga istasyon na naitala sa parehong oras ng radar na tinitingnan mo.'; + + @override + String get moreSectionAccessibility => 'Accessibility'; + + @override + String get eewSpokenAnnouncementOn => 'Naka-on'; + + @override + String get eewSpokenAnnouncementOnDescription => + 'Babasahin muna nang malakas ang tinatayang intensidad bago tumunog ang babala — kaya naaantala ito nang kasinghaba ng pangungusap.'; + + @override + String get eewSpokenAnnouncementOff => 'Naka-off'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index c0720d6dc..ceca3a619 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3356,4 +3356,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get radarWindOverlaySubtitle => 'Menampilkan arah dan kecepatan angin dari stasiun pengamatan pada waktu yang sama dengan citra radar yang sedang dilihat.'; + + @override + String get moreSectionAccessibility => 'Aksesibilitas'; + + @override + String get eewSpokenAnnouncementOn => 'Aktif'; + + @override + String get eewSpokenAnnouncementOnDescription => + 'Intensitas perkiraan dibacakan lebih dulu, lalu suara peringatan diputar — sehingga suara itu tertunda selama kalimatnya.'; + + @override + String get eewSpokenAnnouncementOff => 'Nonaktif'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 7197c25ba..b49906d41 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3280,4 +3280,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get radarWindOverlaySubtitle => '表示中のレーダーエコーと同じ時刻の観測所の風向・風速を重ねて表示します。'; + + @override + String get moreSectionAccessibility => 'アクセシビリティ'; + + @override + String get eewSpokenAnnouncementOn => 'オン'; + + @override + String get eewSpokenAnnouncementOnDescription => + '予想震度を音声で読み上げてから警報音を鳴らします。その分、警報音は読み上げの長さだけ遅れます。'; + + @override + String get eewSpokenAnnouncementOff => 'オフ'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index ea253e3d1..3ea52d93b 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3282,4 +3282,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get radarWindOverlaySubtitle => '현재 보고 있는 레이더 영상과 같은 시각의 관측소 풍향·풍속을 겹쳐서 표시합니다.'; + + @override + String get moreSectionAccessibility => '접근성'; + + @override + String get eewSpokenAnnouncementOn => '켜기'; + + @override + String get eewSpokenAnnouncementOnDescription => + '예상 진도를 음성으로 먼저 안내한 뒤 경보음이 울립니다. 그만큼 경보음이 늦어집니다.'; + + @override + String get eewSpokenAnnouncementOff => '끄기'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 6b7bb54aa..353918bae 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3336,4 +3336,17 @@ class AppLocalizationsTh extends AppLocalizations { @override String get radarWindOverlaySubtitle => 'ซ้อนทิศทางและความเร็วลมจากสถานีตรวจวัดในเวลาเดียวกับภาพเรดาร์ที่กำลังแสดง'; + + @override + String get moreSectionAccessibility => 'การช่วยการเข้าถึง'; + + @override + String get eewSpokenAnnouncementOn => 'เปิด'; + + @override + String get eewSpokenAnnouncementOnDescription => + 'อ่านออกเสียงความรุนแรงที่คาดการณ์ก่อน แล้วจึงส่งเสียงเตือน ซึ่งทำให้เสียงเตือนช้าลงตามความยาวของประโยค'; + + @override + String get eewSpokenAnnouncementOff => 'ปิด'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 721d385cf..8a1f80401 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3344,4 +3344,17 @@ class AppLocalizationsVi extends AppLocalizations { @override String get radarWindOverlaySubtitle => 'Chồng hướng và tốc độ gió từ các trạm quan trắc cùng thời điểm với ảnh radar đang hiển thị.'; + + @override + String get moreSectionAccessibility => 'Trợ năng'; + + @override + String get eewSpokenAnnouncementOn => 'Bật'; + + @override + String get eewSpokenAnnouncementOnDescription => + 'Cường độ dự kiến được đọc lên trước, sau đó mới phát âm báo động — nên âm báo động chậm lại đúng bằng thời lượng câu đọc.'; + + @override + String get eewSpokenAnnouncementOff => 'Tắt'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 5a2707e34..2642117f0 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3262,4 +3262,17 @@ class AppLocalizationsYue extends AppLocalizations { @override String get radarWindOverlaySubtitle => '喺而家嘅雷達回波上面疊加同一時間嘅測站風向同風速。'; + + @override + String get moreSectionAccessibility => '無障礙'; + + @override + String get eewSpokenAnnouncementOn => '開啟'; + + @override + String get eewSpokenAnnouncementOnDescription => + '會先讀出預估震度,之後先播警示音——即係警示音會遲咗一句嘢咁耐。'; + + @override + String get eewSpokenAnnouncementOff => '關閉'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 146442cab..1aee120d0 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3262,6 +3262,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get radarWindOverlaySubtitle => '在当前的雷达回波上叠加同一时间的测站风向与风速。'; + + @override + String get moreSectionAccessibility => '无障碍'; + + @override + String get eewSpokenAnnouncementOn => '开启'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。'; + + @override + String get eewSpokenAnnouncementOff => '关闭'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6521,6 +6534,19 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get radarWindOverlaySubtitle => '在当前的雷达回波上叠加同一时间的测站风向与风速。'; + + @override + String get moreSectionAccessibility => '无障碍'; + + @override + String get eewSpokenAnnouncementOn => '开启'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。'; + + @override + String get eewSpokenAnnouncementOff => '关闭'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9780,6 +9806,19 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get radarWindOverlaySubtitle => '喺目前嘅雷達回波上疊加同一時間嘅測站風向同風速。'; + + @override + String get moreSectionAccessibility => '無障礙'; + + @override + String get eewSpokenAnnouncementOn => '開啟'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。'; + + @override + String get eewSpokenAnnouncementOff => '關閉'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -13039,4 +13078,17 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get radarWindOverlaySubtitle => '在目前的雷達回波上疊加同一時間的測站風向與風速。'; + + @override + String get moreSectionAccessibility => '無障礙'; + + @override + String get eewSpokenAnnouncementOn => '開啟'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。'; + + @override + String get eewSpokenAnnouncementOff => '關閉'; } diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index 9c0b46d91..38776f913 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -106,6 +106,12 @@ abstract final class AppRoutes { static const String eewSource = 'eewSource'; static const String eewSourcePath = '/eew-source'; + /// Whether the monitor speaks the estimated intensity before the warning + /// sound — a page of its own rather than a switch in the menu, so the + /// trade it makes has somewhere to be explained. + static const String spokenIntensity = 'spokenIntensity'; + static const String spokenIntensityPath = '/spoken-intensity'; + static const String log = 'log'; static const String logPath = '/log'; diff --git a/test/core/settings/eew_spoken_announcement_settings_test.dart b/test/core/settings/eew_spoken_announcement_settings_test.dart index 3ae298847..15b724791 100644 --- a/test/core/settings/eew_spoken_announcement_settings_test.dart +++ b/test/core/settings/eew_spoken_announcement_settings_test.dart @@ -1,8 +1,9 @@ /// The monitor's spoken-announcement switch. /// -/// The default is the whole point of these tests: an EEW announcement that -/// silently defaults to off is a feature nobody ever hears, and the failure -/// looks exactly like a broken TTS engine. +/// The default is the whole point of these tests: speech delays the warning +/// sound by however long the phrase takes, so it has to be something the user +/// asked for. A default that silently drifted to on would push that trade onto +/// everyone. library; import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; @@ -11,27 +12,27 @@ import 'package:dpip/core/settings/settings_store.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('defaults to on when nothing was ever saved', () { + test('defaults to off when nothing was ever saved', () { final settings = EewSpokenAnnouncementSettings(SettingsStore.inMemory()); - expect(settings.enabled, isTrue); + expect(settings.enabled, isFalse); }); test('reads back what was saved, in both directions', () async { final store = SettingsStore.inMemory(); final settings = EewSpokenAnnouncementSettings(store); - await settings.setEnabled(false); - expect(settings.enabled, isFalse); - expect(store.getBool(SettingKeys.eewSpokenAnnouncement), isFalse); - await settings.setEnabled(true); expect(settings.enabled, isTrue); + expect(store.getBool(SettingKeys.eewSpokenAnnouncement), isTrue); + + await settings.setEnabled(false); + expect(settings.enabled, isFalse); }); test('a saved value survives a new instance over the same store', () async { final store = SettingsStore.inMemory(); - await EewSpokenAnnouncementSettings(store).setEnabled(false); - expect(EewSpokenAnnouncementSettings(store).enabled, isFalse); + await EewSpokenAnnouncementSettings(store).setEnabled(true); + expect(EewSpokenAnnouncementSettings(store).enabled, isTrue); }); test('notifies listeners so the monitor re-reads it mid-alert', () async { diff --git a/test/features/map/presentation/monitor_eew_announcement_controller_test.dart b/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart similarity index 98% rename from test/features/map/presentation/monitor_eew_announcement_controller_test.dart rename to test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart index 5208019e3..693c60c80 100644 --- a/test/features/map/presentation/monitor_eew_announcement_controller_test.dart +++ b/test/features/earthquake/domain/monitor_eew_announcement_controller_test.dart @@ -7,7 +7,7 @@ import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/core/speech/speech_service.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; -import 'package:dpip/features/map/presentation/monitor_eew_announcement_controller.dart'; +import 'package:dpip/features/earthquake/domain/monitor_eew_announcement_controller.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakeSpeech implements SpeechService { diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 3163f754c..d57adbcf3 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -92,6 +92,7 @@ const _tiles = <(String, String)>[ (AppRoutes.language, 'Language'), (AppRoutes.display, 'Display'), (AppRoutes.log, 'App logs'), + (AppRoutes.spokenIntensity, 'Speak estimated intensity'), ]; GoRouter _router(List visited) => GoRouter( @@ -310,32 +311,40 @@ void main() { expect(beta.dy, lessThan(partner.dy)); }); - testWidgets( - 'the spoken-announcement row starts on and the whole row toggles', - (tester) async { - await _pump(tester, _router([])); - const label = 'Speak estimated intensity'; - Switch speechSwitch() => tester.widget( - find.descendant( - of: find.widgetWithText(ListTile, label), - matching: find.byType(Switch), - ), - ); + testWidgets('the accessibility row shows its state and opens its page', ( + tester, + ) async { + final visited = []; + await _pump(tester, _router(visited)); + const label = 'Speak estimated intensity'; - // Defaults to on: an announcement nobody opted into is the point. - expect(speechSwitch().value, isTrue); + // The row reads as a destination like every other row in this menu — the + // choice and the trade it makes live on the page, not in the menu. + final tile = tester.widget(find.widgetWithText(ListTile, label)); + expect(tile.trailing, isA()); + expect(find.byType(Switch), findsNothing); - // The tap lands on the row, not the switch — a control you can only hit by - // aiming at the switch is a much smaller target than the row it sits in. - await tester.tap(find.widgetWithText(ListTile, label)); - await tester.pump(const Duration(milliseconds: 100)); - expect(speechSwitch().value, isFalse); + // Off by default, and the row says so without opening anything. + expect( + find.descendant( + of: find.widgetWithText(ListTile, label), + matching: find.text('Off'), + ), + findsOneWidget, + ); - await tester.tap(find.widgetWithText(ListTile, label)); - await tester.pump(const Duration(milliseconds: 100)); - expect(speechSwitch().value, isTrue); - }, - ); + // Under 無障礙, not 通知 — a row that drifts back into another section is + // exactly the kind of edit nothing else would notice. + final accessibility = tester.getTopLeft(find.text('Accessibility')).dy; + final row = tester.getTopLeft(find.widgetWithText(ListTile, label)).dy; + final nextSection = tester.getTopLeft(find.text('Mesh network')).dy; + expect(row, greaterThan(accessibility)); + expect(row, lessThan(nextSection)); + + await tester.tap(find.widgetWithText(ListTile, label)); + await tester.pump(const Duration(milliseconds: 600)); + expect(visited, [AppRoutes.spokenIntensity]); + }); testWidgets('permission check sits with the notification settings', ( tester, diff --git a/test/features/settings/spoken_intensity_page_test.dart b/test/features/settings/spoken_intensity_page_test.dart new file mode 100644 index 000000000..14bebfaa4 --- /dev/null +++ b/test/features/settings/spoken_intensity_page_test.dart @@ -0,0 +1,80 @@ +/// The spoken-intensity page. +/// +/// The trade this setting makes — speech delays the warning sound — is stated +/// in the note under the switch and nowhere else, so a page that stopped +/// showing it while the switch is off would quietly turn an informed choice +/// into a blind one. +library; + +import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/settings/presentation/pages/spoken_intensity_page.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +Future _pump( + WidgetTester tester, + EewSpokenAnnouncementSettings settings, +) => tester.pumpWidget( + ChangeNotifierProvider.value( + value: settings, + child: MaterialApp( + locale: const Locale('zh', 'TW'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SpokenIntensityPage(), + ), + ), +); + +void main() { + testWidgets('opens off and the switch turns it on', (tester) async { + final settings = EewSpokenAnnouncementSettings(SettingsStore.inMemory()); + await _pump(tester, settings); + + expect(settings.enabled, isFalse); + expect(tester.widget(find.byType(Switch)).value, isFalse); + expect(find.text('關閉'), findsOneWidget); + + await tester.tap(find.byType(Switch)); + await tester.pump(); + + expect(settings.enabled, isTrue); + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect(find.text('開啟'), findsOneWidget); + }); + + testWidgets('tapping the card toggles it too', (tester) async { + final settings = EewSpokenAnnouncementSettings(SettingsStore.inMemory()); + await _pump(tester, settings); + + // The row is the target, not just the switch — hit the card's title (the + // app bar carries the same text, so scope to the tappable surface). + final cardTitle = find.descendant( + of: find.byType(InkWell), + matching: find.text('朗讀預估震度'), + ); + await tester.tap(cardTitle); + await tester.pump(); + expect(settings.enabled, isTrue); + + await tester.tap(cardTitle); + await tester.pump(); + expect(settings.enabled, isFalse); + }); + + testWidgets('the delay note is shown while the switch is off', ( + tester, + ) async { + await _pump( + tester, + EewSpokenAnnouncementSettings(SettingsStore.inMemory()), + ); + + // The cost, not just the label: a user turning this on is accepting a + // slower warning, and they have to be able to read that before they do. + expect(find.textContaining('警示音會因此延後'), findsOneWidget); + }); +}