diff --git a/lib/features/notification/domain/notify_preset.dart b/lib/features/notification/domain/notify_preset.dart new file mode 100644 index 000000000..5e2d51ac1 --- /dev/null +++ b/lib/features/notification/domain/notify_preset.dart @@ -0,0 +1,142 @@ +/// Notification presets: one decision that sets every channel at once, instead +/// of nine separate ones on the per-channel page. +/// +/// A preset is defined by the option *kind* it picks for each [NotifyChannel] +/// rather than by wire index, so the table below reads as what each channel +/// will do; the wire index is derived through [optionsFor], the same contract +/// the per-channel picker sends. +/// +/// Why each channel lands where it does — every choice is the channel's +/// documented purpose (`NotificationChannels` in `core/notifications/`) read +/// against the defaults a fresh device starts from (every channel on at its +/// local threshold: EEW and the three earthquake channels at local intensity +/// 1+, the three weather channels at "my area", tsunami bulletins and +/// warnings, announcements on): +/// +/// - **EEW is never off in any preset.** Its wire list has no "off" at all; +/// [NotifyPreset.emergencyOnly] narrows it to local intensity 4+, which is the +/// threshold the channel catalogue already routes to its urgent, critical +/// EEW channel (`eew-important-v2`) — the same line this app draws between a +/// major EEW and the rest. Turning EEW down further is only possible +/// channel by channel, i.e. by choosing [NotifyPreset.custom]. +/// - **The three weather channels stay on in every preset.** The server +/// switches a whole channel, and each of them carries an urgent sub-channel +/// (山區暴雨, red-level 天氣警特報, 土石流/淹水/堰塞湖 警訊 — all in +/// `NotificationChannels.urgentChannelKeys`). Turning one off to drop its +/// routine messages would drop its life-safety ones with them. +/// - **Tsunami stays on in every preset**; only [NotifyPreset.emergencyOnly] +/// narrows it to warnings, dropping the distant-tsunami bulletins. +/// - **Recommended** is the fresh-device default minus one repeat: the +/// earthquake report restates, minutes later, what the intensity report +/// already said for the user's township, and stays readable in the app. +/// Everything about the shaking where the user is stays on — the real-time +/// monitor (the only alert when shaking outruns or never triggers an EEW) +/// and the intensity report — and so do announcements: they are rare, and +/// they are how the service says something like "alerts are delayed". +/// - **Emergency only** keeps exactly the channels with an urgent sub-channel +/// (EEW, the three weather channels, tsunami), each at the level that +/// sub-channel needs. The monitor, both earthquake reports and +/// announcements have none, so they are off. +/// - **All** is every channel at its broadest option, nationwide included. +library; + +import 'package:dpip/features/notification/domain/notify_settings.dart'; + +/// One server write: set [channel] to wire option [option]. +typedef NotifyChange = ({NotifyChannel channel, int option}); + +/// A named combination of channel options. Declaration order is display +/// order: [recommended] first because it is the preselected choice, [custom] +/// last because it is the way out to the per-channel controls. +enum NotifyPreset { + /// 建議 — the alerts about the user's own location, minus the repeat. + recommended, + + /// 全部 — every channel at its broadest option. + all, + + /// 僅緊急 — only the channels that carry a life-safety alert. + emergencyOnly, + + /// 自訂 — no fixed options; the name for every combination the other + /// presets don't describe, and the way to the per-channel controls. + custom; + + /// The option kind this preset selects for [channel], or null for [custom]. + NotifyOptionKind? kindFor(NotifyChannel channel) => switch (this) { + // Broadest first is the documented display order of + // [notifyOptionsForDisplay], so its head is "the most" for any channel. + NotifyPreset.all => notifyOptionsForDisplay(channel).first.$2, + NotifyPreset.recommended => switch (channel) { + NotifyChannel.eew || + NotifyChannel.monitor || + NotifyChannel.intensity => NotifyOptionKind.localIntensity1, + NotifyChannel.report => NotifyOptionKind.off, + NotifyChannel.thunderstorm || + NotifyChannel.weatherAdvisory || + NotifyChannel.evacuation => NotifyOptionKind.weatherLocal, + NotifyChannel.tsunami => NotifyOptionKind.tsunamiAll, + NotifyChannel.announcement => NotifyOptionKind.all, + }, + NotifyPreset.emergencyOnly => switch (channel) { + NotifyChannel.eew => NotifyOptionKind.localIntensity4, + NotifyChannel.monitor || + NotifyChannel.report || + NotifyChannel.intensity => NotifyOptionKind.off, + NotifyChannel.thunderstorm || + NotifyChannel.weatherAdvisory || + NotifyChannel.evacuation => NotifyOptionKind.weatherLocal, + NotifyChannel.tsunami => NotifyOptionKind.tsunamiWarning, + NotifyChannel.announcement => NotifyOptionKind.off, + }, + NotifyPreset.custom => null, + }; + + /// The wire option this preset selects for [channel], or null for [custom]. + int? optionFor(NotifyChannel channel) { + final kind = kindFor(channel); + if (kind == null) return null; + final index = optionsFor(channel).indexOf(kind); + // A kind the channel doesn't offer would be sent as -1 — a table typo + // that must fail where it is written, not on the server. + assert(index >= 0, '$name picks $kind, which ${channel.name} lacks'); + return index; + } + + /// The preset [settings] is exactly, or [custom] when it is none of them. + static NotifyPreset matching(NotifySettings settings) { + for (final preset in values) { + if (preset == custom) continue; + final matches = NotifyChannel.values.every( + (channel) => settings.optionOf(channel) == preset.optionFor(channel), + ); + if (matches) return preset; + } + return custom; + } + + /// The writes that turn [current] into this preset — one per channel that + /// differs, none for [custom]. + /// + /// Widening writes (towards more alerts) come before narrowing ones, each + /// group in channel order, so EEW is always first in its group. Applying is + /// one request at a time and can stop part-way; in this order a stop leaves + /// the device hearing more than either the old or the new choice, never + /// less. [optionsFor] runs narrowest to broadest (see + /// [notifyOptionsForDisplay]), so a higher wire index is a wider option. + List changesFrom(NotifySettings current) { + final widening = []; + final narrowing = []; + for (final channel in NotifyChannel.values) { + final target = optionFor(channel); + if (target == null) continue; + final from = current.optionOf(channel); + if (target == from) continue; + (target > from ? widening : narrowing).add(( + channel: channel, + option: target, + )); + } + return [...widening, ...narrowing]; + } +} diff --git a/lib/features/notification/domain/notify_preset_applier.dart b/lib/features/notification/domain/notify_preset_applier.dart new file mode 100644 index 000000000..98548c42a --- /dev/null +++ b/lib/features/notification/domain/notify_preset_applier.dart @@ -0,0 +1,99 @@ +/// Applies a [NotifyPreset] through the same [NotifyRepository.setChannel] the +/// per-channel picker uses, one channel at a time. +library; + +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_repository.dart'; +import 'package:dpip/features/notification/domain/notify_settings.dart'; + +/// Turns a preset into the per-channel writes it needs and sends them in +/// sequence, [spacing] apart. +/// +/// There is no batch endpoint: `/v2/notify/{token}/{channel}/{status}` sets +/// one channel, and the backend rate-limits requests for one token over a +/// short window. Back-to-back calls 429 — measured on the 401 recovery in +/// `NotifyRepositoryImpl`, where two requests with no gap between them failed +/// even when each was valid. So the writes go out one at a time with that +/// recovery's own two-second cool-down between consecutive requests, and only +/// for channels that actually differ: moving between neighbouring presets is +/// a handful of requests, and the worst case — all nine — takes about sixteen +/// seconds. +/// +/// Each write adopts the server's echo, exactly as a per-channel save does, so +/// `onProgress` always reports what the server holds. The first failure stops +/// the sequence and is returned as-is: what was written stays written, and +/// calling [apply] again resumes, since only the channels still differing are +/// sent. +class NotifyPresetApplier { + const NotifyPresetApplier(this._repository, {this.spacing = requestSpacing}); + + /// The default gap between two consecutive notify requests (see the class + /// doc for where the number comes from). + static const Duration requestSpacing = Duration(seconds: 2); + + final NotifyRepository _repository; + + /// The gap kept between consecutive requests of one [apply]. + final Duration spacing; + + /// Sets every channel [preset] covers for [token]. + /// + /// With [current] the writes are computed against it; without, the settings + /// are fetched first (onboarding has none yet — and that fetch is also what + /// registers a push token the backend has never seen). [onProgress] receives + /// every settings snapshot the server returns along the way. [isCancelled] + /// is checked before each request, so a caller that has gone away sends + /// nothing further. + /// + /// [NotifyPreset.custom] names no options, so it writes nothing. + Future> apply( + String token, + NotifyPreset preset, { + NotifySettings? current, + void Function(NotifySettings settings)? onProgress, + bool Function()? isCancelled, + }) async { + var first = true; + Future> send( + Future> Function() request, + ) async { + if (!first) await Future.delayed(spacing); + first = false; + if (isCancelled?.call() ?? false) { + return const Err(NetworkFailure('Request cancelled')); + } + return request(); + } + + final NotifySettings start; + if (current != null) { + start = current; + } else { + final fetched = await send(() => _repository.fetch(token)); + switch (fetched) { + case Ok(:final value): + start = value; + onProgress?.call(value); + case Err(): + return fetched; + } + } + + var settings = start; + for (final change in preset.changesFrom(start)) { + final saved = await send( + () => _repository.setChannel(token, change.channel, change.option), + ); + switch (saved) { + case Ok(:final value): + settings = value; + onProgress?.call(value); + case Err(): + return saved; + } + } + return Ok(settings); + } +} diff --git a/lib/features/notification/presentation/notify_controller.dart b/lib/features/notification/presentation/notify_controller.dart index ab37d3980..45131e16c 100644 --- a/lib/features/notification/presentation/notify_controller.dart +++ b/lib/features/notification/presentation/notify_controller.dart @@ -1,8 +1,10 @@ /// Drives the notification-settings page: loads the server settings and applies -/// per-channel changes. +/// per-channel changes and presets. library; import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_preset_applier.dart'; import 'package:dpip/features/notification/domain/notify_repository.dart'; import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:flutter/foundation.dart'; @@ -26,16 +28,28 @@ enum NotifyLoadStatus { /// each save round-trips to the server and adopts the echoed settings, so the /// UI can never drift from the backend. A failed save keeps the previous value /// and reports `false` so the page can surface it. +/// +/// A preset goes through the very same repository call, once per channel that +/// differs ([NotifyPresetApplier]); each echo is adopted as it arrives, so the +/// rows below the preset fill in one by one and a failure part-way shows +/// exactly what the server ended up holding. class NotifyController extends ChangeNotifier { - NotifyController(this._repository, this._token); + NotifyController( + NotifyRepository repository, + this._token, { + Duration presetSpacing = NotifyPresetApplier.requestSpacing, + }) : _repository = repository, + _presets = NotifyPresetApplier(repository, spacing: presetSpacing); final NotifyRepository _repository; + final NotifyPresetApplier _presets; final String? _token; NotifyLoadStatus _status = NotifyLoadStatus.loading; NotifySettings? _settings; Failure? _failure; NotifyChannel? _saving; + NotifyPreset? _applying; bool _disposed = false; @override @@ -56,6 +70,20 @@ class NotifyController extends ChangeNotifier { /// The channel with a save in flight, if any (for a per-row spinner). NotifyChannel? get saving => _saving; + /// The preset being applied, if any (for the preset row's spinner). + NotifyPreset? get applying => _applying; + + /// Whether any write is in flight — every row is locked meanwhile, so two + /// writes never race for the same token's rate limit. + bool get busy => _saving != null || _applying != null; + + /// The preset the loaded settings are exactly — [NotifyPreset.custom] when + /// they are none of them — or null until loaded. + NotifyPreset? get preset { + final settings = _settings; + return settings == null ? null : NotifyPreset.matching(settings); + } + /// Whether a push token exists — without one there is nothing to target. bool get hasToken => _token != null && _token.isNotEmpty; @@ -90,7 +118,7 @@ class NotifyController extends ChangeNotifier { /// value intact. Future setChannel(NotifyChannel channel, int optionIndex) async { final current = _settings; - if (!hasToken || current == null) return false; + if (!hasToken || current == null || busy) return false; if (current.optionOf(channel) == optionIndex) return true; _saving = channel; @@ -110,4 +138,38 @@ class NotifyController extends ChangeNotifier { }, ); } + + /// Applies [preset] — every channel it covers, one request at a time. + /// Returns whether all of it was saved (already matching counts as success). + /// + /// A failure keeps whatever the server had accepted before it, so the rows + /// and the preset row both show the true state, which is usually + /// [NotifyPreset.custom]; calling this again resumes from there. + /// [NotifyPreset.custom] has nothing to apply and reports false. + Future applyPreset(NotifyPreset preset) async { + final current = _settings; + if (!hasToken || current == null || busy) return false; + if (preset == NotifyPreset.custom) return false; + if (NotifyPreset.matching(current) == preset) return true; + + _applying = preset; + notifyListeners(); + final result = await _presets.apply( + _token!, + preset, + current: current, + onProgress: (settings) { + if (_disposed) return; + _settings = settings; + notifyListeners(); + }, + // Page popped mid-apply: send nothing further. What was written stays, + // and reopening the page shows it as it is. + isCancelled: () => _disposed, + ); + if (_disposed) return false; + _applying = null; + notifyListeners(); + return result.isOk; + } } diff --git a/lib/features/notification/presentation/notify_labels.dart b/lib/features/notification/presentation/notify_labels.dart index 33522edb9..9cdd3d7ab 100644 --- a/lib/features/notification/presentation/notify_labels.dart +++ b/lib/features/notification/presentation/notify_labels.dart @@ -1,7 +1,9 @@ -/// Localized titles, icons, and option labels for the notification channels — -/// the presentation-only mapping the settings page and its option sheet share. +/// Localized titles, icons, and option labels for the notification channels +/// and presets — the presentation-only mapping the settings page and its +/// sheets share. library; +import 'package:dpip/features/notification/domain/notify_preset.dart'; import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; @@ -58,3 +60,31 @@ IconData notifyOptionIcon(NotifyOptionKind kind) => switch (kind) { NotifyOptionKind.tsunamiWarning => Icons.notification_important_outlined, NotifyOptionKind.tsunamiAll => Icons.notifications_active_outlined, }; + +/// The preset's display name. +String notifyPresetLabel(NotifyPreset preset, AppLocalizations l10n) => + switch (preset) { + NotifyPreset.recommended => l10n.notifyPresetRecommended, + NotifyPreset.all => l10n.notifyPresetAll, + NotifyPreset.emergencyOnly => l10n.notifyPresetEmergencyOnly, + NotifyPreset.custom => l10n.notifyPresetCustom, + }; + +/// One sentence on what the preset turns on — and, where it matters, off. +String notifyPresetDescription(NotifyPreset preset, AppLocalizations l10n) => + switch (preset) { + NotifyPreset.recommended => l10n.notifyPresetRecommendedDescription, + NotifyPreset.all => l10n.notifyPresetAllDescription, + NotifyPreset.emergencyOnly => l10n.notifyPresetEmergencyOnlyDescription, + NotifyPreset.custom => l10n.notifyPresetCustomDescription, + }; + +/// The preset's leading icon — the same "how much" cue as +/// [notifyOptionIcon], so "everything" and "only the urgent" read alike in +/// both pickers. +IconData notifyPresetIcon(NotifyPreset preset) => switch (preset) { + NotifyPreset.recommended => Icons.recommend_outlined, + NotifyPreset.all => Icons.notifications_active_outlined, + NotifyPreset.emergencyOnly => Icons.notification_important_outlined, + NotifyPreset.custom => Icons.edit_notifications_outlined, +}; diff --git a/lib/features/notification/presentation/pages/notify_page.dart b/lib/features/notification/presentation/pages/notify_page.dart index b04084762..b62192e85 100644 --- a/lib/features/notification/presentation/pages/notify_page.dart +++ b/lib/features/notification/presentation/pages/notify_page.dart @@ -1,8 +1,10 @@ -/// The notification-settings page: per-channel push filters, grouped by domain. +/// The notification-settings page: a preset picker, then the per-channel push +/// filters grouped by domain. library; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; import 'package:dpip/features/notification/domain/notify_repository.dart'; import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:dpip/features/notification/presentation/notify_controller.dart'; @@ -75,7 +77,8 @@ class _NotifyView extends StatelessWidget { } } -/// The grouped list of channels, one section per domain. +/// The preset picker, then the grouped list of channels, one section per +/// domain. class _NotifyList extends StatelessWidget { const _NotifyList({required this.controller}); @@ -115,14 +118,15 @@ class _NotifyList extends StatelessWidget { return ListView( padding: const EdgeInsets.only(bottom: AppSpacing.xl), children: [ - for (final (i, section) in sections.indexed) ...[ - // Between categories, not above the first or below the last. - if (i > 0) - const Divider( - height: 1, - indent: AppSpacing.lg, - endIndent: AppSpacing.lg, - ), + SectionHeader(l10n.notifyPresetSection), + _PresetSection(controller: controller), + for (final section in sections) ...[ + // Between categories — the preset picker above counts as one. + const Divider( + height: 1, + indent: AppSpacing.lg, + endIndent: AppSpacing.lg, + ), SectionHeader(section.title), for (final channel in section.channels) _ChannelTile(controller: controller, channel: channel), @@ -132,6 +136,96 @@ class _NotifyList extends StatelessWidget { } } +/// The presets as one radio group: the preset the settings currently are is +/// checked — 自訂 when they are none of them — and choosing another applies it +/// through [NotifyController.applyPreset]. +/// +/// While one is being applied it is the checked one, with a spinner in place +/// of its icon, and the rows below fill in as the server accepts each channel; +/// if it fails part-way, the check falls back to whatever the server ended up +/// holding (usually 自訂) and the same snackbar as a failed channel save says +/// so. Choosing it again resumes. +/// +/// 自訂 is a state, not an action — there is nothing to apply — so it is only +/// enabled while it is the checked one; otherwise a screen reader would offer +/// a radio button that does nothing. It is reached by changing a channel. +class _PresetSection extends StatelessWidget { + const _PresetSection({required this.controller}); + + final NotifyController controller; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final checked = controller.applying ?? controller.preset; + + return RadioGroup( + groupValue: checked, + onChanged: (preset) { + if (preset != null && preset != checked) _choose(context, preset); + }, + child: Column( + children: [ + for (final preset in NotifyPreset.values) + RadioListTile( + value: preset, + enabled: + !controller.busy && + (preset != NotifyPreset.custom || checked == preset), + controlAffinity: ListTileControlAffinity.trailing, + secondary: controller.applying == preset + ? Semantics( + label: l10n.notifyPresetApplying, + child: const InlineLoading(), + ) + : Icon(notifyPresetIcon(preset)), + title: Text(notifyPresetLabel(preset, l10n)), + subtitle: Text(notifyPresetDescription(preset, l10n)), + ), + ], + ), + ); + } + + /// Applies [preset] — after a confirmation when it would replace a + /// hand-tuned combination, since that one cannot be brought back by tapping + /// another preset. + Future _choose(BuildContext context, NotifyPreset preset) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + + if (controller.preset == NotifyPreset.custom) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + l10n.notifyPresetConfirmTitle(notifyPresetLabel(preset, l10n)), + ), + content: Text(l10n.notifyPresetConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.commonCancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.notifyPresetApply), + ), + ], + ), + ); + if (confirmed != true) return; + } + + final ok = await controller.applyPreset(preset); + if (!ok && context.mounted) { + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.notifySetFailed))); + } + } +} + /// One channel row — its title, current selection, and (while a save is in /// flight) a spinner. Tapping opens the option sheet. class _ChannelTile extends StatelessWidget { @@ -145,7 +239,9 @@ class _ChannelTile extends StatelessWidget { final l10n = AppLocalizations.of(context); final settings = controller.settings!; final saving = controller.saving == channel; - final busy = controller.saving != null; + // Locked during a preset too: its writes share this token's rate limit, + // and a row changed mid-preset would be overwritten a moment later. + final busy = controller.busy; return ListTile( leading: Icon(notifyChannelIcon(channel)), diff --git a/lib/features/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 26ae8bb75..5d1044998 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -1,10 +1,12 @@ -/// First-launch onboarding: intro → terms → permissions, shown until completed. +/// First-launch onboarding: intro → terms → permissions → notification preset, +/// shown until completed. library; import 'package:dpip/app/theme/app_motion.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/features/onboarding/presentation/widgets/onboarding_intro.dart'; +import 'package:dpip/features/onboarding/presentation/widgets/onboarding_notify_presets.dart'; import 'package:dpip/features/onboarding/presentation/widgets/onboarding_permissions.dart'; import 'package:dpip/features/onboarding/presentation/widgets/onboarding_terms.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; @@ -13,7 +15,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -/// Hosts the three onboarding steps in a swipe-locked pager (navigation is via +/// Hosts the four onboarding steps in a swipe-locked pager (navigation is via /// each step's action), with progress dots and a back affordance. Finishing /// marks onboarding complete, and the router redirect then lets Home through. class OnboardingPage extends StatefulWidget { @@ -24,7 +26,7 @@ class OnboardingPage extends StatefulWidget { } class _OnboardingPageState extends State { - static const int _pageCount = 3; + static const int _pageCount = 4; final PageController _controller = PageController(); int _index = 0; @@ -73,7 +75,8 @@ class _OnboardingPageState extends State { children: [ OnboardingIntroPage(onNext: () => _go(1)), OnboardingTermsPage(onAccept: () => _go(2)), - OnboardingPermissionsPage(onFinish: _finish), + OnboardingPermissionsPage(onFinish: () => _go(3)), + OnboardingNotifyPresetsPage(onFinish: _finish), ], ), ), diff --git a/lib/features/onboarding/presentation/widgets/onboarding_notify_presets.dart b/lib/features/onboarding/presentation/widgets/onboarding_notify_presets.dart new file mode 100644 index 000000000..81dfe98ee --- /dev/null +++ b/lib/features/onboarding/presentation/widgets/onboarding_notify_presets.dart @@ -0,0 +1,279 @@ +/// The last onboarding step: choose how much to be alerted about — one of the +/// notification presets — instead of nine separate channel decisions. +/// +/// It comes after the permissions step on purpose. The choice is written to +/// the server under this device's push token, and a backend that has never +/// seen the token answers 401 until a position has been registered for it; +/// the repository's recovery does that, with the location grant the step +/// before asks for. Asked earlier, the first write on a fresh install would +/// fail for want of a permission the user had not been offered yet. +/// +/// Applying is never a condition of finishing: onboarding must not hold the +/// app hostage to a network call. A failure says so, offers a retry — which +/// resumes, since only the channels still differing are sent — and a way +/// through. Leaving that way keeps whatever the server already holds, and +/// every preset and the server's own defaults keep EEW on, so no path out of +/// this page silences earthquake warnings. +/// +/// Only first launches see it: `OnboardingStore` gates the whole flow, so an +/// upgrade never rewrites settings someone already chose. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_preset_applier.dart'; +import 'package:dpip/features/notification/domain/notify_repository.dart'; +import 'package:dpip/features/onboarding/presentation/widgets/onboarding_scaffold.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class OnboardingNotifyPresetsPage extends StatefulWidget { + const OnboardingNotifyPresetsPage({ + super.key, + required this.onFinish, + this.requestSpacing = NotifyPresetApplier.requestSpacing, + }); + + /// Called once the preset is saved, or when the user leaves without it. + final VoidCallback onFinish; + + /// The gap between two notify requests (see [NotifyPresetApplier]). + final Duration requestSpacing; + + @override + State createState() => + _OnboardingNotifyPresetsPageState(); +} + +/// What the step is doing, for the bottom action and the message above it. +enum _Phase { choosing, applying, failed } + +class _OnboardingNotifyPresetsPageState + extends State { + /// Preselected: the combination meant for someone who has not thought about + /// it yet, which is everyone on this screen. + NotifyPreset _choice = NotifyPreset.recommended; + _Phase _phase = _Phase.choosing; + + /// Why the last attempt failed — shown, and announced, above the retry. + String? _error; + + Future _apply() async { + final l10n = AppLocalizations.of(context); + final token = context.read().token; + // No token yet is not permanent — iOS hands it over a moment after the + // notification grant — so it is a failure with a retry, not a dead end. + if (token == null || token.isEmpty) { + setState(() { + _phase = _Phase.failed; + _error = l10n.notifyUnavailable; + }); + return; + } + + setState(() { + _phase = _Phase.applying; + _error = null; + }); + final applier = NotifyPresetApplier( + context.read(), + spacing: widget.requestSpacing, + ); + final result = await applier.apply( + token, + _choice, + isCancelled: () => !mounted, + ); + if (!mounted) return; + if (result.isOk) { + widget.onFinish(); + return; + } + setState(() { + _phase = _Phase.failed; + _error = l10n.notifySetFailed; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final applying = _phase == _Phase.applying; + + // The settings page's labels live in the notification feature's + // presentation layer, which this feature may not import; the strings are + // the same keys, so the wording still has one home. + final options = [ + ( + preset: NotifyPreset.recommended, + title: l10n.notifyPresetRecommended, + description: l10n.notifyPresetRecommendedDescription, + icon: Icons.recommend_outlined, + ), + ( + preset: NotifyPreset.all, + title: l10n.notifyPresetAll, + description: l10n.notifyPresetAllDescription, + icon: Icons.notifications_active_outlined, + ), + ( + preset: NotifyPreset.emergencyOnly, + title: l10n.notifyPresetEmergencyOnly, + description: l10n.notifyPresetEmergencyOnlyDescription, + icon: Icons.notification_important_outlined, + ), + ]; + + return OnboardingScaffold( + requireScrollToEnd: false, + actionBuilder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_error != null) ...[ + Semantics( + liveRegion: true, + child: Text( + _error!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + OnboardingCta( + label: switch (_phase) { + _Phase.choosing => l10n.onboardingStart, + _Phase.applying => l10n.notifyPresetApplying, + _Phase.failed => l10n.commonRetry, + }, + onPressed: applying ? null : _apply, + ), + if (_phase == _Phase.failed) + TextButton( + onPressed: widget.onFinish, + child: Text(l10n.onboardingNotifySkip), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.onboardingNotifyTitle, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.onboardingNotifyBody, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + RadioGroup( + groupValue: _choice, + onChanged: (preset) { + if (preset == null || applying) return; + setState(() { + _choice = preset; + // A new choice is a fresh attempt, not a retry of the old one. + _phase = _Phase.choosing; + _error = null; + }); + }, + child: Column( + children: [ + for (final option in options) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: _PresetCard( + preset: option.preset, + title: option.title, + description: option.description, + icon: option.icon, + selected: option.preset == _choice, + enabled: !applying, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// One preset as a radio card: the same surface and corner as the permission +/// rows on the step before, so the two steps read as one flow, with the +/// chosen card outlined in primary rather than marked by the radio alone. +/// +/// A [RadioListTile] underneath, so a screen reader hears a radio button in a +/// group of three, checked or not, and the whole card is the touch target. +class _PresetCard extends StatelessWidget { + const _PresetCard({ + required this.preset, + required this.title, + required this.description, + required this.icon, + required this.selected, + required this.enabled, + }); + + final NotifyPreset preset; + final String title; + final String description; + final IconData icon; + final bool selected; + final bool enabled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.medium, + side: BorderSide( + color: selected ? colors.primary : colors.outlineVariant, + width: selected ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: RadioListTile( + value: preset, + enabled: enabled, + controlAffinity: ListTileControlAffinity.trailing, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + secondary: Icon( + icon, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + title: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/widgets/onboarding_permissions.dart b/lib/features/onboarding/presentation/widgets/onboarding_permissions.dart index fbb6212db..36bc658e7 100644 --- a/lib/features/onboarding/presentation/widgets/onboarding_permissions.dart +++ b/lib/features/onboarding/presentation/widgets/onboarding_permissions.dart @@ -29,7 +29,7 @@ class _OnboardingPermissionsPageState extends State { /// The last state the checklist reported — what [_finish] reads. PermissionState? _state; - /// Finishes onboarding — but if a permission that makes localized alerts work + /// Leaves this step — but if a permission that makes localized alerts work /// (notifications or location) is still missing, confirm first. We *can't* /// require them (App Store 5.1.2(i) / 4.5.4 forbid gating the app on a /// permission), so this is a strong nudge, never a block; a persistent in-app @@ -68,7 +68,9 @@ class _OnboardingPermissionsPageState extends State { return OnboardingScaffold( requireScrollToEnd: false, actionBuilder: (context, _) => - OnboardingCta(label: l10n.onboardingStart, onPressed: _finish), + // Not the last step: the notification preset follows, and needs + // these grants in place before it writes anything. + OnboardingCta(label: l10n.onboardingNext, onPressed: _finish), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 08c603562..8a0d24232 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4260,5 +4260,75 @@ "eewFeedDown": "Earthquake alerts disconnected — reconnecting…", "@eewFeedDown": { "description": "Shown on Home and in the seismic monitor's status strip while the earthquake early warning feed is disconnected or not updating, so an empty alert area is not read as all-clear" + }, + "notifyPresetSection": "Presets", + "@notifyPresetSection": { + "description": "Notification settings: header above the preset choices" + }, + "notifyPresetRecommended": "Recommended", + "@notifyPresetRecommended": { + "description": "Notification preset name: the recommended combination" + }, + "notifyPresetRecommendedDescription": "Earthquake, weather and hazard alerts for your area, plus tsunami news and announcements — without the earthquake report that repeats the intensity report", + "@notifyPresetRecommendedDescription": { + "description": "What the recommended preset turns on (and the one thing it leaves out)" + }, + "notifyPresetAll": "All", + "@notifyPresetAll": { + "description": "Notification preset name: every channel at its broadest option" + }, + "notifyPresetAllDescription": "Everything on every channel, including earthquakes anywhere in Taiwan", + "@notifyPresetAllDescription": { + "description": "What the All preset turns on" + }, + "notifyPresetEmergencyOnly": "Emergency only", + "@notifyPresetEmergencyOnly": { + "description": "Notification preset name: only life-safety alerts" + }, + "notifyPresetEmergencyOnlyDescription": "Only when safety is at stake: earthquake alerts for intensity 4+ where you are, weather and hazard warnings for your area, and tsunami warnings", + "@notifyPresetEmergencyOnlyDescription": { + "description": "What the Emergency-only preset keeps" + }, + "notifyPresetCustom": "Custom", + "@notifyPresetCustom": { + "description": "Notification preset name: the current settings match no preset" + }, + "notifyPresetCustomDescription": "Set each channel individually below", + "@notifyPresetCustomDescription": { + "description": "Explains that Custom means the per-channel choices below" + }, + "notifyPresetConfirmTitle": "Apply “{preset}”?", + "@notifyPresetConfirmTitle": { + "description": "Confirmation title before a preset replaces custom per-channel settings", + "placeholders": { + "preset": { + "type": "String", + "example": "Recommended" + } + } + }, + "notifyPresetConfirmBody": "Every channel below will change to match it, replacing the settings you picked one by one.", + "@notifyPresetConfirmBody": { + "description": "Confirmation body: applying the preset overwrites the per-channel choices" + }, + "notifyPresetApply": "Apply", + "@notifyPresetApply": { + "description": "Confirmation button that applies a notification preset" + }, + "notifyPresetApplying": "Applying…", + "@notifyPresetApplying": { + "description": "Shown (and announced) while a notification preset is being saved" + }, + "onboardingNotifyTitle": "Your alerts", + "@onboardingNotifyTitle": { + "description": "Onboarding step title: choosing a notification preset" + }, + "onboardingNotifyBody": "Pick a starting point. You can fine-tune each channel in Notification settings at any time, and every option keeps earthquake early warnings on.", + "@onboardingNotifyBody": { + "description": "Onboarding step body: the choice is a starting point, adjustable later; EEW stays on in every option" + }, + "onboardingNotifySkip": "Skip for now", + "@onboardingNotifySkip": { + "description": "Onboarding: finish without applying a notification preset after a failed attempt" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 3ca76d183..2379e4815 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "Pinakabagong lokasyon", "eewCancelled": "Kinansela", "eewCancelledNotice": "Kinansela ng naglabas na ahensya ang alertong ito", - "eewFeedDown": "Naputol ang koneksyon sa babala sa lindol — kumokonekta muli…" + "eewFeedDown": "Naputol ang koneksyon sa babala sa lindol — kumokonekta muli…", + "notifyPresetSection": "Mabilisang setting", + "notifyPresetRecommended": "Inirerekomenda", + "notifyPresetRecommendedDescription": "Mga alerto sa lindol, panahon, at sakuna sa iyong lugar, kasama ang impormasyon sa tsunami at mga anunsyo — maliban sa ulat ng lindol na umuulit sa ulat ng intensidad", + "notifyPresetAll": "Lahat", + "notifyPresetAllDescription": "Lahat sa bawat channel, kasama ang mga lindol saanman sa Taiwan", + "notifyPresetEmergencyOnly": "Emergency lamang", + "notifyPresetEmergencyOnlyDescription": "Kapag may panganib lamang: mga alerto sa lindol na intensidad 4 pataas sa iyong lokasyon, mga babala sa panahon at sakuna sa iyong lugar, at mga babala sa tsunami", + "notifyPresetCustom": "Custom", + "notifyPresetCustomDescription": "Itakda ang bawat channel sa ibaba", + "notifyPresetConfirmTitle": "Ilapat ang “{preset}”?", + "notifyPresetConfirmBody": "Magbabago ang bawat channel sa ibaba para tumugma rito, at papalitan ang mga setting na isa-isa mong pinili.", + "notifyPresetApply": "Ilapat", + "notifyPresetApplying": "Inilalapat…", + "onboardingNotifyTitle": "Iyong mga alerto", + "onboardingNotifyBody": "Pumili ng panimulang setting. Maaari mong ayusin ang bawat channel sa Mga setting ng notipikasyon anumang oras, at bawat opsyon ay nananatiling may emergency na alerto sa lindol.", + "onboardingNotifySkip": "Laktawan muna" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 2f3b7022e..2a4e59f5f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "Posisi terakhir", "eewCancelled": "Dibatalkan", "eewCancelledNotice": "Lembaga penerbit telah membatalkan peringatan ini", - "eewFeedDown": "Koneksi peringatan gempa terputus — menyambungkan ulang…" + "eewFeedDown": "Koneksi peringatan gempa terputus — menyambungkan ulang…", + "notifyPresetSection": "Setelan cepat", + "notifyPresetRecommended": "Disarankan", + "notifyPresetRecommendedDescription": "Peringatan gempa, cuaca, dan bencana di area Anda, ditambah informasi tsunami dan pengumuman — tanpa laporan gempa yang mengulang laporan intensitas", + "notifyPresetAll": "Semua", + "notifyPresetAllDescription": "Semua hal di setiap saluran, termasuk gempa di mana pun di Taiwan", + "notifyPresetEmergencyOnly": "Darurat saja", + "notifyPresetEmergencyOnlyDescription": "Hanya saat menyangkut keselamatan: peringatan gempa intensitas 4 atau lebih di lokasi Anda, peringatan cuaca dan bencana di area Anda, serta peringatan tsunami", + "notifyPresetCustom": "Kustom", + "notifyPresetCustomDescription": "Atur setiap saluran satu per satu di bawah", + "notifyPresetConfirmTitle": "Terapkan “{preset}”?", + "notifyPresetConfirmBody": "Setiap saluran di bawah akan diubah mengikutinya, menggantikan pengaturan yang Anda pilih satu per satu.", + "notifyPresetApply": "Terapkan", + "notifyPresetApplying": "Menerapkan…", + "onboardingNotifyTitle": "Peringatan Anda", + "onboardingNotifyBody": "Pilih titik awal. Anda dapat menyesuaikan setiap saluran di Pengaturan notifikasi kapan saja, dan setiap pilihan tetap menyalakan peringatan gempa darurat.", + "onboardingNotifySkip": "Lewati dulu" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 31b7179b8..0b3e7dc64 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "最新の位置", "eewCancelled": "取り消し", "eewCancelledNotice": "発表元がこの速報を取り消しました", - "eewFeedDown": "緊急地震速報の接続が切れています。再接続中…" + "eewFeedDown": "緊急地震速報の接続が切れています。再接続中…", + "notifyPresetSection": "プリセット", + "notifyPresetRecommended": "おすすめ", + "notifyPresetRecommendedDescription": "所在地の地震・気象・防災の警報に加え、津波情報とお知らせ。震度速報と重複する地震報告は除きます", + "notifyPresetAll": "すべて", + "notifyPresetAllDescription": "すべてのチャンネルですべて受信。台湾全域の地震も含みます", + "notifyPresetEmergencyOnly": "緊急のみ", + "notifyPresetEmergencyOnlyDescription": "安全に関わるときだけ通知:所在地の震度4以上の緊急地震速報、所在地の気象・防災の警報、津波警報", + "notifyPresetCustom": "カスタム", + "notifyPresetCustomDescription": "下で各チャンネルを個別に設定", + "notifyPresetConfirmTitle": "「{preset}」を適用しますか?", + "notifyPresetConfirmBody": "下の各チャンネルがこの組み合わせに変わり、個別に選んだ設定は置き換えられます。", + "notifyPresetApply": "適用", + "notifyPresetApplying": "適用しています…", + "onboardingNotifyTitle": "通知の受け取り方", + "onboardingNotifyBody": "まずは出発点を選んでください。各チャンネルは「通知設定」でいつでも個別に調整でき、どの選択肢でも緊急地震速報は受け取れます。", + "onboardingNotifySkip": "あとで設定する" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9fd6f42ca..a5413a28d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "마지막 위치", "eewCancelled": "취소됨", "eewCancelledNotice": "발표 기관이 이 경보를 취소했습니다", - "eewFeedDown": "지진 속보 연결이 끊겼습니다. 다시 연결하는 중…" + "eewFeedDown": "지진 속보 연결이 끊겼습니다. 다시 연결하는 중…", + "notifyPresetSection": "빠른 설정", + "notifyPresetRecommended": "권장", + "notifyPresetRecommendedDescription": "현재 위치의 지진·기상·재난 알림과 지진해일 정보, 공지사항. 진도 속보와 겹치는 지진 보고는 제외합니다", + "notifyPresetAll": "전체", + "notifyPresetAllDescription": "모든 채널을 전부 수신합니다. 대만 전역의 지진도 포함됩니다", + "notifyPresetEmergencyOnly": "긴급만", + "notifyPresetEmergencyOnlyDescription": "안전과 관련될 때만 알립니다: 현재 위치 진도 4 이상의 지진 경보, 현재 위치의 기상·재난 경보, 지진해일 경보", + "notifyPresetCustom": "사용자 지정", + "notifyPresetCustomDescription": "아래에서 채널별로 설정", + "notifyPresetConfirmTitle": "‘{preset}’을(를) 적용할까요?", + "notifyPresetConfirmBody": "아래의 모든 채널이 이 조합으로 바뀌며, 채널별로 고른 설정은 대체됩니다.", + "notifyPresetApply": "적용", + "notifyPresetApplying": "적용하는 중…", + "onboardingNotifyTitle": "알림 받기", + "onboardingNotifyBody": "먼저 시작점을 골라 주세요. 각 채널은 언제든 '알림 설정'에서 조정할 수 있으며, 어떤 옵션이든 긴급 지진 경보는 계속 받습니다.", + "onboardingNotifySkip": "나중에 설정" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 008abed30..b22b11a28 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "ตำแหน่งล่าสุด", "eewCancelled": "ยกเลิกแล้ว", "eewCancelledNotice": "หน่วยงานผู้ออกประกาศได้ยกเลิกการแจ้งเตือนนี้แล้ว", - "eewFeedDown": "การเชื่อมต่อการแจ้งเตือนแผ่นดินไหวขาดหาย กำลังเชื่อมต่อใหม่…" + "eewFeedDown": "การเชื่อมต่อการแจ้งเตือนแผ่นดินไหวขาดหาย กำลังเชื่อมต่อใหม่…", + "notifyPresetSection": "การตั้งค่าด่วน", + "notifyPresetRecommended": "แนะนำ", + "notifyPresetRecommendedDescription": "การแจ้งเตือนแผ่นดินไหว สภาพอากาศ และภัยพิบัติในพื้นที่ของคุณ พร้อมข้อมูลสึนามิและประกาศ โดยไม่รวมรายงานแผ่นดินไหวที่ซ้ำกับรายงานความรุนแรง", + "notifyPresetAll": "ทั้งหมด", + "notifyPresetAllDescription": "รับทุกอย่างในทุกช่อง รวมถึงแผ่นดินไหวทั่วไต้หวัน", + "notifyPresetEmergencyOnly": "เฉพาะเหตุฉุกเฉิน", + "notifyPresetEmergencyOnlyDescription": "แจ้งเตือนเฉพาะเมื่อเกี่ยวกับความปลอดภัย: การเตือนแผ่นดินไหวที่ความรุนแรงในพื้นที่ระดับ 4 ขึ้นไป การเตือนสภาพอากาศและภัยพิบัติในพื้นที่ของคุณ และการเตือนภัยสึนามิ", + "notifyPresetCustom": "กำหนดเอง", + "notifyPresetCustomDescription": "ตั้งค่าแต่ละช่องด้านล่าง", + "notifyPresetConfirmTitle": "ใช้ “{preset}” หรือไม่", + "notifyPresetConfirmBody": "ทุกช่องด้านล่างจะเปลี่ยนตามชุดนี้ แทนที่การตั้งค่าที่คุณเลือกไว้ทีละช่อง", + "notifyPresetApply": "ใช้", + "notifyPresetApplying": "กำลังใช้…", + "onboardingNotifyTitle": "การแจ้งเตือนของคุณ", + "onboardingNotifyBody": "เลือกจุดเริ่มต้นก่อน คุณปรับแต่ละช่องได้ทุกเมื่อใน “การตั้งค่าการแจ้งเตือน” และทุกตัวเลือกยังคงรับการเตือนแผ่นดินไหวฉุกเฉิน", + "onboardingNotifySkip": "ข้ามไปก่อน" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 82f31f2cf..0fad033e1 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "Vị trí mới nhất", "eewCancelled": "Đã hủy", "eewCancelledNotice": "Cơ quan phát hành đã hủy cảnh báo này", - "eewFeedDown": "Mất kết nối cảnh báo động đất — đang kết nối lại…" + "eewFeedDown": "Mất kết nối cảnh báo động đất — đang kết nối lại…", + "notifyPresetSection": "Thiết lập nhanh", + "notifyPresetRecommended": "Đề xuất", + "notifyPresetRecommendedDescription": "Cảnh báo động đất, thời tiết và thảm họa tại khu vực của bạn, kèm thông tin sóng thần và thông báo — bỏ báo cáo động đất trùng với báo cáo cường độ", + "notifyPresetAll": "Tất cả", + "notifyPresetAllDescription": "Nhận mọi thứ trên mọi kênh, kể cả động đất ở bất kỳ đâu tại Đài Loan", + "notifyPresetEmergencyOnly": "Chỉ khẩn cấp", + "notifyPresetEmergencyOnlyDescription": "Chỉ thông báo khi liên quan đến an toàn: cảnh báo động đất cường độ 4 trở lên tại chỗ, cảnh báo thời tiết và thảm họa tại khu vực của bạn, và cảnh báo sóng thần", + "notifyPresetCustom": "Tùy chỉnh", + "notifyPresetCustomDescription": "Đặt từng kênh riêng ở bên dưới", + "notifyPresetConfirmTitle": "Áp dụng “{preset}”?", + "notifyPresetConfirmBody": "Mọi kênh bên dưới sẽ đổi theo thiết lập này, thay thế các cài đặt bạn đã chọn từng mục.", + "notifyPresetApply": "Áp dụng", + "notifyPresetApplying": "Đang áp dụng…", + "onboardingNotifyTitle": "Cảnh báo của bạn", + "onboardingNotifyBody": "Hãy chọn một điểm bắt đầu. Bạn có thể tinh chỉnh từng kênh trong Cài đặt thông báo bất cứ lúc nào, và mọi lựa chọn đều giữ cảnh báo động đất khẩn cấp.", + "onboardingNotifySkip": "Để sau" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index ab695873b..f1fdd6cdd 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", "eewCancelledNotice": "發布單位已取消呢個速報", - "eewFeedDown": "地震速報連接中斷,重新連接緊…" + "eewFeedDown": "地震速報連接中斷,重新連接緊…", + "notifyPresetSection": "快速設定", + "notifyPresetRecommended": "建議", + "notifyPresetRecommendedDescription": "所在地嘅地震、天氣同防災警訊,加埋海嘯消息同公告;唔包同震度速報重複嘅地震報告", + "notifyPresetAll": "全部", + "notifyPresetAllDescription": "每個頻道都收晒全部,包括全台各地嘅地震", + "notifyPresetEmergencyOnly": "只限緊急", + "notifyPresetEmergencyOnlyDescription": "淨係關乎安全先通知你:所在地震度 4 或以上嘅地震速報、所在地嘅天氣同防災警訊,同埋海嘯警報", + "notifyPresetCustom": "自訂", + "notifyPresetCustomDescription": "喺下面逐項調整各個頻道", + "notifyPresetConfirmTitle": "套用「{preset}」?", + "notifyPresetConfirmBody": "下面每個頻道都會改成呢個組合,取代你逐項調整過嘅設定。", + "notifyPresetApply": "套用", + "notifyPresetApplying": "套用緊…", + "onboardingNotifyTitle": "通知偏好", + "onboardingNotifyBody": "揀一個起點先,之後隨時可以喺「通知設定」逐項調整。每個選項都會保留地震速報。", + "onboardingNotifySkip": "跳過,遲啲先設定" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9c55d0581..89f1c8976 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2085,5 +2085,21 @@ "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", "eewCancelledNotice": "發布單位已取消此速報", - "eewFeedDown": "地震速報連線中斷,重新連線中…" + "eewFeedDown": "地震速報連線中斷,重新連線中…", + "notifyPresetSection": "快速設定", + "notifyPresetRecommended": "建議", + "notifyPresetRecommendedDescription": "所在地的地震、天氣與防災警訊,以及海嘯消息與公告;略過與震度速報重複的地震報告", + "notifyPresetAll": "全部", + "notifyPresetAllDescription": "每個頻道都接收全部,包含全台各地的地震", + "notifyPresetEmergencyOnly": "僅緊急", + "notifyPresetEmergencyOnlyDescription": "只在攸關安全時通知:所在地震度 4 以上的地震速報、所在地的天氣與防災警訊,以及海嘯警報", + "notifyPresetCustom": "自訂", + "notifyPresetCustomDescription": "在下方逐項調整各頻道", + "notifyPresetConfirmTitle": "套用「{preset}」?", + "notifyPresetConfirmBody": "下方每個頻道都會改成這個組合,取代你逐項調整的設定。", + "notifyPresetApply": "套用", + "notifyPresetApplying": "正在套用…", + "onboardingNotifyTitle": "通知偏好", + "onboardingNotifyBody": "先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。", + "onboardingNotifySkip": "略過,之後再設定" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 4f86b9fd0..fdbf8405d 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", "eewCancelledNotice": "发布单位已取消此速报", - "eewFeedDown": "地震速报连接中断,正在重新连接…" + "eewFeedDown": "地震速报连接中断,正在重新连接…", + "notifyPresetSection": "快速设置", + "notifyPresetRecommended": "推荐", + "notifyPresetRecommendedDescription": "本地的地震、天气与防灾警讯,以及海啸消息与公告;略过与震度速报重复的地震报告", + "notifyPresetAll": "全部", + "notifyPresetAllDescription": "每个频道都接收全部,包括全台各地的地震", + "notifyPresetEmergencyOnly": "仅紧急", + "notifyPresetEmergencyOnlyDescription": "只在关乎安全时通知:本地震度 4 以上的地震预警、本地的天气与防灾警讯,以及海啸警报", + "notifyPresetCustom": "自定义", + "notifyPresetCustomDescription": "在下方逐项调整各频道", + "notifyPresetConfirmTitle": "应用“{preset}”?", + "notifyPresetConfirmBody": "下方每个频道都会改成这个组合,取代你逐项调整的设置。", + "notifyPresetApply": "应用", + "notifyPresetApplying": "正在应用…", + "onboardingNotifyTitle": "通知偏好", + "onboardingNotifyBody": "先选一个起点,之后随时可以在“通知设置”中逐项调整。每个选项都会保留地震预警。", + "onboardingNotifySkip": "跳过,稍后再设置" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 7cb631dfd..0224dea6d 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", "eewCancelledNotice": "發布單位已取消此速報", - "eewFeedDown": "地震速報連接中斷,重新連接中…" + "eewFeedDown": "地震速報連接中斷,重新連接中…", + "notifyPresetSection": "快速設定", + "notifyPresetRecommended": "建議", + "notifyPresetRecommendedDescription": "所在地的地震、天氣及防災警訊,以及海嘯消息及公告;略過與震度速報重複的地震報告", + "notifyPresetAll": "全部", + "notifyPresetAllDescription": "每個頻道均接收全部,包括全台各地的地震", + "notifyPresetEmergencyOnly": "僅緊急", + "notifyPresetEmergencyOnlyDescription": "只在攸關安全時通知:所在地震度 4 或以上的地震速報、所在地的天氣及防災警訊,以及海嘯警報", + "notifyPresetCustom": "自訂", + "notifyPresetCustomDescription": "在下方逐項調整各頻道", + "notifyPresetConfirmTitle": "套用「{preset}」?", + "notifyPresetConfirmBody": "下方每個頻道都會改為這個組合,取代你逐項調整的設定。", + "notifyPresetApply": "套用", + "notifyPresetApplying": "正在套用…", + "onboardingNotifyTitle": "通知偏好", + "onboardingNotifyBody": "先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。", + "onboardingNotifySkip": "略過,之後再設定" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7a9bebb7c..d0cfe63a6 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2093,5 +2093,21 @@ "locationTrackLegendLatest": "最新位置", "eewCancelled": "已取消", "eewCancelledNotice": "發布單位已取消此速報", - "eewFeedDown": "地震速報連線中斷,重新連線中…" + "eewFeedDown": "地震速報連線中斷,重新連線中…", + "notifyPresetSection": "快速設定", + "notifyPresetRecommended": "建議", + "notifyPresetRecommendedDescription": "所在地的地震、天氣與防災警訊,以及海嘯消息與公告;略過與震度速報重複的地震報告", + "notifyPresetAll": "全部", + "notifyPresetAllDescription": "每個頻道都接收全部,包含全台各地的地震", + "notifyPresetEmergencyOnly": "僅緊急", + "notifyPresetEmergencyOnlyDescription": "只在攸關安全時通知:所在地震度 4 以上的地震速報、所在地的天氣與防災警訊,以及海嘯警報", + "notifyPresetCustom": "自訂", + "notifyPresetCustomDescription": "在下方逐項調整各頻道", + "notifyPresetConfirmTitle": "套用「{preset}」?", + "notifyPresetConfirmBody": "下方每個頻道都會改成這個組合,取代你逐項調整的設定。", + "notifyPresetApply": "套用", + "notifyPresetApplying": "正在套用…", + "onboardingNotifyTitle": "通知偏好", + "onboardingNotifyBody": "先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。", + "onboardingNotifySkip": "略過,之後再設定" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index b513cb11f..99e0f25d7 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6665,6 +6665,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Earthquake alerts disconnected — reconnecting…'** String get eewFeedDown; + + /// Notification settings: header above the preset choices + /// + /// In en, this message translates to: + /// **'Presets'** + String get notifyPresetSection; + + /// Notification preset name: the recommended combination + /// + /// In en, this message translates to: + /// **'Recommended'** + String get notifyPresetRecommended; + + /// What the recommended preset turns on (and the one thing it leaves out) + /// + /// In en, this message translates to: + /// **'Earthquake, weather and hazard alerts for your area, plus tsunami news and announcements — without the earthquake report that repeats the intensity report'** + String get notifyPresetRecommendedDescription; + + /// Notification preset name: every channel at its broadest option + /// + /// In en, this message translates to: + /// **'All'** + String get notifyPresetAll; + + /// What the All preset turns on + /// + /// In en, this message translates to: + /// **'Everything on every channel, including earthquakes anywhere in Taiwan'** + String get notifyPresetAllDescription; + + /// Notification preset name: only life-safety alerts + /// + /// In en, this message translates to: + /// **'Emergency only'** + String get notifyPresetEmergencyOnly; + + /// What the Emergency-only preset keeps + /// + /// In en, this message translates to: + /// **'Only when safety is at stake: earthquake alerts for intensity 4+ where you are, weather and hazard warnings for your area, and tsunami warnings'** + String get notifyPresetEmergencyOnlyDescription; + + /// Notification preset name: the current settings match no preset + /// + /// In en, this message translates to: + /// **'Custom'** + String get notifyPresetCustom; + + /// Explains that Custom means the per-channel choices below + /// + /// In en, this message translates to: + /// **'Set each channel individually below'** + String get notifyPresetCustomDescription; + + /// Confirmation title before a preset replaces custom per-channel settings + /// + /// In en, this message translates to: + /// **'Apply “{preset}”?'** + String notifyPresetConfirmTitle(String preset); + + /// Confirmation body: applying the preset overwrites the per-channel choices + /// + /// In en, this message translates to: + /// **'Every channel below will change to match it, replacing the settings you picked one by one.'** + String get notifyPresetConfirmBody; + + /// Confirmation button that applies a notification preset + /// + /// In en, this message translates to: + /// **'Apply'** + String get notifyPresetApply; + + /// Shown (and announced) while a notification preset is being saved + /// + /// In en, this message translates to: + /// **'Applying…'** + String get notifyPresetApplying; + + /// Onboarding step title: choosing a notification preset + /// + /// In en, this message translates to: + /// **'Your alerts'** + String get onboardingNotifyTitle; + + /// Onboarding step body: the choice is a starting point, adjustable later; EEW stays on in every option + /// + /// In en, this message translates to: + /// **'Pick a starting point. You can fine-tune each channel in Notification settings at any time, and every option keeps earthquake early warnings on.'** + String get onboardingNotifyBody; + + /// Onboarding: finish without applying a notification preset after a failed attempt + /// + /// In en, this message translates to: + /// **'Skip for now'** + String get onboardingNotifySkip; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index fc3286b1f..132954333 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3526,4 +3526,60 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eewFeedDown => 'Earthquake alerts disconnected — reconnecting…'; + + @override + String get notifyPresetSection => 'Presets'; + + @override + String get notifyPresetRecommended => 'Recommended'; + + @override + String get notifyPresetRecommendedDescription => + 'Earthquake, weather and hazard alerts for your area, plus tsunami news and announcements — without the earthquake report that repeats the intensity report'; + + @override + String get notifyPresetAll => 'All'; + + @override + String get notifyPresetAllDescription => + 'Everything on every channel, including earthquakes anywhere in Taiwan'; + + @override + String get notifyPresetEmergencyOnly => 'Emergency only'; + + @override + String get notifyPresetEmergencyOnlyDescription => + 'Only when safety is at stake: earthquake alerts for intensity 4+ where you are, weather and hazard warnings for your area, and tsunami warnings'; + + @override + String get notifyPresetCustom => 'Custom'; + + @override + String get notifyPresetCustomDescription => + 'Set each channel individually below'; + + @override + String notifyPresetConfirmTitle(String preset) { + return 'Apply “$preset”?'; + } + + @override + String get notifyPresetConfirmBody => + 'Every channel below will change to match it, replacing the settings you picked one by one.'; + + @override + String get notifyPresetApply => 'Apply'; + + @override + String get notifyPresetApplying => 'Applying…'; + + @override + String get onboardingNotifyTitle => 'Your alerts'; + + @override + String get onboardingNotifyBody => + 'Pick a starting point. You can fine-tune each channel in Notification settings at any time, and every option keeps earthquake early warnings on.'; + + @override + String get onboardingNotifySkip => 'Skip for now'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 301e1901d..54b38d553 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3549,4 +3549,60 @@ class AppLocalizationsFil extends AppLocalizations { @override String get eewFeedDown => 'Naputol ang koneksyon sa babala sa lindol — kumokonekta muli…'; + + @override + String get notifyPresetSection => 'Mabilisang setting'; + + @override + String get notifyPresetRecommended => 'Inirerekomenda'; + + @override + String get notifyPresetRecommendedDescription => + 'Mga alerto sa lindol, panahon, at sakuna sa iyong lugar, kasama ang impormasyon sa tsunami at mga anunsyo — maliban sa ulat ng lindol na umuulit sa ulat ng intensidad'; + + @override + String get notifyPresetAll => 'Lahat'; + + @override + String get notifyPresetAllDescription => + 'Lahat sa bawat channel, kasama ang mga lindol saanman sa Taiwan'; + + @override + String get notifyPresetEmergencyOnly => 'Emergency lamang'; + + @override + String get notifyPresetEmergencyOnlyDescription => + 'Kapag may panganib lamang: mga alerto sa lindol na intensidad 4 pataas sa iyong lokasyon, mga babala sa panahon at sakuna sa iyong lugar, at mga babala sa tsunami'; + + @override + String get notifyPresetCustom => 'Custom'; + + @override + String get notifyPresetCustomDescription => + 'Itakda ang bawat channel sa ibaba'; + + @override + String notifyPresetConfirmTitle(String preset) { + return 'Ilapat ang “$preset”?'; + } + + @override + String get notifyPresetConfirmBody => + 'Magbabago ang bawat channel sa ibaba para tumugma rito, at papalitan ang mga setting na isa-isa mong pinili.'; + + @override + String get notifyPresetApply => 'Ilapat'; + + @override + String get notifyPresetApplying => 'Inilalapat…'; + + @override + String get onboardingNotifyTitle => 'Iyong mga alerto'; + + @override + String get onboardingNotifyBody => + 'Pumili ng panimulang setting. Maaari mong ayusin ang bawat channel sa Mga setting ng notipikasyon anumang oras, at bawat opsyon ay nananatiling may emergency na alerto sa lindol.'; + + @override + String get onboardingNotifySkip => 'Laktawan muna'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 7b72068b8..2bbfd5a02 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3542,4 +3542,60 @@ class AppLocalizationsId extends AppLocalizations { @override String get eewFeedDown => 'Koneksi peringatan gempa terputus — menyambungkan ulang…'; + + @override + String get notifyPresetSection => 'Setelan cepat'; + + @override + String get notifyPresetRecommended => 'Disarankan'; + + @override + String get notifyPresetRecommendedDescription => + 'Peringatan gempa, cuaca, dan bencana di area Anda, ditambah informasi tsunami dan pengumuman — tanpa laporan gempa yang mengulang laporan intensitas'; + + @override + String get notifyPresetAll => 'Semua'; + + @override + String get notifyPresetAllDescription => + 'Semua hal di setiap saluran, termasuk gempa di mana pun di Taiwan'; + + @override + String get notifyPresetEmergencyOnly => 'Darurat saja'; + + @override + String get notifyPresetEmergencyOnlyDescription => + 'Hanya saat menyangkut keselamatan: peringatan gempa intensitas 4 atau lebih di lokasi Anda, peringatan cuaca dan bencana di area Anda, serta peringatan tsunami'; + + @override + String get notifyPresetCustom => 'Kustom'; + + @override + String get notifyPresetCustomDescription => + 'Atur setiap saluran satu per satu di bawah'; + + @override + String notifyPresetConfirmTitle(String preset) { + return 'Terapkan “$preset”?'; + } + + @override + String get notifyPresetConfirmBody => + 'Setiap saluran di bawah akan diubah mengikutinya, menggantikan pengaturan yang Anda pilih satu per satu.'; + + @override + String get notifyPresetApply => 'Terapkan'; + + @override + String get notifyPresetApplying => 'Menerapkan…'; + + @override + String get onboardingNotifyTitle => 'Peringatan Anda'; + + @override + String get onboardingNotifyBody => + 'Pilih titik awal. Anda dapat menyesuaikan setiap saluran di Pengaturan notifikasi kapan saja, dan setiap pilihan tetap menyalakan peringatan gempa darurat.'; + + @override + String get onboardingNotifySkip => 'Lewati dulu'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 699fd1e7d..53e3aaa9d 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3462,4 +3462,58 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eewFeedDown => '緊急地震速報の接続が切れています。再接続中…'; + + @override + String get notifyPresetSection => 'プリセット'; + + @override + String get notifyPresetRecommended => 'おすすめ'; + + @override + String get notifyPresetRecommendedDescription => + '所在地の地震・気象・防災の警報に加え、津波情報とお知らせ。震度速報と重複する地震報告は除きます'; + + @override + String get notifyPresetAll => 'すべて'; + + @override + String get notifyPresetAllDescription => 'すべてのチャンネルですべて受信。台湾全域の地震も含みます'; + + @override + String get notifyPresetEmergencyOnly => '緊急のみ'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '安全に関わるときだけ通知:所在地の震度4以上の緊急地震速報、所在地の気象・防災の警報、津波警報'; + + @override + String get notifyPresetCustom => 'カスタム'; + + @override + String get notifyPresetCustomDescription => '下で各チャンネルを個別に設定'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '「$preset」を適用しますか?'; + } + + @override + String get notifyPresetConfirmBody => + '下の各チャンネルがこの組み合わせに変わり、個別に選んだ設定は置き換えられます。'; + + @override + String get notifyPresetApply => '適用'; + + @override + String get notifyPresetApplying => '適用しています…'; + + @override + String get onboardingNotifyTitle => '通知の受け取り方'; + + @override + String get onboardingNotifyBody => + 'まずは出発点を選んでください。各チャンネルは「通知設定」でいつでも個別に調整でき、どの選択肢でも緊急地震速報は受け取れます。'; + + @override + String get onboardingNotifySkip => 'あとで設定する'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 9c14d9c5b..66dcb8998 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3464,4 +3464,58 @@ class AppLocalizationsKo extends AppLocalizations { @override String get eewFeedDown => '지진 속보 연결이 끊겼습니다. 다시 연결하는 중…'; + + @override + String get notifyPresetSection => '빠른 설정'; + + @override + String get notifyPresetRecommended => '권장'; + + @override + String get notifyPresetRecommendedDescription => + '현재 위치의 지진·기상·재난 알림과 지진해일 정보, 공지사항. 진도 속보와 겹치는 지진 보고는 제외합니다'; + + @override + String get notifyPresetAll => '전체'; + + @override + String get notifyPresetAllDescription => '모든 채널을 전부 수신합니다. 대만 전역의 지진도 포함됩니다'; + + @override + String get notifyPresetEmergencyOnly => '긴급만'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '안전과 관련될 때만 알립니다: 현재 위치 진도 4 이상의 지진 경보, 현재 위치의 기상·재난 경보, 지진해일 경보'; + + @override + String get notifyPresetCustom => '사용자 지정'; + + @override + String get notifyPresetCustomDescription => '아래에서 채널별로 설정'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '‘$preset’을(를) 적용할까요?'; + } + + @override + String get notifyPresetConfirmBody => + '아래의 모든 채널이 이 조합으로 바뀌며, 채널별로 고른 설정은 대체됩니다.'; + + @override + String get notifyPresetApply => '적용'; + + @override + String get notifyPresetApplying => '적용하는 중…'; + + @override + String get onboardingNotifyTitle => '알림 받기'; + + @override + String get onboardingNotifyBody => + '먼저 시작점을 골라 주세요. 각 채널은 언제든 \'알림 설정\'에서 조정할 수 있으며, 어떤 옵션이든 긴급 지진 경보는 계속 받습니다.'; + + @override + String get onboardingNotifySkip => '나중에 설정'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index c9aae9026..7f991bfc6 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3521,4 +3521,59 @@ class AppLocalizationsTh extends AppLocalizations { @override String get eewFeedDown => 'การเชื่อมต่อการแจ้งเตือนแผ่นดินไหวขาดหาย กำลังเชื่อมต่อใหม่…'; + + @override + String get notifyPresetSection => 'การตั้งค่าด่วน'; + + @override + String get notifyPresetRecommended => 'แนะนำ'; + + @override + String get notifyPresetRecommendedDescription => + 'การแจ้งเตือนแผ่นดินไหว สภาพอากาศ และภัยพิบัติในพื้นที่ของคุณ พร้อมข้อมูลสึนามิและประกาศ โดยไม่รวมรายงานแผ่นดินไหวที่ซ้ำกับรายงานความรุนแรง'; + + @override + String get notifyPresetAll => 'ทั้งหมด'; + + @override + String get notifyPresetAllDescription => + 'รับทุกอย่างในทุกช่อง รวมถึงแผ่นดินไหวทั่วไต้หวัน'; + + @override + String get notifyPresetEmergencyOnly => 'เฉพาะเหตุฉุกเฉิน'; + + @override + String get notifyPresetEmergencyOnlyDescription => + 'แจ้งเตือนเฉพาะเมื่อเกี่ยวกับความปลอดภัย: การเตือนแผ่นดินไหวที่ความรุนแรงในพื้นที่ระดับ 4 ขึ้นไป การเตือนสภาพอากาศและภัยพิบัติในพื้นที่ของคุณ และการเตือนภัยสึนามิ'; + + @override + String get notifyPresetCustom => 'กำหนดเอง'; + + @override + String get notifyPresetCustomDescription => 'ตั้งค่าแต่ละช่องด้านล่าง'; + + @override + String notifyPresetConfirmTitle(String preset) { + return 'ใช้ “$preset” หรือไม่'; + } + + @override + String get notifyPresetConfirmBody => + 'ทุกช่องด้านล่างจะเปลี่ยนตามชุดนี้ แทนที่การตั้งค่าที่คุณเลือกไว้ทีละช่อง'; + + @override + String get notifyPresetApply => 'ใช้'; + + @override + String get notifyPresetApplying => 'กำลังใช้…'; + + @override + String get onboardingNotifyTitle => 'การแจ้งเตือนของคุณ'; + + @override + String get onboardingNotifyBody => + 'เลือกจุดเริ่มต้นก่อน คุณปรับแต่ละช่องได้ทุกเมื่อใน “การตั้งค่าการแจ้งเตือน” และทุกตัวเลือกยังคงรับการเตือนแผ่นดินไหวฉุกเฉิน'; + + @override + String get onboardingNotifySkip => 'ข้ามไปก่อน'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 6f6399c15..55580275b 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3531,4 +3531,59 @@ class AppLocalizationsVi extends AppLocalizations { @override String get eewFeedDown => 'Mất kết nối cảnh báo động đất — đang kết nối lại…'; + + @override + String get notifyPresetSection => 'Thiết lập nhanh'; + + @override + String get notifyPresetRecommended => 'Đề xuất'; + + @override + String get notifyPresetRecommendedDescription => + 'Cảnh báo động đất, thời tiết và thảm họa tại khu vực của bạn, kèm thông tin sóng thần và thông báo — bỏ báo cáo động đất trùng với báo cáo cường độ'; + + @override + String get notifyPresetAll => 'Tất cả'; + + @override + String get notifyPresetAllDescription => + 'Nhận mọi thứ trên mọi kênh, kể cả động đất ở bất kỳ đâu tại Đài Loan'; + + @override + String get notifyPresetEmergencyOnly => 'Chỉ khẩn cấp'; + + @override + String get notifyPresetEmergencyOnlyDescription => + 'Chỉ thông báo khi liên quan đến an toàn: cảnh báo động đất cường độ 4 trở lên tại chỗ, cảnh báo thời tiết và thảm họa tại khu vực của bạn, và cảnh báo sóng thần'; + + @override + String get notifyPresetCustom => 'Tùy chỉnh'; + + @override + String get notifyPresetCustomDescription => 'Đặt từng kênh riêng ở bên dưới'; + + @override + String notifyPresetConfirmTitle(String preset) { + return 'Áp dụng “$preset”?'; + } + + @override + String get notifyPresetConfirmBody => + 'Mọi kênh bên dưới sẽ đổi theo thiết lập này, thay thế các cài đặt bạn đã chọn từng mục.'; + + @override + String get notifyPresetApply => 'Áp dụng'; + + @override + String get notifyPresetApplying => 'Đang áp dụng…'; + + @override + String get onboardingNotifyTitle => 'Cảnh báo của bạn'; + + @override + String get onboardingNotifyBody => + 'Hãy chọn một điểm bắt đầu. Bạn có thể tinh chỉnh từng kênh trong Cài đặt thông báo bất cứ lúc nào, và mọi lựa chọn đều giữ cảnh báo động đất khẩn cấp.'; + + @override + String get onboardingNotifySkip => 'Để sau'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 33c94c8b3..36ac9bf80 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3441,4 +3441,56 @@ class AppLocalizationsYue extends AppLocalizations { @override String get eewFeedDown => '地震速報連接中斷,重新連接緊…'; + + @override + String get notifyPresetSection => '快速設定'; + + @override + String get notifyPresetRecommended => '建議'; + + @override + String get notifyPresetRecommendedDescription => + '所在地嘅地震、天氣同防災警訊,加埋海嘯消息同公告;唔包同震度速報重複嘅地震報告'; + + @override + String get notifyPresetAll => '全部'; + + @override + String get notifyPresetAllDescription => '每個頻道都收晒全部,包括全台各地嘅地震'; + + @override + String get notifyPresetEmergencyOnly => '只限緊急'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '淨係關乎安全先通知你:所在地震度 4 或以上嘅地震速報、所在地嘅天氣同防災警訊,同埋海嘯警報'; + + @override + String get notifyPresetCustom => '自訂'; + + @override + String get notifyPresetCustomDescription => '喺下面逐項調整各個頻道'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '套用「$preset」?'; + } + + @override + String get notifyPresetConfirmBody => '下面每個頻道都會改成呢個組合,取代你逐項調整過嘅設定。'; + + @override + String get notifyPresetApply => '套用'; + + @override + String get notifyPresetApplying => '套用緊…'; + + @override + String get onboardingNotifyTitle => '通知偏好'; + + @override + String get onboardingNotifyBody => '揀一個起點先,之後隨時可以喺「通知設定」逐項調整。每個選項都會保留地震速報。'; + + @override + String get onboardingNotifySkip => '跳過,遲啲先設定'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index e23233900..f49dcc2d9 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3441,6 +3441,58 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eewFeedDown => '地震速報連線中斷,重新連線中…'; + + @override + String get notifyPresetSection => '快速設定'; + + @override + String get notifyPresetRecommended => '建議'; + + @override + String get notifyPresetRecommendedDescription => + '所在地的地震、天氣與防災警訊,以及海嘯消息與公告;略過與震度速報重複的地震報告'; + + @override + String get notifyPresetAll => '全部'; + + @override + String get notifyPresetAllDescription => '每個頻道都接收全部,包含全台各地的地震'; + + @override + String get notifyPresetEmergencyOnly => '僅緊急'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '只在攸關安全時通知:所在地震度 4 以上的地震速報、所在地的天氣與防災警訊,以及海嘯警報'; + + @override + String get notifyPresetCustom => '自訂'; + + @override + String get notifyPresetCustomDescription => '在下方逐項調整各頻道'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '套用「$preset」?'; + } + + @override + String get notifyPresetConfirmBody => '下方每個頻道都會改成這個組合,取代你逐項調整的設定。'; + + @override + String get notifyPresetApply => '套用'; + + @override + String get notifyPresetApplying => '正在套用…'; + + @override + String get onboardingNotifyTitle => '通知偏好'; + + @override + String get onboardingNotifyBody => '先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。'; + + @override + String get onboardingNotifySkip => '略過,之後再設定'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6876,6 +6928,58 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get eewFeedDown => '地震速报连接中断,正在重新连接…'; + + @override + String get notifyPresetSection => '快速设置'; + + @override + String get notifyPresetRecommended => '推荐'; + + @override + String get notifyPresetRecommendedDescription => + '本地的地震、天气与防灾警讯,以及海啸消息与公告;略过与震度速报重复的地震报告'; + + @override + String get notifyPresetAll => '全部'; + + @override + String get notifyPresetAllDescription => '每个频道都接收全部,包括全台各地的地震'; + + @override + String get notifyPresetEmergencyOnly => '仅紧急'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '只在关乎安全时通知:本地震度 4 以上的地震预警、本地的天气与防灾警讯,以及海啸警报'; + + @override + String get notifyPresetCustom => '自定义'; + + @override + String get notifyPresetCustomDescription => '在下方逐项调整各频道'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '应用“$preset”?'; + } + + @override + String get notifyPresetConfirmBody => '下方每个频道都会改成这个组合,取代你逐项调整的设置。'; + + @override + String get notifyPresetApply => '应用'; + + @override + String get notifyPresetApplying => '正在应用…'; + + @override + String get onboardingNotifyTitle => '通知偏好'; + + @override + String get onboardingNotifyBody => '先选一个起点,之后随时可以在“通知设置”中逐项调整。每个选项都会保留地震预警。'; + + @override + String get onboardingNotifySkip => '跳过,稍后再设置'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -10312,6 +10416,58 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get eewFeedDown => '地震速報連接中斷,重新連接中…'; + + @override + String get notifyPresetSection => '快速設定'; + + @override + String get notifyPresetRecommended => '建議'; + + @override + String get notifyPresetRecommendedDescription => + '所在地的地震、天氣及防災警訊,以及海嘯消息及公告;略過與震度速報重複的地震報告'; + + @override + String get notifyPresetAll => '全部'; + + @override + String get notifyPresetAllDescription => '每個頻道均接收全部,包括全台各地的地震'; + + @override + String get notifyPresetEmergencyOnly => '僅緊急'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '只在攸關安全時通知:所在地震度 4 或以上的地震速報、所在地的天氣及防災警訊,以及海嘯警報'; + + @override + String get notifyPresetCustom => '自訂'; + + @override + String get notifyPresetCustomDescription => '在下方逐項調整各頻道'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '套用「$preset」?'; + } + + @override + String get notifyPresetConfirmBody => '下方每個頻道都會改為這個組合,取代你逐項調整的設定。'; + + @override + String get notifyPresetApply => '套用'; + + @override + String get notifyPresetApplying => '正在套用…'; + + @override + String get onboardingNotifyTitle => '通知偏好'; + + @override + String get onboardingNotifyBody => '先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。'; + + @override + String get onboardingNotifySkip => '略過,之後再設定'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -13748,4 +13904,56 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get eewFeedDown => '地震速報連線中斷,重新連線中…'; + + @override + String get notifyPresetSection => '快速設定'; + + @override + String get notifyPresetRecommended => '建議'; + + @override + String get notifyPresetRecommendedDescription => + '所在地的地震、天氣與防災警訊,以及海嘯消息與公告;略過與震度速報重複的地震報告'; + + @override + String get notifyPresetAll => '全部'; + + @override + String get notifyPresetAllDescription => '每個頻道都接收全部,包含全台各地的地震'; + + @override + String get notifyPresetEmergencyOnly => '僅緊急'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '只在攸關安全時通知:所在地震度 4 以上的地震速報、所在地的天氣與防災警訊,以及海嘯警報'; + + @override + String get notifyPresetCustom => '自訂'; + + @override + String get notifyPresetCustomDescription => '在下方逐項調整各頻道'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '套用「$preset」?'; + } + + @override + String get notifyPresetConfirmBody => '下方每個頻道都會改成這個組合,取代你逐項調整的設定。'; + + @override + String get notifyPresetApply => '套用'; + + @override + String get notifyPresetApplying => '正在套用…'; + + @override + String get onboardingNotifyTitle => '通知偏好'; + + @override + String get onboardingNotifyBody => '先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。'; + + @override + String get onboardingNotifySkip => '略過,之後再設定'; } diff --git a/test/features/notification/domain/notify_preset_applier_test.dart b/test/features/notification/domain/notify_preset_applier_test.dart new file mode 100644 index 000000000..b06ce1b61 --- /dev/null +++ b/test/features/notification/domain/notify_preset_applier_test.dart @@ -0,0 +1,210 @@ +/// [NotifyPresetApplier] is nine writes against an endpoint that answers a +/// burst with 429. What it must never do: fire two requests without the gap +/// between them, write past a failure as if it had not happened, or keep +/// writing for a caller that has gone away. Timing is checked under +/// [fakeAsync], so the real two-second spacing is what is pinned. +library; + +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_preset_applier.dart'; +import 'package:dpip/features/notification/domain/notify_repository.dart'; +import 'package:dpip/features/notification/domain/notify_settings.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Every channel at its local threshold — a fresh device. +NotifySettings _fresh() => + NotifySettings.fromWire(const [1, 1, 1, 1, 1, 1, 1, 1, 1]); + +/// Echoes writes into an in-memory settings value, records when each request +/// arrived (in fake time), and fails the request numbered [failAt]. +class _Repo implements NotifyRepository { + _Repo(this.settings, this._elapsed); + + NotifySettings settings; + final Duration Function() _elapsed; + final List<(String call, Duration at)> calls = []; + int? failAt; + + Result _answer(String call) { + calls.add((call, _elapsed())); + if (failAt == calls.length) { + return const Err(NetworkFailure('Server error (429)')); + } + return Ok(settings); + } + + @override + Future> fetch(String token) async => _answer('fetch'); + + @override + Future> setChannel( + String token, + NotifyChannel channel, + int optionIndex, + ) async { + final result = _answer(channel.name); + if (result.isOk) settings = settings.withChannel(channel, optionIndex); + return result.isOk ? Ok(settings) : result; + } +} + +void main() { + test('fetches first, then writes only what differs, 2 s apart', () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed); + Result? result; + + NotifyPresetApplier(repo) + .apply('tok', NotifyPreset.recommended) + .then((r) => result = r); + async.flushTimers(); + + expect(repo.calls.map((c) => c.$1), ['fetch', 'report']); + expect(repo.calls.map((c) => c.$2), [ + Duration.zero, + const Duration(seconds: 2), + ]); + expect(result?.valueOrNull?.optionOf(NotifyChannel.report), 0); + expect( + NotifyPreset.matching(result!.valueOrNull!), + NotifyPreset.recommended, + ); + }); + }); + + test('with known settings it skips the fetch', () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed); + NotifyPresetApplier(repo) + .apply('tok', NotifyPreset.emergencyOnly, current: _fresh()); + async.flushTimers(); + + final names = repo.calls.map((c) => c.$1).toList(); + expect(names.first, 'eew'); + expect(names, isNot(contains('fetch'))); + // Six channels differ; each request waits for the one before it. + expect(repo.calls, hasLength(6)); + for (var i = 1; i < repo.calls.length; i++) { + expect( + repo.calls[i].$2 - repo.calls[i - 1].$2, + NotifyPresetApplier.requestSpacing, + ); + } + }); + }); + + test( + 'a failure stops the sequence, reports it, and keeps what was written', + () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed)..failAt = 3; + final progress = []; + Result? result; + + NotifyPresetApplier(repo) + .apply( + 'tok', + NotifyPreset.emergencyOnly, + current: _fresh(), + onProgress: progress.add, + ) + .then((r) => result = r); + async.flushTimers(); + + expect(result?.failureOrNull, isA()); + expect(repo.calls, hasLength(3), reason: 'nothing after the failure'); + expect( + progress, + hasLength(2), + reason: 'one snapshot per accepted write', + ); + expect(progress.last.optionOf(NotifyChannel.eew), 0); + expect(progress.last.optionOf(NotifyChannel.monitor), 0); + expect(progress.last.optionOf(NotifyChannel.report), 1); + }); + }, + ); + + test('running it again resumes with only the channels still differing', () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed)..failAt = 3; + final applier = NotifyPresetApplier(repo); + applier.apply('tok', NotifyPreset.emergencyOnly, current: _fresh()); + async.flushTimers(); + + repo + ..calls.clear() + ..failAt = null; + Result? result; + applier.apply('tok', NotifyPreset.emergencyOnly).then((r) => result = r); + async.flushTimers(); + + expect(repo.calls.map((c) => c.$1), [ + 'fetch', + 'report', + 'intensity', + 'tsunami', + 'announcement', + ]); + expect( + NotifyPreset.matching(result!.valueOrNull!), + NotifyPreset.emergencyOnly, + ); + }); + }); + + test('a failed fetch writes nothing', () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed)..failAt = 1; + Result? result; + NotifyPresetApplier(repo) + .apply('tok', NotifyPreset.all) + .then((r) => result = r); + async.flushTimers(); + + expect(repo.calls.map((c) => c.$1), ['fetch']); + expect(result?.failureOrNull, isA()); + }); + }); + + test('a cancelled caller sends nothing further', () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed); + var gone = false; + Result? result; + NotifyPresetApplier(repo) + .apply( + 'tok', + NotifyPreset.emergencyOnly, + current: _fresh(), + isCancelled: () => gone, + ) + .then((r) => result = r); + async.elapse(const Duration(milliseconds: 100)); + expect(repo.calls, hasLength(1)); + + gone = true; + async.flushTimers(); + + expect(repo.calls, hasLength(1)); + expect(result?.isOk, isFalse); + }); + }); + + test('custom writes nothing and answers with the settings it was given', () { + fakeAsync((async) { + final repo = _Repo(_fresh(), () => async.elapsed); + Result? result; + NotifyPresetApplier(repo) + .apply('tok', NotifyPreset.custom, current: _fresh()) + .then((r) => result = r); + async.flushTimers(); + + expect(repo.calls, isEmpty); + expect(result?.isOk, isTrue); + }); + }); +} diff --git a/test/features/notification/domain/notify_preset_test.dart b/test/features/notification/domain/notify_preset_test.dart new file mode 100644 index 000000000..499ed56d6 --- /dev/null +++ b/test/features/notification/domain/notify_preset_test.dart @@ -0,0 +1,215 @@ +/// The preset table is a safety decision written as data: which channel each +/// preset silences. A wrong row is a valid list of integers — the server +/// accepts it and the phone simply stops ringing for something — so the rows +/// that keep a life-safety alert on are pinned one by one, not only "it +/// round-trips". +library; + +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_settings.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A preset's options as a settings value — what the server holds after the +/// preset is applied in full. +NotifySettings _settingsOf(NotifyPreset preset) => NotifySettings.fromWire([ + for (final channel in NotifyChannel.values) preset.optionFor(channel)!, +]); + +const _named = [ + NotifyPreset.recommended, + NotifyPreset.all, + NotifyPreset.emergencyOnly, +]; + +const _weather = [ + NotifyChannel.thunderstorm, + NotifyChannel.weatherAdvisory, + NotifyChannel.evacuation, +]; + +void main() { + test('every named preset picks an option each channel really offers', () { + for (final preset in _named) { + for (final channel in NotifyChannel.values) { + final option = preset.optionFor(channel); + expect(option, isNotNull, reason: '$preset leaves $channel unset'); + expect( + optionsFor(channel)[option!], + preset.kindFor(channel), + reason: '$preset/$channel resolves to the wrong wire index', + ); + } + } + }); + + test('custom names no option for any channel', () { + for (final channel in NotifyChannel.values) { + expect(NotifyPreset.custom.kindFor(channel), isNull); + expect(NotifyPreset.custom.optionFor(channel), isNull); + } + }); + + test('the named presets are three different combinations', () { + final combos = { + for (final preset in _named) + NotifyChannel.values.map(preset.optionFor).join(','), + }; + expect(combos, hasLength(_named.length)); + }); + + group('life-safety channels', () { + test('no preset turns the weather or hazard channels off', () { + // Each carries an urgent sub-channel (mountain torrents, red-level + // advisories, debris-flow warnings) the server switches together with + // the routine messages. + for (final preset in _named) { + for (final channel in _weather) { + expect( + preset.kindFor(channel), + NotifyOptionKind.weatherLocal, + reason: '$preset silences $channel', + ); + } + } + }); + + test('emergency only keeps EEW at intensity 4+ and tsunami warnings', () { + expect( + NotifyPreset.emergencyOnly.kindFor(NotifyChannel.eew), + NotifyOptionKind.localIntensity4, + ); + expect( + NotifyPreset.emergencyOnly.kindFor(NotifyChannel.tsunami), + NotifyOptionKind.tsunamiWarning, + ); + }); + + test( + 'emergency only turns off exactly the channels with no urgent tier', + () { + final off = [ + for (final channel in NotifyChannel.values) + if (NotifyPreset.emergencyOnly.kindFor(channel) == + NotifyOptionKind.off) + channel, + ]; + expect(off, [ + NotifyChannel.monitor, + NotifyChannel.report, + NotifyChannel.intensity, + NotifyChannel.announcement, + ]); + }, + ); + }); + + test('recommended drops only the earthquake report', () { + final kinds = { + for (final channel in NotifyChannel.values) + channel: NotifyPreset.recommended.kindFor(channel), + }; + expect(kinds, { + NotifyChannel.eew: NotifyOptionKind.localIntensity1, + NotifyChannel.monitor: NotifyOptionKind.localIntensity1, + NotifyChannel.report: NotifyOptionKind.off, + NotifyChannel.intensity: NotifyOptionKind.localIntensity1, + NotifyChannel.thunderstorm: NotifyOptionKind.weatherLocal, + NotifyChannel.weatherAdvisory: NotifyOptionKind.weatherLocal, + NotifyChannel.evacuation: NotifyOptionKind.weatherLocal, + NotifyChannel.tsunami: NotifyOptionKind.tsunamiAll, + NotifyChannel.announcement: NotifyOptionKind.all, + }); + }); + + test('all is the broadest option on every channel', () { + for (final channel in NotifyChannel.values) { + expect( + NotifyPreset.all.optionFor(channel), + optionsFor(channel).length - 1, + reason: 'wire order runs narrowest to broadest', + ); + } + }); + + group('matching', () { + test('recognises each named preset', () { + for (final preset in _named) { + expect(NotifyPreset.matching(_settingsOf(preset)), preset); + } + }); + + test('one channel off any preset is custom', () { + final nearlyRecommended = _settingsOf(NotifyPreset.recommended) + .withChannel(NotifyChannel.report, 1); + expect(NotifyPreset.matching(nearlyRecommended), NotifyPreset.custom); + }); + + test('the defaults a fresh device starts from are custom', () { + // Every channel at its local threshold, report included — what an + // upgraded user's server already holds. Calling it a preset would claim + // a choice they never made. + final fresh = NotifySettings.fromWire(const [1, 1, 1, 1, 1, 1, 1, 1, 1]); + expect(NotifyPreset.matching(fresh), NotifyPreset.custom); + }); + }); + + group('changesFrom', () { + test('writes nothing when the settings already match', () { + for (final preset in _named) { + expect(preset.changesFrom(_settingsOf(preset)), isEmpty); + } + expect( + NotifyPreset.custom.changesFrom(_settingsOf(NotifyPreset.all)), + isEmpty, + ); + }); + + test('writes only the channels that differ', () { + final changes = NotifyPreset.emergencyOnly.changesFrom( + _settingsOf(NotifyPreset.recommended), + ); + expect(changes.map((c) => c.channel).toSet(), { + NotifyChannel.eew, + NotifyChannel.monitor, + NotifyChannel.intensity, + NotifyChannel.tsunami, + NotifyChannel.announcement, + }); + }); + + test('widening writes go before narrowing ones', () { + // Emergency-only with the earthquake report turned all the way up: + // going to recommended widens five channels and narrows that one. A + // sequence that stops part-way must have added alerts, never removed + // them first. + final mixed = _settingsOf(NotifyPreset.emergencyOnly) + .withChannel(NotifyChannel.report, 2); + final changes = NotifyPreset.recommended.changesFrom(mixed); + + final firstNarrowing = changes.indexWhere( + (c) => c.option < mixed.optionOf(c.channel), + ); + expect(firstNarrowing, greaterThanOrEqualTo(0)); + for (final change in changes.take(firstNarrowing)) { + expect(change.option, greaterThan(mixed.optionOf(change.channel))); + } + for (final change in changes.skip(firstNarrowing)) { + expect(change.option, lessThan(mixed.optionOf(change.channel))); + } + expect(changes.first.channel, NotifyChannel.eew); + expect(changes.last.channel, NotifyChannel.report); + }); + + test('an applied change list reaches the preset', () { + for (final from in _named) { + for (final to in _named) { + var settings = _settingsOf(from); + for (final change in to.changesFrom(settings)) { + settings = settings.withChannel(change.channel, change.option); + } + expect(NotifyPreset.matching(settings), to, reason: '$from → $to'); + } + } + }); + }); +} diff --git a/test/features/notification/presentation/notify_controller_test.dart b/test/features/notification/presentation/notify_controller_test.dart index ebf2bf07a..d68c042d0 100644 --- a/test/features/notification/presentation/notify_controller_test.dart +++ b/test/features/notification/presentation/notify_controller_test.dart @@ -1,5 +1,6 @@ import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; import 'package:dpip/features/notification/domain/notify_repository.dart'; import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:dpip/features/notification/presentation/notify_controller.dart'; @@ -13,6 +14,9 @@ class _FakeRepo implements NotifyRepository { bool fail = false; int setCalls = 0; + /// Fails only the write numbered this (1-based), when set. + int? failSetAt; + @override Future> fetch(String token) async => fail ? const Err(NetworkFailure('boom')) : Ok(settings); @@ -24,7 +28,7 @@ class _FakeRepo implements NotifyRepository { int optionIndex, ) async { setCalls++; - if (fail) return const Err(NetworkFailure('boom')); + if (fail || failSetAt == setCalls) return const Err(NetworkFailure('boom')); settings = settings.withChannel(channel, optionIndex); return Ok(settings); } @@ -102,4 +106,84 @@ void main() { expect(controller.settings!.optionOf(NotifyChannel.eew), 1); // unchanged expect(controller.saving, isNull); }); + + group('presets', () { + NotifyController controller0(_FakeRepo repo) => + NotifyController(repo, 'tok', presetSpacing: Duration.zero); + + test('the loaded settings name the preset they match', () async { + final controller = controller0(_FakeRepo(_settings())); + expect(controller.preset, isNull, reason: 'nothing loaded yet'); + + await controller.load(); + + expect(controller.preset, NotifyPreset.custom); + }); + + test('applying adopts every echo and lands on the preset', () async { + final repo = _FakeRepo(_settings()); + final controller = controller0(repo); + await controller.load(); + final seen = []; + controller.addListener(() => seen.add(controller.applying)); + + final ok = await controller.applyPreset(NotifyPreset.emergencyOnly); + + expect(ok, isTrue); + expect(controller.preset, NotifyPreset.emergencyOnly); + expect(controller.applying, isNull); + expect(controller.busy, isFalse); + // Marked as applying throughout, with a notify per accepted write. + expect(seen.first, NotifyPreset.emergencyOnly); + expect(seen.length, repo.setCalls + 2); + }); + + test('a failure part-way keeps what the server accepted', () async { + final repo = _FakeRepo(_settings())..failSetAt = 2; + final controller = controller0(repo); + await controller.load(); + + final ok = await controller.applyPreset(NotifyPreset.emergencyOnly); + + expect(ok, isFalse); + expect(repo.setCalls, 2); + expect(controller.settings!.optionOf(NotifyChannel.eew), 0); + expect(controller.preset, NotifyPreset.custom); + expect(controller.applying, isNull); + }); + + test('a preset already in place sends nothing', () async { + final repo = _FakeRepo(_settings()); + final controller = controller0(repo); + await controller.load(); + await controller.applyPreset(NotifyPreset.all); + final writes = repo.setCalls; + + expect(await controller.applyPreset(NotifyPreset.all), isTrue); + expect(repo.setCalls, writes); + }); + + test('custom has nothing to apply', () async { + final repo = _FakeRepo(_settings()); + final controller = controller0(repo); + await controller.load(); + + expect(await controller.applyPreset(NotifyPreset.custom), isFalse); + expect(repo.setCalls, 0); + }); + + test('no write starts while a preset is being applied', () async { + final repo = _FakeRepo(_settings()); + final controller = controller0(repo); + await controller.load(); + + final applying = controller.applyPreset(NotifyPreset.emergencyOnly); + expect(controller.busy, isTrue); + expect(await controller.setChannel(NotifyChannel.report, 2), isFalse); + expect(await controller.applyPreset(NotifyPreset.all), isFalse); + await applying; + + expect(controller.preset, NotifyPreset.emergencyOnly); + }); + }); } diff --git a/test/features/notification/presentation/notify_labels_test.dart b/test/features/notification/presentation/notify_labels_test.dart index d17b8896c..1254cb78e 100644 --- a/test/features/notification/presentation/notify_labels_test.dart +++ b/test/features/notification/presentation/notify_labels_test.dart @@ -8,6 +8,7 @@ library; import 'package:dpip/features/notification/domain/notify_settings.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; import 'package:dpip/features/notification/presentation/notify_labels.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; @@ -67,4 +68,20 @@ void main() { expect(everything, hasLength(1), reason: 'both are the "all" tier'); expect({off, ...gated, ...everything}, hasLength(3)); }); + + test('every preset has a name, a description and an icon of its own', () { + final names = { + for (final preset in NotifyPreset.values) notifyPresetLabel(preset, l10n), + }; + final descriptions = { + for (final preset in NotifyPreset.values) + notifyPresetDescription(preset, l10n), + }; + final icons = NotifyPreset.values.map(notifyPresetIcon).toSet(); + + expect(names, everyElement(isNotEmpty)); + expect(names, hasLength(NotifyPreset.values.length)); + expect(descriptions, hasLength(NotifyPreset.values.length)); + expect(icons, hasLength(NotifyPreset.values.length)); + }); } diff --git a/test/features/notification/presentation/pages/notify_page_test.dart b/test/features/notification/presentation/pages/notify_page_test.dart index a6cccfebb..c62d5e49c 100644 --- a/test/features/notification/presentation/pages/notify_page_test.dart +++ b/test/features/notification/presentation/pages/notify_page_test.dart @@ -8,6 +8,7 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; import 'package:dpip/features/notification/domain/notify_repository.dart'; import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:dpip/features/notification/presentation/notify_labels.dart'; @@ -88,6 +89,8 @@ Future _pump( } void main() { + presetTests(); + testWidgets('no token shows the unavailable state', (tester) async { await _pump(tester, repo: _Repo(Ok(_settings()))); final l10n = await AppLocalizations.delegate.load(const Locale('en')); @@ -161,3 +164,187 @@ class _FailingSave implements NotifyRepository { int optionIndex, ) async => const Err(NetworkFailure('save failed')); } + +/// Echoes each write into the full settings, like the server does — what a +/// preset needs to be seen landing — and can fail the write numbered +/// [failSetAt]. +class _EchoRepo implements NotifyRepository { + _EchoRepo(this.settings); + + NotifySettings settings; + int sets = 0; + int? failSetAt; + + @override + Future> fetch(String token) async => Ok(settings); + + @override + Future> setChannel( + String token, + NotifyChannel channel, + int optionIndex, + ) async { + sets++; + if (failSetAt == sets) return const Err(NetworkFailure('429')); + settings = settings.withChannel(channel, optionIndex); + return Ok(settings); + } +} + +/// Every channel at its local threshold — a fresh device, which is custom. +NotifySettings _fresh() => + NotifySettings.fromWire(const [1, 1, 1, 1, 1, 1, 1, 1, 1]); + +NotifySettings _presetSettings(NotifyPreset preset) => NotifySettings.fromWire([ + for (final channel in NotifyChannel.values) preset.optionFor(channel)!, +]); + +Finder _presetRow(NotifyPreset preset, AppLocalizations l10n) => + find.widgetWithText( + RadioListTile, + notifyPresetLabel(preset, l10n), + ); + +/// Long enough for every request of the widest preset, 2 s apart. +const Duration _allRequests = Duration(seconds: 30); + +void presetTests() { + group('presets', () { + testWidgets('each preset is a radio button; custom is the checked one', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pump(tester, repo: _EchoRepo(_fresh()), token: 'token'); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + for (final preset in NotifyPreset.values) { + final checked = preset == NotifyPreset.custom; + expect( + tester.getSemantics(_presetRow(preset, l10n)), + isSemantics( + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, + isChecked: checked, + hasEnabledState: true, + isEnabled: true, + ), + reason: preset.name, + ); + } + semantics.dispose(); + }); + + testWidgets('settings that match a preset check it; custom then disables', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pump( + tester, + repo: _EchoRepo(_presetSettings(NotifyPreset.emergencyOnly)), + token: 'token', + ); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + expect( + tester.getSemantics(_presetRow(NotifyPreset.emergencyOnly, l10n)), + isSemantics(isChecked: true), + ); + // Nothing to apply, so nothing to offer a screen reader. + expect( + tester.getSemantics(_presetRow(NotifyPreset.custom, l10n)), + isSemantics(isChecked: false, isEnabled: false), + ); + semantics.dispose(); + }); + + testWidgets('leaving custom asks first; cancelling sends nothing', ( + tester, + ) async { + final repo = _EchoRepo(_fresh()); + await _pump(tester, repo: repo, token: 'token'); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + await tester.tap(_presetRow(NotifyPreset.all, l10n)); + await tester.pumpAndSettle(); + expect(find.text(l10n.notifyPresetConfirmBody), findsOneWidget); + + await tester.tap(find.text(l10n.commonCancel)); + await tester.pumpAndSettle(); + expect(repo.sets, 0); + }); + + testWidgets('applying fills the channels in and checks the preset', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final repo = _EchoRepo(_fresh()); + await _pump(tester, repo: repo, token: 'token'); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + await tester.tap(_presetRow(NotifyPreset.emergencyOnly, l10n)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.notifyPresetApply)); + await tester.pump(); + + // In flight: the chosen row spins — and says so to a screen reader — + // and every channel row is locked. + final applying = tester.getSemantics( + _presetRow(NotifyPreset.emergencyOnly, l10n), + ); + expect(applying.label, contains(l10n.notifyPresetApplying)); + expect(applying, isSemantics(isChecked: true)); + final eewRow = tester.widget( + find.widgetWithText( + ListTile, + notifyChannelTitle(NotifyChannel.eew, l10n), + ), + ); + expect(eewRow.enabled, isFalse); + + await tester.pump(_allRequests); + await tester.pumpAndSettle(); + + expect(NotifyPreset.matching(repo.settings), NotifyPreset.emergencyOnly); + expect( + tester + .widget>( + find.byType(RadioGroup), + ) + .groupValue, + NotifyPreset.emergencyOnly, + ); + expect( + find.text(notifyOptionLabel(NotifyOptionKind.localIntensity4, l10n)), + findsOneWidget, + reason: 'the EEW row shows what the server now holds', + ); + expect(find.text(l10n.notifySetFailed), findsNothing); + semantics.dispose(); + }); + + testWidgets('a failure part-way says so and shows the real state', ( + tester, + ) async { + final repo = _EchoRepo(_presetSettings(NotifyPreset.recommended)) + ..failSetAt = 2; + await _pump(tester, repo: repo, token: 'token'); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + // From a preset, not custom: no confirmation in the way. + await tester.tap(_presetRow(NotifyPreset.emergencyOnly, l10n)); + await tester.pump(_allRequests); + await tester.pumpAndSettle(); + + expect(find.text(l10n.notifySetFailed), findsOneWidget); + expect(repo.sets, 2); + expect( + tester + .widget>( + find.byType(RadioGroup), + ) + .groupValue, + NotifyPreset.custom, + ); + }); + }); +} diff --git a/test/features/onboarding/presentation/pages/onboarding_page_test.dart b/test/features/onboarding/presentation/pages/onboarding_page_test.dart index 740aa8488..ea80f6434 100644 --- a/test/features/onboarding/presentation/pages/onboarding_page_test.dart +++ b/test/features/onboarding/presentation/pages/onboarding_page_test.dart @@ -1,16 +1,24 @@ /// Onboarding is the only door into the app. A next button that stays on the /// intro, or a finish that never marks the store complete, would trap a /// first launch or let someone through without accepting the terms. +/// +/// The walk ends on the notification preset, and is checked against what the +/// server holds afterwards: finishing must mean the choice was saved. library; import 'package:awesome_notifications/awesome_notifications_platform_interface.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/permissions/permission_health.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_repository.dart'; +import 'package:dpip/features/notification/domain/notify_settings.dart'; import 'package:dpip/features/onboarding/presentation/pages/onboarding_page.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; @@ -24,6 +32,34 @@ const Size _tall = Size(400, 4000); const _awesome = MethodChannel('awesome_notifications'); +/// Echoes writes like the server; starts from a fresh device's defaults. +class _Server implements NotifyRepository { + NotifySettings settings = NotifySettings.fromWire(const [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + ]); + + @override + Future> fetch(String token) async => Ok(settings); + + @override + Future> setChannel( + String token, + NotifyChannel channel, + int optionIndex, + ) async { + settings = settings.withChannel(channel, optionIndex); + return Ok(settings); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -42,14 +78,17 @@ void main() { AwesomeNotificationsPlatform.resetInstance(); }); - testWidgets('next, agree, back, then skip finishes onboarding', ( + testWidgets('next, agree, back, skip, then a preset finishes onboarding', ( tester, ) async { tester.view.physicalSize = _tall; tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - final settings = SettingsStore.inMemory(); + final settings = SettingsStore.inMemory({ + SettingKeys.pushToken.name: 'token', + }); + final server = _Server(); final onboarding = OnboardingStore(settings); final notifications = NotificationService(settings); final location = LocationService(const TownDirectory({})); @@ -71,6 +110,7 @@ void main() { ChangeNotifierProvider.value(value: onboarding), ChangeNotifierProvider(create: (_) => LocaleController(settings)), Provider.value(value: notifications), + Provider.value(value: server), Provider.value(value: location), ChangeNotifierProvider( create: (_) => PermissionHealth( @@ -116,7 +156,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text(l10n.onboardingStart)); + await tester.tap(find.text(l10n.onboardingNext)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.text(l10n.onboardingSkipTitle), findsOneWidget); @@ -124,6 +164,17 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); + // Not done yet: the preset step is last, and leaving permissions + // without the essentials only moves on to it. + expect(find.text(l10n.onboardingNotifyTitle), findsOneWidget); + expect(onboarding.isComplete, isFalse); + + await tester.tap(find.text(l10n.onboardingStart)); + await tester.pump(); + await tester.pump(const Duration(seconds: 30)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(NotifyPreset.matching(server.settings), NotifyPreset.recommended); expect(onboarding.isComplete, isTrue); expect(find.text('home-tab'), findsOneWidget); }); diff --git a/test/features/onboarding/presentation/widgets/onboarding_notify_presets_test.dart b/test/features/onboarding/presentation/widgets/onboarding_notify_presets_test.dart new file mode 100644 index 000000000..696e3d6c3 --- /dev/null +++ b/test/features/onboarding/presentation/widgets/onboarding_notify_presets_test.dart @@ -0,0 +1,246 @@ +/// The onboarding preset step is the first write a new device makes to its +/// alert filters, and the last thing between a first launch and the app. +/// +/// Two ways it can go wrong silently: report success without the server +/// holding the choice (the user believes they chose "emergency only" while +/// every channel still rings), or trap the user when the network or the push +/// token is not there yet. So each outcome is pinned against what the fake +/// server actually ended up holding, and every failure must leave a way out. +library; + +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/notification/domain/notify_preset.dart'; +import 'package:dpip/features/notification/domain/notify_repository.dart'; +import 'package:dpip/features/notification/domain/notify_settings.dart'; +import 'package:dpip/features/onboarding/presentation/widgets/onboarding_notify_presets.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'; + +/// A fresh device: every channel at its local threshold. +NotifySettings _fresh() => + NotifySettings.fromWire(const [1, 1, 1, 1, 1, 1, 1, 1, 1]); + +/// Echoes each write like the server, and fails the request numbered +/// [failAt] (fetches and writes counted together). +class _Server implements NotifyRepository { + NotifySettings settings = _fresh(); + int requests = 0; + int? failAt; + + bool get _fails => ++requests == failAt; + + @override + Future> fetch(String token) async => + _fails ? const Err(NetworkFailure('429')) : Ok(settings); + + @override + Future> setChannel( + String token, + NotifyChannel channel, + int optionIndex, + ) async { + if (_fails) return const Err(NetworkFailure('429')); + settings = settings.withChannel(channel, optionIndex); + return Ok(settings); + } +} + +/// Long enough for every request of the widest change, 2 s apart. +const Duration _allRequests = Duration(seconds: 30); + +late AppLocalizations l10n; + +Future _pump( + WidgetTester tester, { + required _Server server, + required VoidCallback onFinish, + String? token = 'token', + double textScale = 1, +}) async { + final settings = SettingsStore.inMemory( + token == null ? const {} : {SettingKeys.pushToken.name: token}, + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: server), + Provider.value(value: NotificationService(settings)), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold(body: OnboardingNotifyPresetsPage(onFinish: onFinish)), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Finder _card(String title) => + find.widgetWithText(RadioListTile, title); + +void main() { + setUpAll(() async { + l10n = await AppLocalizations.delegate.load(const Locale('en')); + }); + + testWidgets('three radio buttons, recommended checked to start with', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pump(tester, server: _Server(), onFinish: () {}); + + expect(find.byType(RadioListTile), findsNWidgets(3)); + expect(find.text(l10n.notifyPresetCustom), findsNothing); + for (final (title, checked) in [ + (l10n.notifyPresetRecommended, true), + (l10n.notifyPresetAll, false), + (l10n.notifyPresetEmergencyOnly, false), + ]) { + expect( + tester.getSemantics(_card(title)), + isSemantics( + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, + isChecked: checked, + isEnabled: true, + ), + reason: title, + ); + } + semantics.dispose(); + }); + + testWidgets('the chosen preset is what the server ends up holding', ( + tester, + ) async { + final server = _Server(); + var finished = false; + await _pump(tester, server: server, onFinish: () => finished = true); + + await tester.tap(_card(l10n.notifyPresetEmergencyOnly)); + await tester.pump(); + await tester.tap(find.text(l10n.onboardingStart)); + await tester.pump(); + + // In flight: nothing can be changed, and the button says why. + expect(find.text(l10n.notifyPresetApplying), findsOneWidget); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + expect( + tester + .widget>(_card(l10n.notifyPresetAll)) + .enabled, + isFalse, + ); + expect(finished, isFalse); + + await tester.pump(_allRequests); + await tester.pumpAndSettle(); + + expect(finished, isTrue); + expect(NotifyPreset.matching(server.settings), NotifyPreset.emergencyOnly); + }); + + testWidgets('a failure offers a retry that resumes, and a way out', ( + tester, + ) async { + // Fetch, then the first write lands, then the second is refused. + final server = _Server()..failAt = 3; + var finished = false; + await _pump(tester, server: server, onFinish: () => finished = true); + + await tester.tap(_card(l10n.notifyPresetEmergencyOnly)); + await tester.pump(); + await tester.tap(find.text(l10n.onboardingStart)); + await tester.pump(_allRequests); + await tester.pumpAndSettle(); + + expect(finished, isFalse); + expect(find.text(l10n.notifySetFailed), findsOneWidget); + expect(find.text(l10n.onboardingNotifySkip), findsOneWidget); + expect(server.settings.optionOf(NotifyChannel.eew), 0, reason: 'kept'); + + await tester.tap(find.text(l10n.commonRetry)); + await tester.pump(_allRequests); + await tester.pumpAndSettle(); + + expect(finished, isTrue); + expect(NotifyPreset.matching(server.settings), NotifyPreset.emergencyOnly); + }); + + testWidgets('no push token yet: says so, writes nothing, can still leave', ( + tester, + ) async { + final server = _Server(); + var finished = false; + await _pump( + tester, + server: server, + token: null, + onFinish: () => finished = true, + ); + + await tester.tap(find.text(l10n.onboardingStart)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.notifyUnavailable), findsOneWidget); + expect(server.requests, 0); + expect(finished, isFalse); + + await tester.tap(find.text(l10n.onboardingNotifySkip)); + await tester.pumpAndSettle(); + + expect(finished, isTrue); + expect(server.requests, 0); + }); + + testWidgets('picking another preset after a failure starts afresh', ( + tester, + ) async { + await _pump(tester, server: _Server(), token: null, onFinish: () {}); + await tester.tap(find.text(l10n.onboardingStart)); + await tester.pumpAndSettle(); + expect(find.text(l10n.notifyUnavailable), findsOneWidget); + + await tester.tap(_card(l10n.notifyPresetAll)); + await tester.pumpAndSettle(); + + expect(find.text(l10n.notifyUnavailable), findsNothing); + expect(find.text(l10n.onboardingStart), findsOneWidget); + expect(find.text(l10n.onboardingNotifySkip), findsNothing); + }); + + testWidgets('lays out at twice the text size on a small phone', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await _pump(tester, server: _Server(), onFinish: () {}, textScale: 2); + + expect(tester.takeException(), isNull); + // The body scrolls; the last card is reachable and the action stays put. + await tester.dragUntilVisible( + _card(l10n.notifyPresetEmergencyOnly), + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + expect(_card(l10n.notifyPresetEmergencyOnly).hitTestable(), findsOneWidget); + expect(find.text(l10n.onboardingStart).hitTestable(), findsOneWidget); + }); +} diff --git a/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart b/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart index 396649379..f5f4341f7 100644 --- a/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart +++ b/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart @@ -204,7 +204,7 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(OnboardingPermissionsPage)), ); - await tester.tap(find.text(l10n.onboardingStart)); + await tester.tap(find.text(l10n.onboardingNext)); await tester.pumpAndSettle(); expect(finished, isTrue); @@ -230,7 +230,7 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(OnboardingPermissionsPage)), ); - await tester.tap(find.text(l10n.onboardingStart)); + await tester.tap(find.text(l10n.onboardingNext)); await tester.pumpAndSettle(); expect(find.text(l10n.onboardingSkipTitle), findsOneWidget); @@ -254,7 +254,7 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(OnboardingPermissionsPage)), ); - await tester.tap(find.text(l10n.onboardingStart)); + await tester.tap(find.text(l10n.onboardingNext)); await tester.pumpAndSettle(); expect(find.text(l10n.onboardingSkipTitle), findsOneWidget); @@ -284,7 +284,7 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(OnboardingPermissionsPage)), ); - await tester.tap(find.text(l10n.onboardingStart)); + await tester.tap(find.text(l10n.onboardingNext)); await tester.pumpAndSettle(); expect(find.text(l10n.onboardingSkipTitle), findsOneWidget);