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:
Tizian 「ティツィアーン」 2026-11-01 21:51:11 +01:00
commit e26ba47fb9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 264 additions and 63 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,8 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications import UserNotifications
// For FlutterLocalNotificationsPlugin.setPluginRegistrantCallback.
import flutter_local_notifications
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@ -14,6 +16,11 @@ import UserNotifications
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { 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) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
} }
} }

View file

@ -7,6 +7,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'; import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' import 'package:halkyon_todo_client/halkyon_todo_client.dart'
show Client, UuidValue; 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/sync_content_key_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart'; import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
@ -402,7 +403,16 @@ class TodoDevice({
/// reminders. /// reminders.
Future<ReminderGateway> _reminders() async { Future<ReminderGateway> _reminders() async {
try { 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) { } on Object catch (error) {
log('No reminders on this device: $error', name: 'Reminders'); log('No reminders on this device: $error', name: 'Reminders');
return const NoReminders(); return const NoReminders();

View file

@ -8,17 +8,15 @@ import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart';
import 'package:timezone/timezone.dart' as tz; import 'package:timezone/timezone.dart' as tz;
/// A reminder snoozed from its notification while the app isn't running /// A reminder snoozed from its notification while the app isn't running
/// (Android, ADR 0033, D4): scheduled again from what the notification /// (Android, iOS, ADR 0033, D4): scheduled again from what the
/// carries, in an isolate of its own, where nothing of the person's opens. /// notification carries, in an isolate of its own, where nothing of the
/// person's opens.
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> snoozeInBackground(NotificationResponse response) async { Future<void> snoozeInBackground(NotificationResponse response) =>
if (response.actionId != snoozeAction) { _scheduleSnoozed(FlutterLocalNotificationsPlugin(), response);
return;
}
await _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( Future<void> _scheduleSnoozed(
FlutterLocalNotificationsPlugin plugin, FlutterLocalNotificationsPlugin plugin,
NotificationResponse response, { NotificationResponse response, {
@ -29,15 +27,28 @@ Future<void> _scheduleSnoozed(
)? )?
showLater, showLater,
}) async { }) async {
final back = snoozed( final pressed = snoozeOf(
id: response.id ?? 0, actionId: response.actionId,
id: response.id,
payload: response.payload, payload: response.payload,
);
if (pressed == null) {
return;
}
final back = snoozed(
id: pressed.id,
payload: pressed.payload,
now: DateTime.now(), now: DateTime.now(),
); );
if (back == null) { if (back == null) {
return; 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) { if (showLater != null) {
showLater(back.notice, details, back.payload); showLater(back.notice, details, back.payload);
return; return;
@ -59,32 +70,56 @@ Future<void> _scheduleSnoozed(
} }
/// How a reminder shows: in the channel [channel], high, private on a /// How a reminder shows: in the channel [channel], high, private on a
/// locked screen; with the Snooze action where [snooze] is given (Android, /// locked screen; with the Snooze action where [snooze] is given (D4): on
/// Linux; iOS, macOS and Windows to come). /// Android and Linux given with it, on Apple's systems its category's
NotificationDetails _details(String channel, ReminderSnooze? snooze) => /// ([snoozeCategory]), on Windows a button carrying the notification's
NotificationDetails( /// [id] and [payload].
android: AndroidNotificationDetails( NotificationDetails _details(
'reminders', String channel,
channel, ReminderSnooze? snooze, {
importance: Importance.high, required int id,
priority: Priority.high, required String? payload,
category: AndroidNotificationCategory.reminder, }) => NotificationDetails(
// Its title is the task's: not on a locked screen. android: AndroidNotificationDetails(
visibility: NotificationVisibility.private, 'reminders',
actions: [ channel,
if (snooze != null) importance: Importance.high,
AndroidNotificationAction(snoozeAction, snooze.label), priority: Priority.high,
], category: AndroidNotificationCategory.reminder,
), // Its title is the task's: not on a locked screen.
iOS: const DarwinNotificationDetails(), visibility: NotificationVisibility.private,
macOS: const DarwinNotificationDetails(), actions: [
linux: LinuxNotificationDetails( if (snooze != null) AndroidNotificationAction(snoozeAction, snooze.label),
actions: [ ],
if (snooze != null) ),
LinuxNotificationAction(key: snoozeAction, label: 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 /// The system's notifications through `flutter_local_notifications` (ADR
/// 0033, D5): scheduled with the system on Android, iOS, macOS and Windows, /// 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 /// 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. /// 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 /// A reminder has a Snooze action (D4): the reminder comes back so many
/// comes back so many minutes later, scheduled from what its notification /// minutes later, scheduled from what its notification carries
/// carries ([snoozePayload]), on Android with the app closed too; the plan /// ([snoozePayload]); with the app closed too, on Android and iOS in an
/// of the person's reminders leaves the snoozed ones alone. /// 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) class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
implements ReminderGateway { implements ReminderGateway {
final _timers = <int, Timer>{}; final _timers = <int, Timer>{};
@ -109,9 +147,6 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
/// A notification's action, the app running. /// A notification's action, the app running.
Future<void> _respond(NotificationResponse response) async { Future<void> _respond(NotificationResponse response) async {
if (response.actionId != snoozeAction) {
return;
}
await _scheduleSnoozed( await _scheduleSnoozed(
_plugin, _plugin,
response, response,
@ -143,9 +178,22 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
} }
/// Starts the plugin; [appName] and the Windows IDs name the app where the /// Starts the plugin; [appName] and the Windows IDs name the app where the
/// system shows whose a notification is. /// system shows whose a notification is, and [snoozeLabel] the Snooze
static Future<SystemReminderGateway> create({required String appName}) async { /// 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 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; late final SystemReminderGateway gateway;
await plugin.initialize( await plugin.initialize(
onDidReceiveNotificationResponse: (response) => onDidReceiveNotificationResponse: (response) =>
@ -154,15 +202,17 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
settings: InitializationSettings( settings: InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'), android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Asked for when the person first adds a reminder, not at start. // Asked for when the person first adds a reminder, not at start.
iOS: const DarwinInitializationSettings( iOS: DarwinInitializationSettings(
requestAlertPermission: false, requestAlertPermission: false,
requestBadgePermission: false, requestBadgePermission: false,
requestSoundPermission: false, requestSoundPermission: false,
notificationCategories: categories,
), ),
macOS: const DarwinInitializationSettings( macOS: DarwinInitializationSettings(
requestAlertPermission: false, requestAlertPermission: false,
requestBadgePermission: false, requestBadgePermission: false,
requestSoundPermission: false, requestSoundPermission: false,
notificationCategories: categories,
), ),
linux: const LinuxInitializationSettings(defaultActionName: 'Open'), linux: const LinuxInitializationSettings(defaultActionName: 'Open'),
windows: WindowsInitializationSettings( 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; bool get _linux => defaultTargetPlatform == TargetPlatform.linux;
@ -192,7 +250,6 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
), ),
null => null, null => null,
}; };
final details = _details(channel, how);
String? payloadOf(ReminderNotice notice) => switch (how) { String? payloadOf(ReminderNotice notice) => switch (how) {
final how? => snoozePayload( final how? => snoozePayload(
title: notice.title, title: notice.title,
@ -201,9 +258,11 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
), ),
null => null, null => null,
}; };
NotificationDetails detailsOf(ReminderNotice notice) =>
_details(channel, how, id: notice.id, payload: payloadOf(notice));
final ids = {for (final notice in notices) notice.id}; final ids = {for (final notice in notices) notice.id};
if (_linux) { if (_linux) {
_replaceTimers(notices, details, payloadOf); _replaceTimers(notices, detailsOf, payloadOf);
return; return;
} }
try { try {
@ -222,7 +281,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
title: notice.title, title: notice.title,
body: notice.body, body: notice.body,
scheduledDate: tz.TZDateTime.from(notice.at.toUtc(), tz.UTC), scheduledDate: tz.TZDateTime.from(notice.at.toUtc(), tz.UTC),
notificationDetails: details, notificationDetails: detailsOf(notice),
androidScheduleMode: mode, androidScheduleMode: mode,
payload: payloadOf(notice), payload: payloadOf(notice),
); );
@ -246,7 +305,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
void _replaceTimers( void _replaceTimers(
List<ReminderNotice> notices, List<ReminderNotice> notices,
NotificationDetails details, NotificationDetails Function(ReminderNotice notice) detailsOf,
String? Function(ReminderNotice notice) payloadOf, String? Function(ReminderNotice notice) payloadOf,
) { ) {
for (final timer in _timers.values) { for (final timer in _timers.values) {
@ -262,7 +321,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
id: notice.id, id: notice.id,
title: notice.title, title: notice.title,
body: notice.body, body: notice.body,
notificationDetails: details, notificationDetails: detailsOf(notice),
payload: payloadOf(notice), payload: payloadOf(notice),
), ),
); );

View file

@ -11,6 +11,55 @@ typedef ReminderSnooze = ({int minutes, String label, String channel});
/// The action a reminder's notification is snoozed with. /// The action a reminder's notification is snoozed with.
const snoozeAction = 'snooze'; 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 /// What a snoozable reminder's notification carries: its words and how it
/// is snoozed; [again] for one snoozed already, which the plan of the /// is snoozed; [again] for one snoozed already, which the plan of the
/// person's reminders leaves alone. /// person's reminders leaves alone.

View file

@ -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/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/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/reminder/widget/reminder_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.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'; 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. /// The automatic reminders offered, minutes before a task's time.
const automaticReminderChoices = [0, 5, 10, 15, 30, 60]; 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 /// Settings' *Tasks* (ADR 0033, D4): the weekday weeks start on (the
/// language's unless set), and whether a recurring task done opens its /// 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. /// sub-tasks again; kept in the person's sealed settings, on every device.

View file

@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; 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/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 /// 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 /// that the system can bring it back with the app closed, and what comes
@ -40,4 +41,71 @@ void main() {
expect(isSnoozed(payload), isFalse); 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);
});
});
} }

View file

@ -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`), 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 so on Android with the app closed too (`snoozeInBackground`, the plugin's
`ActionBroadcastReceiver` declared), and on Linux by the running app, as its reminders; `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 the plan of the person's reminders leaves the snoozed ones alone. On iOS, macOS and
register a notification's actions once, as the app starts and before its texts are Windows since 1 Nov 2026 (the developer asked for it): Apple's systems take a
known, and Windows answers an action by starting the app: Snooze comes there later. 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 - **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 `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, data key (`content_key`, halkyon_app's `PayloadEnvelope`), nothing readable in a column,