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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions lib/features/notification/domain/notify_preset.dart
Original file line number Diff line number Diff line change
@@ -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<NotifyChange> changesFrom(NotifySettings current) {
final widening = <NotifyChange>[];
final narrowing = <NotifyChange>[];
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];
}
}
99 changes: 99 additions & 0 deletions lib/features/notification/domain/notify_preset_applier.dart
Original file line number Diff line number Diff line change
@@ -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<Result<NotifySettings>> apply(
String token,
NotifyPreset preset, {
NotifySettings? current,
void Function(NotifySettings settings)? onProgress,
bool Function()? isCancelled,
}) async {
var first = true;
Future<Result<NotifySettings>> send(
Future<Result<NotifySettings>> Function() request,
) async {
if (!first) await Future<void>.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);
}
}
68 changes: 65 additions & 3 deletions lib/features/notification/presentation/notify_controller.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -90,7 +118,7 @@ class NotifyController extends ChangeNotifier {
/// value intact.
Future<bool> 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;
Expand All @@ -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<bool> 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;
}
}
34 changes: 32 additions & 2 deletions lib/features/notification/presentation/notify_labels.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
};
Loading
Loading