Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 7 additions & 6 deletions lib/core/settings/eew_spoken_announcement_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> setEnabled(bool value) async {
await _settings.setBool(SettingKeys.eewSpokenAnnouncement, value);
Expand Down
4 changes: 2 additions & 2 deletions lib/core/settings/setting_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> eewSpokenAnnouncement = SettingKey<bool>._(
'earthquake.eewSpokenAnnouncement',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
98 changes: 98 additions & 0 deletions lib/features/earthquake/presentation/pages/report_replay_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,6 +109,24 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
/// 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();
Expand All @@ -118,6 +143,7 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
widget.replayTimestamp,
cwaOnly: () => cwaOnly.enabled,
)..start();
_session.eew.addListener(_syncAnnouncement);
_startTicker();
}

Expand All @@ -136,6 +162,9 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
/// 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();
Expand All @@ -145,6 +174,8 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
_ticker = null;
_session.pause();
}
_resumed = active;
_syncAnnouncement();
}

void _startTicker() {
Expand All @@ -154,10 +185,77 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
);
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
_l10n = AppLocalizations.of(context);
_languageTag = Localizations.localeOf(context).toLanguageTag();
_announcement ??= _createAnnouncementController();
final speechSettings = context.read<EewSpokenAnnouncementSettings?>();
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<SpeechService?>();
if (speech == null) return null;
final location = context.read<LocationService?>();
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();
}
Expand Down
6 changes: 3 additions & 3 deletions lib/features/map/presentation/widgets/rts_monitor_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -191,11 +191,11 @@ class _RtsMonitorPanelState extends State<RtsMonitorPanel>
if (controller == null || l10n == null) return;
final foreground =
_lifecycleState == null || _lifecycleState == AppLifecycleState.resumed;
// Absent provider means a test that supplied neitherannounce, matching
// Absent provider means a test that supplied nonestay 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,
Expand Down
58 changes: 23 additions & 35 deletions lib/features/more/presentation/pages/more_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class MorePage extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final mapLayer = context.watch<DefaultMapLayerController>().layer;
final eewCwaOnly = context.watch<EewCwaOnlySettings>().enabled;
final spokenIntensity = context
.watch<EewSpokenAnnouncementSettings>()
.enabled;
return Scaffold(
body: SafeArea(
bottom: false,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<EewSpokenAnnouncementSettings>();
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();

Expand Down
Loading
Loading