feat(todo): Snooze on a reminder on iOS, macOS and Windows (ADR 0033, D4)
Apple's systems take actions from categories registered at start: one for each length a snooze can be, its action in the language the app starts in, not to the foreground, so that iOS snoozes in the plugin's isolate with the app closed (AppDelegate registers the plugins for it). Windows hands back only the button's arguments, which carry the notification's ID and payload. macOS and Windows launch the app for an action while it is closed, and the gateway snoozes what launched it as it starts. Not yet tried on any of the three devices. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
0b6f79244f
commit
e26ba47fb9
8 changed files with 264 additions and 63 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,8 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
// For FlutterLocalNotificationsPlugin.setPluginRegistrantCallback.
|
||||
import flutter_local_notifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
|
|
@ -14,6 +16,11 @@ import UserNotifications
|
|||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
// A reminder snoozed with the app closed is scheduled again in an
|
||||
// isolate of the plugin's, which needs the plugins too (ADR 0033, D4).
|
||||
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart'
|
||||
show Client, UuidValue;
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
|
||||
|
|
@ -402,7 +403,16 @@ class TodoDevice({
|
|||
/// reminders.
|
||||
Future<ReminderGateway> _reminders() async {
|
||||
try {
|
||||
return await SystemReminderGateway.create(appName: 'Halkyon Todo');
|
||||
final l10n = lookupAppLocalizations(
|
||||
resolveAppLocale(
|
||||
WidgetsBinding.instance.platformDispatcher.locales,
|
||||
AppLocalizations.supportedLocales,
|
||||
),
|
||||
);
|
||||
return await SystemReminderGateway.create(
|
||||
appName: 'Halkyon Todo',
|
||||
snoozeLabel: l10n.reminderSnooze,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('No reminders on this device: $error', name: 'Reminders');
|
||||
return const NoReminders();
|
||||
|
|
|
|||
|
|
@ -8,17 +8,15 @@ import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart';
|
|||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
/// A reminder snoozed from its notification while the app isn't running
|
||||
/// (Android, ADR 0033, D4): scheduled again from what the notification
|
||||
/// carries, in an isolate of its own, where nothing of the person's opens.
|
||||
/// (Android, iOS, ADR 0033, D4): scheduled again from what the
|
||||
/// notification carries, in an isolate of its own, where nothing of the
|
||||
/// person's opens.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> snoozeInBackground(NotificationResponse response) async {
|
||||
if (response.actionId != snoozeAction) {
|
||||
return;
|
||||
}
|
||||
await _scheduleSnoozed(FlutterLocalNotificationsPlugin(), response);
|
||||
}
|
||||
Future<void> snoozeInBackground(NotificationResponse response) =>
|
||||
_scheduleSnoozed(FlutterLocalNotificationsPlugin(), response);
|
||||
|
||||
/// Schedules again the reminder [response] snoozed, where it carries how.
|
||||
/// Schedules again the reminder [response] snoozed, where it says Snooze
|
||||
/// was pressed ([snoozeOf]) and carries how.
|
||||
Future<void> _scheduleSnoozed(
|
||||
FlutterLocalNotificationsPlugin plugin,
|
||||
NotificationResponse response, {
|
||||
|
|
@ -29,15 +27,28 @@ Future<void> _scheduleSnoozed(
|
|||
)?
|
||||
showLater,
|
||||
}) async {
|
||||
final back = snoozed(
|
||||
id: response.id ?? 0,
|
||||
final pressed = snoozeOf(
|
||||
actionId: response.actionId,
|
||||
id: response.id,
|
||||
payload: response.payload,
|
||||
);
|
||||
if (pressed == null) {
|
||||
return;
|
||||
}
|
||||
final back = snoozed(
|
||||
id: pressed.id,
|
||||
payload: pressed.payload,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
if (back == null) {
|
||||
return;
|
||||
}
|
||||
final details = _details(back.snooze.channel, back.snooze);
|
||||
final details = _details(
|
||||
back.snooze.channel,
|
||||
back.snooze,
|
||||
id: back.notice.id,
|
||||
payload: back.payload,
|
||||
);
|
||||
if (showLater != null) {
|
||||
showLater(back.notice, details, back.payload);
|
||||
return;
|
||||
|
|
@ -59,32 +70,56 @@ Future<void> _scheduleSnoozed(
|
|||
}
|
||||
|
||||
/// How a reminder shows: in the channel [channel], high, private on a
|
||||
/// locked screen; with the Snooze action where [snooze] is given (Android,
|
||||
/// Linux; iOS, macOS and Windows to come).
|
||||
NotificationDetails _details(String channel, ReminderSnooze? snooze) =>
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'reminders',
|
||||
channel,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.reminder,
|
||||
// Its title is the task's: not on a locked screen.
|
||||
visibility: NotificationVisibility.private,
|
||||
actions: [
|
||||
if (snooze != null)
|
||||
AndroidNotificationAction(snoozeAction, snooze.label),
|
||||
],
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
macOS: const DarwinNotificationDetails(),
|
||||
linux: LinuxNotificationDetails(
|
||||
actions: [
|
||||
if (snooze != null)
|
||||
LinuxNotificationAction(key: snoozeAction, label: snooze.label),
|
||||
],
|
||||
),
|
||||
);
|
||||
/// locked screen; with the Snooze action where [snooze] is given (D4): on
|
||||
/// Android and Linux given with it, on Apple's systems its category's
|
||||
/// ([snoozeCategory]), on Windows a button carrying the notification's
|
||||
/// [id] and [payload].
|
||||
NotificationDetails _details(
|
||||
String channel,
|
||||
ReminderSnooze? snooze, {
|
||||
required int id,
|
||||
required String? payload,
|
||||
}) => NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'reminders',
|
||||
channel,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.reminder,
|
||||
// Its title is the task's: not on a locked screen.
|
||||
visibility: NotificationVisibility.private,
|
||||
actions: [
|
||||
if (snooze != null) AndroidNotificationAction(snoozeAction, snooze.label),
|
||||
],
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
categoryIdentifier: switch (snooze) {
|
||||
final snooze? => snoozeCategory(snooze.minutes),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
macOS: DarwinNotificationDetails(
|
||||
categoryIdentifier: switch (snooze) {
|
||||
final snooze? => snoozeCategory(snooze.minutes),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
linux: LinuxNotificationDetails(
|
||||
actions: [
|
||||
if (snooze != null)
|
||||
LinuxNotificationAction(key: snoozeAction, label: snooze.label),
|
||||
],
|
||||
),
|
||||
windows: WindowsNotificationDetails(
|
||||
actions: [
|
||||
if ((snooze, payload) case (final snooze?, final payload?))
|
||||
WindowsAction(
|
||||
content: snooze.label,
|
||||
arguments: windowsSnoozeArguments(id: id, payload: payload),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// The system's notifications through `flutter_local_notifications` (ADR
|
||||
/// 0033, D5): scheduled with the system on Android, iOS, macOS and Windows,
|
||||
|
|
@ -95,10 +130,13 @@ NotificationDetails _details(String channel, ReminderSnooze? snooze) =>
|
|||
/// clock, and the app plans again whenever it runs, so a device moved to
|
||||
/// another zone has its reminders moved with it once the app is opened.
|
||||
///
|
||||
/// A reminder has a Snooze action on Android and Linux (D4): the reminder
|
||||
/// comes back so many minutes later, scheduled from what its notification
|
||||
/// carries ([snoozePayload]), on Android with the app closed too; the plan
|
||||
/// of the person's reminders leaves the snoozed ones alone.
|
||||
/// A reminder has a Snooze action (D4): the reminder comes back so many
|
||||
/// minutes later, scheduled from what its notification carries
|
||||
/// ([snoozePayload]); with the app closed too, on Android and iOS in an
|
||||
/// isolate of the plugin's ([snoozeInBackground]), on macOS and Windows,
|
||||
/// which launch the app with it, as it starts; on Linux only while the
|
||||
/// app runs. The plan of the person's reminders leaves the snoozed ones
|
||||
/// alone.
|
||||
class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
||||
implements ReminderGateway {
|
||||
final _timers = <int, Timer>{};
|
||||
|
|
@ -109,9 +147,6 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
|
||||
/// A notification's action, the app running.
|
||||
Future<void> _respond(NotificationResponse response) async {
|
||||
if (response.actionId != snoozeAction) {
|
||||
return;
|
||||
}
|
||||
await _scheduleSnoozed(
|
||||
_plugin,
|
||||
response,
|
||||
|
|
@ -143,9 +178,22 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
}
|
||||
|
||||
/// Starts the plugin; [appName] and the Windows IDs name the app where the
|
||||
/// system shows whose a notification is.
|
||||
static Future<SystemReminderGateway> create({required String appName}) async {
|
||||
/// system shows whose a notification is, and [snoozeLabel] the Snooze
|
||||
/// action of Apple's categories, registered now, in the language the app
|
||||
/// starts in. A Snooze that launched the app is done now.
|
||||
static Future<SystemReminderGateway> create({
|
||||
required String appName,
|
||||
required String Function(int minutes) snoozeLabel,
|
||||
}) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
final categories = [
|
||||
for (final (:id, :label) in snoozeCategories(snoozeLabel))
|
||||
DarwinNotificationCategory(
|
||||
id,
|
||||
// Not to the foreground: on iOS snoozed in the background.
|
||||
actions: [DarwinNotificationAction.plain(snoozeAction, label)],
|
||||
),
|
||||
];
|
||||
late final SystemReminderGateway gateway;
|
||||
await plugin.initialize(
|
||||
onDidReceiveNotificationResponse: (response) =>
|
||||
|
|
@ -154,15 +202,17 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
settings: InitializationSettings(
|
||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
// Asked for when the person first adds a reminder, not at start.
|
||||
iOS: const DarwinInitializationSettings(
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
notificationCategories: categories,
|
||||
),
|
||||
macOS: const DarwinInitializationSettings(
|
||||
macOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
notificationCategories: categories,
|
||||
),
|
||||
linux: const LinuxInitializationSettings(defaultActionName: 'Open'),
|
||||
windows: WindowsInitializationSettings(
|
||||
|
|
@ -173,7 +223,15 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
),
|
||||
),
|
||||
);
|
||||
return gateway = SystemReminderGateway(plugin);
|
||||
gateway = SystemReminderGateway(plugin);
|
||||
final launch = await plugin.getNotificationAppLaunchDetails();
|
||||
if (launch case NotificationAppLaunchDetails(
|
||||
didNotificationLaunchApp: true,
|
||||
:final notificationResponse?,
|
||||
)) {
|
||||
await gateway._respond(notificationResponse);
|
||||
}
|
||||
return gateway;
|
||||
}
|
||||
|
||||
bool get _linux => defaultTargetPlatform == TargetPlatform.linux;
|
||||
|
|
@ -192,7 +250,6 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
),
|
||||
null => null,
|
||||
};
|
||||
final details = _details(channel, how);
|
||||
String? payloadOf(ReminderNotice notice) => switch (how) {
|
||||
final how? => snoozePayload(
|
||||
title: notice.title,
|
||||
|
|
@ -201,9 +258,11 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
),
|
||||
null => null,
|
||||
};
|
||||
NotificationDetails detailsOf(ReminderNotice notice) =>
|
||||
_details(channel, how, id: notice.id, payload: payloadOf(notice));
|
||||
final ids = {for (final notice in notices) notice.id};
|
||||
if (_linux) {
|
||||
_replaceTimers(notices, details, payloadOf);
|
||||
_replaceTimers(notices, detailsOf, payloadOf);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -222,7 +281,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
title: notice.title,
|
||||
body: notice.body,
|
||||
scheduledDate: tz.TZDateTime.from(notice.at.toUtc(), tz.UTC),
|
||||
notificationDetails: details,
|
||||
notificationDetails: detailsOf(notice),
|
||||
androidScheduleMode: mode,
|
||||
payload: payloadOf(notice),
|
||||
);
|
||||
|
|
@ -246,7 +305,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
|
||||
void _replaceTimers(
|
||||
List<ReminderNotice> notices,
|
||||
NotificationDetails details,
|
||||
NotificationDetails Function(ReminderNotice notice) detailsOf,
|
||||
String? Function(ReminderNotice notice) payloadOf,
|
||||
) {
|
||||
for (final timer in _timers.values) {
|
||||
|
|
@ -262,7 +321,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
id: notice.id,
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
notificationDetails: details,
|
||||
notificationDetails: detailsOf(notice),
|
||||
payload: payloadOf(notice),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,55 @@ typedef ReminderSnooze = ({int minutes, String label, String channel});
|
|||
/// The action a reminder's notification is snoozed with.
|
||||
const snoozeAction = 'snooze';
|
||||
|
||||
/// How long a reminder may be snoozed for, in minutes (Settings' choices).
|
||||
const snoozeChoices = [5, 10, 15, 30, 60];
|
||||
|
||||
/// The category of Apple's notifications snoozed for [minutes] (D4): their
|
||||
/// actions are registered as the app starts, not given with each
|
||||
/// notification, so there is one for each of [snoozeChoices]; none for
|
||||
/// another length, which gets no Snooze there.
|
||||
String? snoozeCategory(int minutes) =>
|
||||
snoozeChoices.contains(minutes) ? 'reminder-snooze-$minutes' : null;
|
||||
|
||||
/// Apple's categories (D4), each with its Snooze action's words from
|
||||
/// [labelOf].
|
||||
List<({String id, String label})> snoozeCategories(
|
||||
String Function(int minutes) labelOf,
|
||||
) => [
|
||||
for (final minutes in snoozeChoices)
|
||||
(id: snoozeCategory(minutes)!, label: labelOf(minutes)),
|
||||
];
|
||||
|
||||
/// What Snooze's button carries on Windows (D4), which hands back the
|
||||
/// button's arguments alone: the action, the notification's [id] and its
|
||||
/// [payload].
|
||||
String windowsSnoozeArguments({required int id, required String payload}) =>
|
||||
'$snoozeAction:$id:$payload';
|
||||
|
||||
/// The notification snoozed, where a response says Snooze was pressed: as
|
||||
/// Android, Linux and Apple's systems say it, [actionId] the action with
|
||||
/// the notification's [id] and [payload]; as Windows does, [actionId] the
|
||||
/// button's arguments ([windowsSnoozeArguments]). None for anything else.
|
||||
({int id, String payload})? snoozeOf({
|
||||
required String? actionId,
|
||||
required int? id,
|
||||
required String? payload,
|
||||
}) {
|
||||
if (actionId == snoozeAction && payload != null) {
|
||||
return (id: id ?? 0, payload: payload);
|
||||
}
|
||||
if (actionId != null && actionId.startsWith('$snoozeAction:')) {
|
||||
final rest = actionId.substring(snoozeAction.length + 1);
|
||||
final colon = rest.indexOf(':');
|
||||
if (colon > 0) {
|
||||
if (int.tryParse(rest.substring(0, colon)) case final id?) {
|
||||
return (id: id, payload: rest.substring(colon + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// What a snoozable reminder's notification carries: its words and how it
|
||||
/// is snoozed; [again] for one snoozed already, which the plan of the
|
||||
/// person's reminders leaves alone.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -14,9 +15,6 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// The automatic reminders offered, minutes before a task's time.
|
||||
const automaticReminderChoices = [0, 5, 10, 15, 30, 60];
|
||||
|
||||
/// How long a reminder may be snoozed for, in minutes.
|
||||
const snoozeChoices = [5, 10, 15, 30, 60];
|
||||
|
||||
/// Settings' *Tasks* (ADR 0033, D4): the weekday weeks start on (the
|
||||
/// language's unless set), and whether a recurring task done opens its
|
||||
/// sub-tasks again; kept in the person's sealed settings, on every device.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
|
||||
|
||||
/// A reminder snoozed (ADR 0033, D4): what its notification carries, so
|
||||
/// that the system can bring it back with the app closed, and what comes
|
||||
|
|
@ -40,4 +41,71 @@ void main() {
|
|||
expect(isSnoozed(payload), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
group('the systems’ ways of saying Snooze was pressed (iOS, macOS, '
|
||||
'Windows)', () {
|
||||
final payload = snoozePayload(
|
||||
title: reminder.title,
|
||||
body: reminder.body,
|
||||
snooze: snooze,
|
||||
);
|
||||
|
||||
test('Android’s, Linux’s and Apple’s: the action, with the '
|
||||
'notification’s ID and payload', () {
|
||||
expect(
|
||||
snoozeOf(actionId: snoozeAction, id: 41, payload: payload),
|
||||
(id: 41, payload: payload),
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows’: the button’s arguments alone, carrying both', () {
|
||||
final arguments = windowsSnoozeArguments(id: 41, payload: payload);
|
||||
expect(
|
||||
snoozeOf(actionId: arguments, id: null, payload: arguments),
|
||||
(id: 41, payload: payload),
|
||||
);
|
||||
expect(
|
||||
snoozed(id: 41, payload: payload, now: now)!.notice.id,
|
||||
snoozed(
|
||||
id: snoozeOf(actionId: arguments, id: null, payload: arguments)!.id,
|
||||
payload: payload,
|
||||
now: now,
|
||||
)!.notice.id,
|
||||
reason: 'snoozed under the same ID however it was pressed',
|
||||
);
|
||||
});
|
||||
|
||||
test('anything else is no snooze: the notification tapped, another '
|
||||
'action, arguments broken', () {
|
||||
for (final (actionId, id) in [
|
||||
(null, 41),
|
||||
('', 41),
|
||||
('open', 41),
|
||||
(payload, null),
|
||||
('$snoozeAction:', null),
|
||||
('$snoozeAction:x:$payload', null),
|
||||
]) {
|
||||
expect(
|
||||
snoozeOf(actionId: actionId, id: id, payload: payload),
|
||||
isNull,
|
||||
reason: '$actionId',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Apple’s categories: one for each length a snooze can be, its '
|
||||
'action named for it; none for another length', () {
|
||||
expect(snoozeChoices, contains(const ReminderSettings().snooze));
|
||||
final categories = snoozeCategories((minutes) => 'Snooze $minutes min');
|
||||
expect(categories.map((c) => c.id), [
|
||||
for (final minutes in snoozeChoices) snoozeCategory(minutes),
|
||||
]);
|
||||
expect(
|
||||
categories.firstWhere((c) => c.id == snoozeCategory(10)).label,
|
||||
'Snooze 10 min',
|
||||
);
|
||||
expect(snoozeCategory(10), isNot(snoozeCategory(15)));
|
||||
expect(snoozeCategory(7), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,9 +94,19 @@ the rail, a wash of strawberry, settings grouped as theirs. What the screenshots
|
|||
under an ID of its own, scheduled from what the notification carries (`snooze.dart`),
|
||||
so on Android with the app closed too (`snoozeInBackground`, the plugin's
|
||||
`ActionBroadcastReceiver` declared), and on Linux by the running app, as its reminders;
|
||||
the plan of the person's reminders leaves the snoozed ones alone. iOS and macOS
|
||||
register a notification's actions once, as the app starts and before its texts are
|
||||
known, and Windows answers an action by starting the app: Snooze comes there later.
|
||||
the plan of the person's reminders leaves the snoozed ones alone. On iOS, macOS and
|
||||
Windows since 1 Nov 2026 (the developer asked for it): Apple's systems take a
|
||||
notification's actions from categories registered as the app starts, so there is one
|
||||
for each length a snooze can be (`snoozeCategory`, `snoozeCategories`), its action
|
||||
in the language the app starts in (the device's, as `resolveAppLocale` picks it; a
|
||||
language changed meanwhile shows from the next start) and not to the foreground, so
|
||||
that on iOS the plugin's isolate snoozes with the app closed, as on Android
|
||||
(`AppDelegate` registers the plugins for it). Windows hands back only the button's
|
||||
arguments, so the button carries the notification's ID and payload
|
||||
(`windowsSnoozeArguments`, read by `snoozeOf` as the others' action). macOS and
|
||||
Windows launch the app for an action while it is closed: the gateway snoozes what
|
||||
launched it as it starts. Built and tested on Linux alone: none of the three was
|
||||
tried on a device yet.
|
||||
- **D5 Todo's rows are sealed, always, as Train's (ADR 0030, D7, D15)**, synced with
|
||||
`serverpod_offline_sync`: every payload ADR 0017's sealed content under the person's
|
||||
data key (`content_key`, halkyon_app's `PayloadEnvelope`), nothing readable in a column,
|
||||
|
|
|
|||
Loading…
Reference in a new issue