From c3568aabdb4f4be75bb7621985cc521421da2483 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:56:37 +0800 Subject: [PATCH 1/3] feat(eew): speak the estimated intensity during a report replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 地震報告的重播也會朗讀預估震度,和即時監視器一樣 New(en-US): a report replay now speaks the estimated intensity, the same way the live monitor does --- .../monitor_eew_announcement_controller.dart | 8 +- .../pages/report_replay_page.dart | 85 +++++++++++++++++++ .../widgets/rts_monitor_panel.dart | 2 +- ...itor_eew_announcement_controller_test.dart | 2 +- 4 files changed, 94 insertions(+), 3 deletions(-) rename lib/features/{map/presentation => earthquake/domain}/monitor_eew_announcement_controller.dart (90%) rename test/features/{map/presentation => earthquake/domain}/monitor_eew_announcement_controller_test.dart (98%) 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 9e347d8ac..327875c2e 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'; @@ -98,6 +105,18 @@ 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 the app is backgrounded — the replay's own pause, since its + /// channels are outside RealtimeService's lifecycle (see [initState]). + bool _resumed = true; + @override void initState() { super.initState(); @@ -107,6 +126,7 @@ class _ReportReplayPageState extends State { widget.replayTimestamp, cwaOnly: () => context.read().enabled, )..start(); + _session.eew.addListener(_syncAnnouncement); _startTicker(); // The session's channels live outside RealtimeService (a replay must not // look like a live feed), so its lifecycle pause never reaches them — @@ -117,10 +137,14 @@ class _ReportReplayPageState extends State { _ticker?.cancel(); _ticker = null; _session.pause(); + _resumed = false; + _syncAnnouncement(); }, onResume: () { _startTicker(); _session.resume(); + _resumed = true; + _syncAnnouncement(); }, ); } @@ -134,11 +158,72 @@ class _ReportReplayPageState extends State { late final AppLifecycleListener _lifecycle; + @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 read keeps the page testable without the app's provider list. + 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 { + 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() { _lifecycle.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..14e953d2e 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'; 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 { From 198c237fe52820118cc8879d4bf95475a90a72c4 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:57:22 +0800 Subject: [PATCH 2/3] fix(eew): keep the spoken announcement off until it is asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 預估震度朗讀改為預設關閉,要用的人自己開啟 Fix(en-US): the spoken intensity announcement is off by default and has to be switched on --- .../eew_spoken_announcement_settings.dart | 13 ++++++----- lib/core/settings/setting_keys.dart | 4 ++-- .../widgets/rts_monitor_panel.dart | 4 ++-- ...eew_spoken_announcement_settings_test.dart | 23 ++++++++++--------- test/features/more/more_page_test.dart | 10 ++++---- 5 files changed, 28 insertions(+), 26 deletions(-) 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 aea772505..469f3e400 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -237,8 +237,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/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 14e953d2e..35937d47f 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.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/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/more/more_page_test.dart b/test/features/more/more_page_test.dart index 3163f754c..335f3d91d 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -311,7 +311,7 @@ void main() { }); testWidgets( - 'the spoken-announcement row starts on and the whole row toggles', + 'the spoken-announcement row starts off and the whole row toggles', (tester) async { await _pump(tester, _router([])); const label = 'Speak estimated intensity'; @@ -322,18 +322,18 @@ void main() { ), ); - // Defaults to on: an announcement nobody opted into is the point. - expect(speechSwitch().value, isTrue); + // Defaults to off: speech delays the warning sound, so it is opt-in. + expect(speechSwitch().value, isFalse); // 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); + expect(speechSwitch().value, isTrue); await tester.tap(find.widgetWithText(ListTile, label)); await tester.pump(const Duration(milliseconds: 100)); - expect(speechSwitch().value, isTrue); + expect(speechSwitch().value, isFalse); }, ); From 4a6963c9caa6f4a4bcf5ec3ca1eea73ade1d7546 Mon Sep 17 00:00:00 2001 From: archie0732 <121162902+archie0732@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:57:44 +0800 Subject: [PATCH 3/3] feat(settings): give the spoken intensity a page under accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 「更多」新增「無障礙」區塊,預估震度朗讀有了自己的設定頁 New(en-US): the More menu has an Accessibility section, and the spoken intensity announcement has a page of its own --- lib/app/router/app_router.dart | 6 + .../more/presentation/pages/more_page.dart | 58 +++---- .../pages/spoken_intensity_page.dart | 149 ++++++++++++++++++ lib/l10n/app_en.arb | 20 +++ lib/l10n/app_fil.arb | 7 +- lib/l10n/app_id.arb | 7 +- lib/l10n/app_ja.arb | 7 +- lib/l10n/app_ko.arb | 7 +- lib/l10n/app_th.arb | 7 +- lib/l10n/app_vi.arb | 7 +- lib/l10n/app_yue.arb | 7 +- lib/l10n/app_zh.arb | 7 +- lib/l10n/app_zh_Hans.arb | 7 +- lib/l10n/app_zh_Hant_HK.arb | 7 +- lib/l10n/app_zh_TW.arb | 7 +- lib/l10n/gen/app_localizations.dart | 30 ++++ lib/l10n/gen/app_localizations_en.dart | 17 ++ lib/l10n/gen/app_localizations_fil.dart | 17 ++ lib/l10n/gen/app_localizations_id.dart | 17 ++ lib/l10n/gen/app_localizations_ja.dart | 16 ++ lib/l10n/gen/app_localizations_ko.dart | 16 ++ lib/l10n/gen/app_localizations_th.dart | 17 ++ lib/l10n/gen/app_localizations_vi.dart | 17 ++ lib/l10n/gen/app_localizations_yue.dart | 16 ++ lib/l10n/gen/app_localizations_zh.dart | 64 ++++++++ lib/shared/navigation/app_routes.dart | 6 + test/features/more/more_page_test.dart | 55 ++++--- .../settings/spoken_intensity_page_test.dart | 62 ++++++++ 28 files changed, 591 insertions(+), 69 deletions(-) create mode 100644 lib/features/settings/presentation/pages/spoken_intensity_page.dart create mode 100644 test/features/settings/spoken_intensity_page_test.dart 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/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 4771dc6de..be38f9e4d 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. @@ -136,6 +133,26 @@ class MorePage extends StatelessWidget { // 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. + // 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), + ), + ], + ), SectionHeader(l10n.moreSectionMesh), _MoreGroup( children: [ @@ -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..d46bac6eb --- /dev/null +++ b/lib/features/settings/presentation/pages/spoken_intensity_page.dart @@ -0,0 +1,149 @@ +/// 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'; + +/// Two-card choice, the same shape as `EewSourcePage`, rather than a switch in +/// the menu. +/// +/// A switch states the setting but has nowhere to state its cost: speech delays +/// the warning sound by however long the phrase takes. That is the one thing a +/// user has to know before turning this on, so the "on" card says it. +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; + 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.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.xl), + _ChoiceCard( + icon: Icons.record_voice_over_outlined, + title: Text(l10n.eewSpokenAnnouncementOn), + description: Text(l10n.eewSpokenAnnouncementOnDescription), + selected: enabled, + onTap: () => settings.setEnabled(true), + ), + const SizedBox(height: AppSpacing.md), + _ChoiceCard( + icon: Icons.voice_over_off_outlined, + title: Text(l10n.eewSpokenAnnouncementOff), + description: Text(l10n.eewSpokenAnnouncementOffDescription), + selected: !enabled, + onTap: () => settings.setEnabled(false), + ), + ], + ), + ); + } +} + +/// One option, called out with a tinted surface, an outline and a filled icon +/// so the active choice reads without relying on a checkmark alone. +class _ChoiceCard extends StatelessWidget { + const _ChoiceCard({ + required this.icon, + required this.title, + required this.description, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final Widget title; + final Widget description; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Semantics( + button: true, + selected: selected, + child: Material( + color: selected + ? colors.primaryContainer.withValues(alpha: 0.55) + : colors.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.medium, + side: BorderSide( + color: selected ? colors.primary : colors.outlineVariant, + width: selected ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: selected + ? colors.primary + : colors.surfaceContainerHighest, + borderRadius: AppRadius.medium, + ), + child: Icon( + icon, + color: selected + ? colors.onPrimary + : colors.onSurfaceVariant, + ), + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + style: Theme.of(context).textTheme.titleMedium, + child: title, + ), + const SizedBox(height: AppSpacing.xs), + DefaultTextStyle.merge( + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + child: description, + ), + ], + ), + ), + if (selected) ...[ + const SizedBox(width: AppSpacing.md), + Icon(Icons.check_circle, color: colors.primary), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b0bb8156a..e06019afe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3984,5 +3984,25 @@ }, "@eewSpokenAnnouncementDescription": { "description": "Explains what the spoken-announcement toggle does and when it speaks" + }, + "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", + "eewSpokenAnnouncementOffDescription": "Only the warning sound plays, with no announcement.", + "@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" + }, + "@eewSpokenAnnouncementOffDescription": { + "description": "Explains that only the warning sound plays" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 02e6014d1..3fc8bcd99 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "Tinatayang intensidad sa iyong lokasyon: {intensity}.", "eewSpokenMaxIntensity": "Tinatayang pinakamataas na intensidad: {intensity}.", "eewSpokenAnnouncementTitle": "Basahin ang tinatayang intensidad", - "eewSpokenAnnouncementDescription": "Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala." + "eewSpokenAnnouncementDescription": "Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala.", + "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", + "eewSpokenAnnouncementOffDescription": "Ang babala lang ang tutunog, walang binabasa." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0b4c57c32..6eeafaac7 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "Perkiraan intensitas di lokasi Anda: {intensity}.", "eewSpokenMaxIntensity": "Perkiraan intensitas maksimum: {intensity}.", "eewSpokenAnnouncementTitle": "Bacakan intensitas perkiraan", - "eewSpokenAnnouncementDescription": "Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar." + "eewSpokenAnnouncementDescription": "Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar.", + "moreSectionAccessibility": "Aksesibilitas", + "eewSpokenAnnouncementOn": "Aktif", + "eewSpokenAnnouncementOnDescription": "Intensitas perkiraan dibacakan lebih dulu, lalu suara peringatan diputar — sehingga suara itu tertunda selama kalimatnya.", + "eewSpokenAnnouncementOff": "Nonaktif", + "eewSpokenAnnouncementOffDescription": "Hanya suara peringatan yang diputar, tanpa pembacaan." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index cfe219f27..8a659ad81 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "現在地の予想震度、{intensity}。", "eewSpokenMaxIntensity": "予想最大震度、{intensity}。", "eewSpokenAnnouncementTitle": "予想震度を読み上げる", - "eewSpokenAnnouncementDescription": "強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。" + "eewSpokenAnnouncementDescription": "強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。", + "moreSectionAccessibility": "アクセシビリティ", + "eewSpokenAnnouncementOn": "オン", + "eewSpokenAnnouncementOnDescription": "予想震度を音声で読み上げてから警報音を鳴らします。その分、警報音は読み上げの長さだけ遅れます。", + "eewSpokenAnnouncementOff": "オフ", + "eewSpokenAnnouncementOffDescription": "読み上げず、警報音のみを鳴らします。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a5f5e97e3..8a506b6de 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "현재 위치 예상 진도, {intensity}.", "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}.", "eewSpokenAnnouncementTitle": "예상 진도 음성 안내", - "eewSpokenAnnouncementDescription": "지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다." + "eewSpokenAnnouncementDescription": "지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다.", + "moreSectionAccessibility": "접근성", + "eewSpokenAnnouncementOn": "켜기", + "eewSpokenAnnouncementOnDescription": "예상 진도를 음성으로 먼저 안내한 뒤 경보음이 울립니다. 그만큼 경보음이 늦어집니다.", + "eewSpokenAnnouncementOff": "끄기", + "eewSpokenAnnouncementOffDescription": "안내 없이 경보음만 울립니다." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b6c184c6d..d5656348b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: {intensity}", "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}", "eewSpokenAnnouncementTitle": "อ่านออกเสียงความรุนแรงที่คาดการณ์", - "eewSpokenAnnouncementDescription": "เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน" + "eewSpokenAnnouncementDescription": "เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน", + "moreSectionAccessibility": "การช่วยการเข้าถึง", + "eewSpokenAnnouncementOn": "เปิด", + "eewSpokenAnnouncementOnDescription": "อ่านออกเสียงความรุนแรงที่คาดการณ์ก่อน แล้วจึงส่งเสียงเตือน ซึ่งทำให้เสียงเตือนช้าลงตามความยาวของประโยค", + "eewSpokenAnnouncementOff": "ปิด", + "eewSpokenAnnouncementOffDescription": "ส่งเสียงเตือนอย่างเดียว ไม่มีการอ่านออกเสียง" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0e25756c1..33b6066d1 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1986,5 +1986,10 @@ "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}.", "eewSpokenAnnouncementTitle": "Đọc cường độ dự kiến", - "eewSpokenAnnouncementDescription": "Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động." + "eewSpokenAnnouncementDescription": "Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động.", + "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", + "eewSpokenAnnouncementOffDescription": "Chỉ phát âm báo động, không đọc." } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 8df7c7431..518ba74ab 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗讀預估震度", - "eewSpokenAnnouncementDescription": "開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。" + "eewSpokenAnnouncementDescription": "開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。", + "moreSectionAccessibility": "無障礙", + "eewSpokenAnnouncementOn": "開啟", + "eewSpokenAnnouncementOnDescription": "會先讀出預估震度,之後先播警示音——即係警示音會遲咗一句嘢咁耐。", + "eewSpokenAnnouncementOff": "關閉", + "eewSpokenAnnouncementOffDescription": "淨係播警示音,唔會讀出嚟。" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 06cd6a355..c35586e4c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1978,5 +1978,10 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗读预估烈度", - "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。" + "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。", + "moreSectionAccessibility": "无障碍", + "eewSpokenAnnouncementOn": "开启", + "eewSpokenAnnouncementOnDescription": "先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。", + "eewSpokenAnnouncementOff": "关闭", + "eewSpokenAnnouncementOffDescription": "只播放警示音,不朗读。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 318a6dff7..c279b4c57 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "所在地预估烈度,{intensity}。", "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。", "eewSpokenAnnouncementTitle": "朗读预估烈度", - "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。" + "eewSpokenAnnouncementDescription": "打开强震监视器时,先用语音朗读预估烈度,再播放警示音。", + "moreSectionAccessibility": "无障碍", + "eewSpokenAnnouncementOn": "开启", + "eewSpokenAnnouncementOnDescription": "先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。", + "eewSpokenAnnouncementOff": "关闭", + "eewSpokenAnnouncementOffDescription": "只播放警示音,不朗读。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index d55860c70..399c1b367 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗讀預估震度", - "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。" + "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。", + "moreSectionAccessibility": "無障礙", + "eewSpokenAnnouncementOn": "開啟", + "eewSpokenAnnouncementOnDescription": "先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。", + "eewSpokenAnnouncementOff": "關閉", + "eewSpokenAnnouncementOffDescription": "只播放警示音,不朗讀。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 712899bbf..7433cdce2 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1986,5 +1986,10 @@ "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", "eewSpokenMaxIntensity": "預估最大震度,{intensity}。", "eewSpokenAnnouncementTitle": "朗讀預估震度", - "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。" + "eewSpokenAnnouncementDescription": "開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。", + "moreSectionAccessibility": "無障礙", + "eewSpokenAnnouncementOn": "開啟", + "eewSpokenAnnouncementOnDescription": "先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。", + "eewSpokenAnnouncementOff": "關閉", + "eewSpokenAnnouncementOffDescription": "只播放警示音,不朗讀。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3061da64b..dbe425a83 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6298,6 +6298,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.'** String get eewSpokenAnnouncementDescription; + + /// 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; + + /// Explains that only the warning sound plays + /// + /// In en, this message translates to: + /// **'Only the warning sound plays, with no announcement.'** + String get eewSpokenAnnouncementOffDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 3b682ae57..72f8c7cb4 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3317,4 +3317,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'When the seismic monitor is open, the estimated intensity is read aloud before the warning sound plays.'; + + @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'; + + @override + String get eewSpokenAnnouncementOffDescription => + 'Only the warning sound plays, with no announcement.'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 65604e5bc..a7a0073d5 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3335,4 +3335,21 @@ class AppLocalizationsFil extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'Kapag bukas ang seismic monitor, binabasa nang malakas ang tinatayang intensidad bago tumunog ang babala.'; + + @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'; + + @override + String get eewSpokenAnnouncementOffDescription => + 'Ang babala lang ang tutunog, walang binabasa.'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index bfe1d624f..57888ddb5 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3328,4 +3328,21 @@ class AppLocalizationsId extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'Saat monitor gempa terbuka, intensitas perkiraan dibacakan sebelum suara peringatan diputar.'; + + @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'; + + @override + String get eewSpokenAnnouncementOffDescription => + 'Hanya suara peringatan yang diputar, tanpa pembacaan.'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index b2b84b383..46995f76b 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3256,4 +3256,20 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '強震モニタを開いているとき、警報音の前に予想震度を音声で読み上げます。'; + + @override + String get moreSectionAccessibility => 'アクセシビリティ'; + + @override + String get eewSpokenAnnouncementOn => 'オン'; + + @override + String get eewSpokenAnnouncementOnDescription => + '予想震度を音声で読み上げてから警報音を鳴らします。その分、警報音は読み上げの長さだけ遅れます。'; + + @override + String get eewSpokenAnnouncementOff => 'オフ'; + + @override + String get eewSpokenAnnouncementOffDescription => '読み上げず、警報音のみを鳴らします。'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index c9f516da2..69accc14e 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3256,4 +3256,20 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '지진 모니터를 열었을 때 경보음보다 먼저 예상 진도를 음성으로 안내합니다.'; + + @override + String get moreSectionAccessibility => '접근성'; + + @override + String get eewSpokenAnnouncementOn => '켜기'; + + @override + String get eewSpokenAnnouncementOnDescription => + '예상 진도를 음성으로 먼저 안내한 뒤 경보음이 울립니다. 그만큼 경보음이 늦어집니다.'; + + @override + String get eewSpokenAnnouncementOff => '끄기'; + + @override + String get eewSpokenAnnouncementOffDescription => '안내 없이 경보음만 울립니다.'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 4346de7fa..296d7103c 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3310,4 +3310,21 @@ class AppLocalizationsTh extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'เมื่อเปิดจอเฝ้าระวังแผ่นดินไหว จะอ่านออกเสียงความรุนแรงที่คาดการณ์ก่อนเสียงเตือน'; + + @override + String get moreSectionAccessibility => 'การช่วยการเข้าถึง'; + + @override + String get eewSpokenAnnouncementOn => 'เปิด'; + + @override + String get eewSpokenAnnouncementOnDescription => + 'อ่านออกเสียงความรุนแรงที่คาดการณ์ก่อน แล้วจึงส่งเสียงเตือน ซึ่งทำให้เสียงเตือนช้าลงตามความยาวของประโยค'; + + @override + String get eewSpokenAnnouncementOff => 'ปิด'; + + @override + String get eewSpokenAnnouncementOffDescription => + 'ส่งเสียงเตือนอย่างเดียว ไม่มีการอ่านออกเสียง'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 9817eef11..39dfe9b4c 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3318,4 +3318,21 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => 'Khi mở màn hình theo dõi động đất, cường độ dự kiến được đọc lên trước khi phát âm báo động.'; + + @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'; + + @override + String get eewSpokenAnnouncementOffDescription => + 'Chỉ phát âm báo động, không đọc.'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 5a5af9485..284f69585 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3238,4 +3238,20 @@ class AppLocalizationsYue extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '開咗強震監視器嘅時候,會先讀出預估震度,之後先播警示音。'; + + @override + String get moreSectionAccessibility => '無障礙'; + + @override + String get eewSpokenAnnouncementOn => '開啟'; + + @override + String get eewSpokenAnnouncementOnDescription => + '會先讀出預估震度,之後先播警示音——即係警示音會遲咗一句嘢咁耐。'; + + @override + String get eewSpokenAnnouncementOff => '關閉'; + + @override + String get eewSpokenAnnouncementOffDescription => '淨係播警示音,唔會讀出嚟。'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e158b2952..99fecaafc 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3238,6 +3238,22 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eewSpokenAnnouncementDescription => '打开强震监视器时,先用语音朗读预估烈度,再播放警示音。'; + + @override + String get moreSectionAccessibility => '无障碍'; + + @override + String get eewSpokenAnnouncementOn => '开启'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。'; + + @override + String get eewSpokenAnnouncementOff => '关闭'; + + @override + String get eewSpokenAnnouncementOffDescription => '只播放警示音,不朗读。'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6473,6 +6489,22 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get eewSpokenAnnouncementDescription => '打开强震监视器时,先用语音朗读预估烈度,再播放警示音。'; + + @override + String get moreSectionAccessibility => '无障碍'; + + @override + String get eewSpokenAnnouncementOn => '开启'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先用语音朗读预估烈度,再播放警示音——警示音会因此延后一句话的长度。'; + + @override + String get eewSpokenAnnouncementOff => '关闭'; + + @override + String get eewSpokenAnnouncementOffDescription => '只播放警示音,不朗读。'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9708,6 +9740,22 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get eewSpokenAnnouncementDescription => '開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。'; + + @override + String get moreSectionAccessibility => '無障礙'; + + @override + String get eewSpokenAnnouncementOn => '開啟'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。'; + + @override + String get eewSpokenAnnouncementOff => '關閉'; + + @override + String get eewSpokenAnnouncementOffDescription => '只播放警示音,不朗讀。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12943,4 +12991,20 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get eewSpokenAnnouncementDescription => '開啟強震監視器時,先以語音朗讀預估震度,再播放警示音。'; + + @override + String get moreSectionAccessibility => '無障礙'; + + @override + String get eewSpokenAnnouncementOn => '開啟'; + + @override + String get eewSpokenAnnouncementOnDescription => + '先以語音朗讀預估震度,再播放警示音——警示音會因此延後一句話的長度。'; + + @override + String get eewSpokenAnnouncementOff => '關閉'; + + @override + String get eewSpokenAnnouncementOffDescription => '只播放警示音,不朗讀。'; } 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/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 335f3d91d..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 off 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 off: speech delays the warning sound, so it is opt-in. - expect(speechSwitch().value, isFalse); + // 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, isTrue); + // 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, isFalse); - }, - ); + // 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..2ef0fe213 --- /dev/null +++ b/test/features/settings/spoken_intensity_page_test.dart @@ -0,0 +1,62 @@ +/// The spoken-intensity page. +/// +/// The trade this setting makes — speech delays the warning sound — is stated +/// on the "on" card and nowhere else, so a card that stopped carrying its +/// description 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 on 關閉 and switches to 開啟', (tester) async { + final settings = EewSpokenAnnouncementSettings(SettingsStore.inMemory()); + await _pump(tester, settings); + + // Off by default, and exactly one card is marked as the current choice. + expect(settings.enabled, isFalse); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + + await tester.tap(find.text('開啟')); + await tester.pump(); + + expect(settings.enabled, isTrue); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + + await tester.tap(find.text('關閉')); + await tester.pump(); + + expect(settings.enabled, isFalse); + }); + + testWidgets('the on card says the warning sound is delayed', (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 the page is the only place that says so. + expect(find.textContaining('警示音會因此延後'), findsOneWidget); + }); +}