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 @@ -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<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
85 changes: 85 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 @@ -98,6 +105,18 @@ 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 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();
Expand All @@ -107,6 +126,7 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
widget.replayTimestamp,
cwaOnly: () => context.read<EewCwaOnlySettings>().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 —
Expand All @@ -117,10 +137,14 @@ class _ReportReplayPageState extends State<ReportReplayPage> {
_ticker?.cancel();
_ticker = null;
_session.pause();
_resumed = false;
_syncAnnouncement();
},
onResume: () {
_startTicker();
_session.resume();
_resumed = true;
_syncAnnouncement();
},
);
}
Expand All @@ -134,11 +158,72 @@ class _ReportReplayPageState extends State<ReportReplayPage> {

late final AppLifecycleListener _lifecycle;

@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 read keeps the page testable without the app's provider list.
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 {
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();
}
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 @@ -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: [
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