feat(todo): Settings' Tasks and Reminders, with Snooze on a reminder (ADR 0033, D4)
Settings gains Todo's own groups, kept in the person's sealed settings row on every device. Tasks: the weekday weeks start on (the language's unless set), which the week strip, the date and repeat pickers and the filters follow; and whether a recurring task done opens its sub-tasks again (on at first). Reminders: an automatic reminder (none at first) that TaskRepository writes into a task gaining a time without reminders of its own, and how long a reminder is snoozed for (15 minutes at first). Each reminder's notification has a Snooze action on Android and Linux: the reminder comes back that much later under an ID of its own, scheduled from what the notification carries, so on Android with the app closed too (the plugin's ActionBroadcastReceiver declared); the plan of the person's reminders leaves snoozed ones alone. iOS, macOS and Windows get it later. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
2939437c74
commit
51ebed41cd
31 changed files with 1204 additions and 69 deletions
File diff suppressed because one or more lines are too long
|
|
@ -49,6 +49,8 @@
|
|||
<!-- Shows the reminders scheduled, and schedules them again after a
|
||||
restart or an update (flutter_local_notifications). -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<!-- A reminder's Snooze action (ADR 0033, D4), the app closed too. -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
|
|
|
|||
|
|
@ -220,5 +220,16 @@
|
|||
"coloringProject": "Projekt",
|
||||
"allDay": "Ganztägig",
|
||||
"daysPrevious": "Frühere Tage",
|
||||
"daysNext": "Spätere Tage"
|
||||
"daysNext": "Spätere Tage",
|
||||
"settingsTasks": "Aufgaben",
|
||||
"weekStartTitle": "Woche beginnt am",
|
||||
"weekStartLanguage": "Wie in der Sprache",
|
||||
"resetSubTasksTitle": "Unteraufgaben zurücksetzen",
|
||||
"resetSubTasksHint": "Wieder öffnen, wenn eine wiederkehrende Aufgabe erledigt ist",
|
||||
"automaticReminderTitle": "Automatische Erinnerung",
|
||||
"automaticReminderHint": "Für eine Aufgabe mit Uhrzeit, wenn sie keine eigene Erinnerung hat",
|
||||
"automaticReminderNone": "Keine",
|
||||
"snoozeTitle": "Schlummern für",
|
||||
"snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
|
||||
"reminderSnooze": "{count} Min. schlummern"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -680,5 +680,59 @@
|
|||
"daysNext": "Later days",
|
||||
"@daysNext": {
|
||||
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
|
||||
},
|
||||
"settingsTasks": "Tasks",
|
||||
"@settingsTasks": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"weekStartTitle": "Start the week on",
|
||||
"@weekStartTitle": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"weekStartLanguage": "As the language has it",
|
||||
"@weekStartLanguage": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"resetSubTasksTitle": "Reset sub-tasks",
|
||||
"@resetSubTasksTitle": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"resetSubTasksHint": "Open them again when a recurring task is done",
|
||||
"@resetSubTasksHint": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"automaticReminderTitle": "Automatic reminder",
|
||||
"@automaticReminderTitle": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"automaticReminderHint": "For a task given a time, unless it has reminders of its own",
|
||||
"@automaticReminderHint": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"automaticReminderNone": "None",
|
||||
"@automaticReminderNone": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"snoozeTitle": "When snoozed",
|
||||
"@snoozeTitle": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4)."
|
||||
},
|
||||
"snoozeMinutes": "{count, plural, =1{1 minute} other{{count} minutes}}",
|
||||
"@snoozeMinutes": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4).",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reminderSnooze": "Snooze {count} min",
|
||||
"@reminderSnooze": {
|
||||
"description": "Settings of tasks and reminders (ADR 0033, D4).",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1425,6 +1425,72 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Later days'**
|
||||
String get daysNext;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tasks'**
|
||||
String get settingsTasks;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start the week on'**
|
||||
String get weekStartTitle;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'As the language has it'**
|
||||
String get weekStartLanguage;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset sub-tasks'**
|
||||
String get resetSubTasksTitle;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open them again when a recurring task is done'**
|
||||
String get resetSubTasksHint;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatic reminder'**
|
||||
String get automaticReminderTitle;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For a task given a time, unless it has reminders of its own'**
|
||||
String get automaticReminderHint;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get automaticReminderNone;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'When snoozed'**
|
||||
String get snoozeTitle;
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 minute} other{{count} minutes}}'**
|
||||
String snoozeMinutes(int count);
|
||||
|
||||
/// Settings of tasks and reminders (ADR 0033, D4).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Snooze {count} min'**
|
||||
String reminderSnooze(int count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -876,4 +876,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get daysNext => 'Spätere Tage';
|
||||
|
||||
@override
|
||||
String get settingsTasks => 'Aufgaben';
|
||||
|
||||
@override
|
||||
String get weekStartTitle => 'Woche beginnt am';
|
||||
|
||||
@override
|
||||
String get weekStartLanguage => 'Wie in der Sprache';
|
||||
|
||||
@override
|
||||
String get resetSubTasksTitle => 'Unteraufgaben zurücksetzen';
|
||||
|
||||
@override
|
||||
String get resetSubTasksHint =>
|
||||
'Wieder öffnen, wenn eine wiederkehrende Aufgabe erledigt ist';
|
||||
|
||||
@override
|
||||
String get automaticReminderTitle => 'Automatische Erinnerung';
|
||||
|
||||
@override
|
||||
String get automaticReminderHint =>
|
||||
'Für eine Aufgabe mit Uhrzeit, wenn sie keine eigene Erinnerung hat';
|
||||
|
||||
@override
|
||||
String get automaticReminderNone => 'Keine';
|
||||
|
||||
@override
|
||||
String get snoozeTitle => 'Schlummern für';
|
||||
|
||||
@override
|
||||
String snoozeMinutes(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Minuten',
|
||||
one: '1 Minute',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String reminderSnooze(int count) {
|
||||
return '$count Min. schlummern';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -875,6 +875,51 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get daysNext => 'Later days';
|
||||
|
||||
@override
|
||||
String get settingsTasks => 'Tasks';
|
||||
|
||||
@override
|
||||
String get weekStartTitle => 'Start the week on';
|
||||
|
||||
@override
|
||||
String get weekStartLanguage => 'As the language has it';
|
||||
|
||||
@override
|
||||
String get resetSubTasksTitle => 'Reset sub-tasks';
|
||||
|
||||
@override
|
||||
String get resetSubTasksHint =>
|
||||
'Open them again when a recurring task is done';
|
||||
|
||||
@override
|
||||
String get automaticReminderTitle => 'Automatic reminder';
|
||||
|
||||
@override
|
||||
String get automaticReminderHint =>
|
||||
'For a task given a time, unless it has reminders of its own';
|
||||
|
||||
@override
|
||||
String get automaticReminderNone => 'None';
|
||||
|
||||
@override
|
||||
String get snoozeTitle => 'When snoozed';
|
||||
|
||||
@override
|
||||
String snoozeMinutes(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes',
|
||||
one: '1 minute',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String reminderSnooze(int count) {
|
||||
return 'Snooze $count min';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -321,9 +321,13 @@ Future<TodoPerson> _openPersonData(
|
|||
await keys.refresh();
|
||||
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
|
||||
final sections = SectionRepository(SyncSectionGateway(db, sync), keys);
|
||||
final tasks = TaskRepository(SyncTaskGateway(db, sync), keys);
|
||||
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync), keys);
|
||||
final tasks = TaskRepository(
|
||||
SyncTaskGateway(db, sync),
|
||||
keys,
|
||||
automaticReminder: () async => (await settings.read()).reminders.automatic,
|
||||
);
|
||||
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
|
||||
// The Inbox, made once by whichever device is first; nothing where this
|
||||
// device holds none of the account's keys.
|
||||
unawaited(
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class const ProjectCalendarView({
|
|||
final today = Day.of(clock.now());
|
||||
final weekStart = WeekStrip.weekOf(
|
||||
today,
|
||||
MaterialLocalizations.of(context).firstDayOfWeekIndex,
|
||||
firstWeekdayOf(context),
|
||||
);
|
||||
return [
|
||||
for (final task in state.tasks)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
final today = Day.of(clock.now());
|
||||
final weekStart = WeekStrip.weekOf(
|
||||
today,
|
||||
MaterialLocalizations.of(context).firstDayOfWeekIndex,
|
||||
firstWeekdayOf(context),
|
||||
);
|
||||
// The project's tasks of [sectionId] its Display keeps, in its order.
|
||||
List<OpenTask> kept({String? sectionId}) => [
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@ typedef ReminderNotice = ({int id, DateTime at, String title, String body});
|
|||
/// on the device alone, since the instance can read no task (D5).
|
||||
abstract interface class ReminderGateway() {
|
||||
/// Makes [notices] the reminders scheduled, those scheduled before and no
|
||||
/// longer among them cancelled; [channel] names their kind where the
|
||||
/// system lists it (Android's notification channels).
|
||||
Future<void> replace(List<ReminderNotice> notices, {required String channel});
|
||||
/// longer among them cancelled (but those snoozed); [channel] names their
|
||||
/// kind where the system lists it (Android's notification channels), and
|
||||
/// [snooze] how long a reminder is snoozed for and the action's words
|
||||
/// (D4), where the system offers it.
|
||||
Future<void> replace(
|
||||
List<ReminderNotice> notices, {
|
||||
required String channel,
|
||||
({int minutes, String label})? snooze,
|
||||
});
|
||||
|
||||
/// Asks the person to let the app show notifications, where the system
|
||||
/// asks; whether it may.
|
||||
|
|
@ -21,6 +27,7 @@ class const NoReminders() implements ReminderGateway {
|
|||
Future<void> replace(
|
||||
List<ReminderNotice> notices, {
|
||||
required String channel,
|
||||
({int minutes, String label})? snooze,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -4,8 +4,88 @@ import 'dart:developer';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
|
||||
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.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> snoozeInBackground(NotificationResponse response) async {
|
||||
if (response.actionId != snoozeAction) {
|
||||
return;
|
||||
}
|
||||
await _scheduleSnoozed(FlutterLocalNotificationsPlugin(), response);
|
||||
}
|
||||
|
||||
/// Schedules again the reminder [response] snoozed, where it carries how.
|
||||
Future<void> _scheduleSnoozed(
|
||||
FlutterLocalNotificationsPlugin plugin,
|
||||
NotificationResponse response, {
|
||||
void Function(
|
||||
ReminderNotice notice,
|
||||
NotificationDetails details,
|
||||
String payload,
|
||||
)?
|
||||
showLater,
|
||||
}) async {
|
||||
final back = snoozed(
|
||||
id: response.id ?? 0,
|
||||
payload: response.payload,
|
||||
now: DateTime.now(),
|
||||
);
|
||||
if (back == null) {
|
||||
return;
|
||||
}
|
||||
final details = _details(back.snooze.channel, back.snooze);
|
||||
if (showLater != null) {
|
||||
showLater(back.notice, details, back.payload);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await plugin.zonedSchedule(
|
||||
id: back.notice.id,
|
||||
title: back.notice.title,
|
||||
body: back.notice.body,
|
||||
scheduledDate: tz.TZDateTime.from(back.notice.at.toUtc(), tz.UTC),
|
||||
notificationDetails: details,
|
||||
// Snoozed for minutes, not to the minute: no exact alarm needed.
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
payload: back.payload,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Reminder not snoozed: $error', name: 'Reminders');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// The system's notifications through `flutter_local_notifications` (ADR
|
||||
/// 0033, D5): scheduled with the system on Android, iOS, macOS and Windows,
|
||||
/// so they come while the app is closed; on Linux, which schedules none,
|
||||
|
|
@ -14,15 +94,63 @@ import 'package:timezone/timezone.dart' as tz;
|
|||
/// Each is scheduled at its instant (in UTC): a task's time is its wall
|
||||
/// 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.
|
||||
class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
||||
implements ReminderGateway {
|
||||
final _timers = <int, Timer>{};
|
||||
|
||||
/// The reminders snoozed while the app runs on Linux, which schedules
|
||||
/// none: shown by the app, as the planned ones.
|
||||
final _snoozedTimers = <int, Timer>{};
|
||||
|
||||
/// A notification's action, the app running.
|
||||
Future<void> _respond(NotificationResponse response) async {
|
||||
if (response.actionId != snoozeAction) {
|
||||
return;
|
||||
}
|
||||
await _scheduleSnoozed(
|
||||
_plugin,
|
||||
response,
|
||||
showLater: _linux ? _showLater : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _showLater(
|
||||
ReminderNotice notice,
|
||||
NotificationDetails details,
|
||||
String payload,
|
||||
) {
|
||||
_snoozedTimers.remove(notice.id)?.cancel();
|
||||
_snoozedTimers[notice.id] = Timer(
|
||||
notice.at.difference(DateTime.now()),
|
||||
() {
|
||||
_snoozedTimers.remove(notice.id);
|
||||
unawaited(
|
||||
_plugin.show(
|
||||
id: notice.id,
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
notificationDetails: details,
|
||||
payload: payload,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
late final SystemReminderGateway gateway;
|
||||
await plugin.initialize(
|
||||
onDidReceiveNotificationResponse: (response) =>
|
||||
unawaited(gateway._respond(response)),
|
||||
onDidReceiveBackgroundNotificationResponse: snoozeInBackground,
|
||||
settings: InitializationSettings(
|
||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
// Asked for when the person first adds a reminder, not at start.
|
||||
|
|
@ -45,7 +173,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
),
|
||||
),
|
||||
);
|
||||
return SystemReminderGateway(plugin);
|
||||
return gateway = SystemReminderGateway(plugin);
|
||||
}
|
||||
|
||||
bool get _linux => defaultTargetPlatform == TargetPlatform.linux;
|
||||
|
|
@ -54,29 +182,34 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
Future<void> replace(
|
||||
List<ReminderNotice> notices, {
|
||||
required String channel,
|
||||
({int minutes, String label})? snooze,
|
||||
}) async {
|
||||
final details = 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,
|
||||
final how = switch (snooze) {
|
||||
(:final minutes, :final label) => (
|
||||
minutes: minutes,
|
||||
label: label,
|
||||
channel: channel,
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
macOS: const DarwinNotificationDetails(),
|
||||
linux: const LinuxNotificationDetails(),
|
||||
);
|
||||
null => null,
|
||||
};
|
||||
final details = _details(channel, how);
|
||||
String? payloadOf(ReminderNotice notice) => switch (how) {
|
||||
final how? => snoozePayload(
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
snooze: how,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
final ids = {for (final notice in notices) notice.id};
|
||||
if (_linux) {
|
||||
_replaceTimers(notices, details);
|
||||
_replaceTimers(notices, details, payloadOf);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (final pending in await _plugin.pendingNotificationRequests()) {
|
||||
if (!ids.contains(pending.id)) {
|
||||
// A snoozed one stays until it comes.
|
||||
if (!ids.contains(pending.id) && !isSnoozed(pending.payload)) {
|
||||
await _plugin.cancel(id: pending.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +224,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
scheduledDate: tz.TZDateTime.from(notice.at.toUtc(), tz.UTC),
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: mode,
|
||||
payload: payloadOf(notice),
|
||||
);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
|
|
@ -113,6 +247,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
void _replaceTimers(
|
||||
List<ReminderNotice> notices,
|
||||
NotificationDetails details,
|
||||
String? Function(ReminderNotice notice) payloadOf,
|
||||
) {
|
||||
for (final timer in _timers.values) {
|
||||
timer.cancel();
|
||||
|
|
@ -128,6 +263,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
|
|||
title: notice.title,
|
||||
body: notice.body,
|
||||
notificationDetails: details,
|
||||
payload: payloadOf(notice),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
|
||||
|
||||
/// How a reminder is snoozed (ADR 0033, D4): for so many `minutes`, the
|
||||
/// action's `label`, and the `channel` its notification is in, all carried
|
||||
/// in the notification, so that the system brings it back with the app
|
||||
/// closed, where the person's settings don't open.
|
||||
typedef ReminderSnooze = ({int minutes, String label, String channel});
|
||||
|
||||
/// The action a reminder's notification is snoozed with.
|
||||
const snoozeAction = 'snooze';
|
||||
|
||||
/// 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.
|
||||
String snoozePayload({
|
||||
required String title,
|
||||
required String body,
|
||||
required ReminderSnooze snooze,
|
||||
bool again = false,
|
||||
}) => jsonEncode({
|
||||
't': title,
|
||||
'b': body,
|
||||
's': snooze.minutes,
|
||||
'l': snooze.label,
|
||||
'c': snooze.channel,
|
||||
if (again) 'z': true,
|
||||
});
|
||||
|
||||
/// The reminder that comes back when the notification [id] carrying
|
||||
/// [payload] is snoozed [now]: the same words, the snooze later, under an
|
||||
/// ID of its own (the same however often it is snoozed), snoozable again;
|
||||
/// none for a payload that isn't a snoozable reminder's.
|
||||
({ReminderNotice notice, ReminderSnooze snooze, String payload})? snoozed({
|
||||
required int id,
|
||||
required String? payload,
|
||||
required DateTime now,
|
||||
}) {
|
||||
if (_read(payload)
|
||||
case {
|
||||
't': final String title,
|
||||
'b': final String body,
|
||||
's': final int minutes,
|
||||
'l': final String label,
|
||||
'c': final String channel,
|
||||
}
|
||||
when minutes > 0) {
|
||||
final snooze = (minutes: minutes, label: label, channel: channel);
|
||||
final again = _read(payload)!['z'] == true;
|
||||
return (
|
||||
notice: (
|
||||
id: again ? id : _snoozedId(id),
|
||||
at: now.add(Duration(minutes: minutes)),
|
||||
title: title,
|
||||
body: body,
|
||||
),
|
||||
snooze: snooze,
|
||||
payload: snoozePayload(
|
||||
title: title,
|
||||
body: body,
|
||||
snooze: snooze,
|
||||
again: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether [payload] is a snoozed reminder's.
|
||||
bool isSnoozed(String? payload) => _read(payload)?['z'] == true;
|
||||
|
||||
Map<String, Object?>? _read(String? payload) {
|
||||
if (payload == null || payload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return switch (jsonDecode(payload)) {
|
||||
final Map<String, Object?> map => map,
|
||||
_ => null,
|
||||
};
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The ID a snoozed reminder of the notification [id] comes back under,
|
||||
/// apart from the planned ones (31 bits, as theirs).
|
||||
int _snoozedId(int id) => (id ^ 0x2a5f3c11) & 0x7fffffff;
|
||||
|
|
@ -15,7 +15,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// Keeps the device's reminders in step with the person's tasks (ADR 0033,
|
||||
/// D5): planned again whenever the tasks change, here or through sync,
|
||||
/// when the app comes back to the front, and every few hours as the
|
||||
/// planned weeks move on; handed to the system only where the plan changed.
|
||||
/// planned weeks move on; handed to the system only where the plan, or how
|
||||
/// long a reminder is snoozed for (D4), changed.
|
||||
class const ReminderScheduler({required final Widget child, super.key})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -24,6 +25,7 @@ class const ReminderScheduler({required final Widget child, super.key})
|
|||
|
||||
class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
||||
List<PlannedReminder>? _planned;
|
||||
int? _snooze;
|
||||
Locale? _locale;
|
||||
Timer? _timer;
|
||||
AppLifecycleListener? _lifecycle;
|
||||
|
|
@ -55,21 +57,27 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
|||
final plan = planReminders([
|
||||
for (final task in state.tasks) StoredTask(task.id, task.content),
|
||||
], now: clock.now());
|
||||
if (listEquals(plan, _planned)) {
|
||||
final snooze = state.settings.reminders.snooze;
|
||||
if (listEquals(plan, _planned) && snooze == _snooze) {
|
||||
return;
|
||||
}
|
||||
_planned = plan;
|
||||
_snooze = snooze;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
unawaited(
|
||||
PersonScope.of<TodoPerson>(context).reminders.replace([
|
||||
for (final reminder in plan)
|
||||
(
|
||||
id: reminder.id,
|
||||
at: reminder.at,
|
||||
title: reminder.title,
|
||||
body: l10n.reminderDue(timeText(context, reminder.time)),
|
||||
),
|
||||
], channel: l10n.remindersChannel),
|
||||
PersonScope.of<TodoPerson>(context).reminders.replace(
|
||||
[
|
||||
for (final reminder in plan)
|
||||
(
|
||||
id: reminder.id,
|
||||
at: reminder.at,
|
||||
title: reminder.title,
|
||||
body: l10n.reminderDue(timeText(context, reminder.time)),
|
||||
),
|
||||
],
|
||||
channel: l10n.remindersChannel,
|
||||
snooze: (minutes: snooze, label: l10n.reminderSnooze(snooze)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +91,9 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
|||
@override
|
||||
Widget build(BuildContext context) => BlocListener<TodoCubit, TodoState>(
|
||||
listenWhen: (before, after) =>
|
||||
before.loaded != after.loaded || !identical(before.tasks, after.tasks),
|
||||
before.loaded != after.loaded ||
|
||||
!identical(before.tasks, after.tasks) ||
|
||||
before.settings.reminders != after.settings.reminders,
|
||||
listener: (context, state) => _plan(),
|
||||
child: widget.child,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ ByteData encodeSettings(TodoSettings settings) =>
|
|||
encodeJson(settingsSchemaVersion, {
|
||||
if (!settings.upcoming.isDefault)
|
||||
'upcoming': _encodeUpcoming(settings.upcoming),
|
||||
if (settings.tasks != const TaskSettings())
|
||||
'tasks': {
|
||||
'weekStart': ?settings.tasks.weekStart,
|
||||
if (!settings.tasks.resetSubTasks) 'resetSubTasks': false,
|
||||
},
|
||||
if (settings.reminders != const ReminderSettings())
|
||||
'reminders': {
|
||||
'automatic': ?settings.reminders.automatic,
|
||||
if (settings.reminders.snooze != const ReminderSettings().snooze)
|
||||
'snooze': settings.reminders.snooze,
|
||||
},
|
||||
});
|
||||
|
||||
Map<String, Object?> _encodeUpcoming(UpcomingView view) => {
|
||||
|
|
@ -27,9 +38,43 @@ TodoSettings? decodeSettings(ByteData payload) => decodeJson(
|
|||
payload,
|
||||
version: settingsSchemaVersion,
|
||||
name: 'Settings',
|
||||
read: (json) => TodoSettings(upcoming: _decodeUpcoming(json['upcoming'])),
|
||||
read: (json) => TodoSettings(
|
||||
upcoming: _decodeUpcoming(json['upcoming']),
|
||||
tasks: _decodeTasks(json['tasks']),
|
||||
reminders: _decodeReminders(json['reminders']),
|
||||
),
|
||||
);
|
||||
|
||||
TaskSettings _decodeTasks(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const TaskSettings();
|
||||
}
|
||||
return TaskSettings(
|
||||
weekStart: switch (json['weekStart']) {
|
||||
final int day when day >= DateTime.monday && day <= DateTime.sunday =>
|
||||
day,
|
||||
_ => null,
|
||||
},
|
||||
resetSubTasks: json['resetSubTasks'] != false,
|
||||
);
|
||||
}
|
||||
|
||||
ReminderSettings _decodeReminders(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const ReminderSettings();
|
||||
}
|
||||
return ReminderSettings(
|
||||
automatic: switch (json['automatic']) {
|
||||
final int minutes when minutes >= 0 => minutes,
|
||||
_ => null,
|
||||
},
|
||||
snooze: switch (json['snooze']) {
|
||||
final int minutes when minutes > 0 => minutes,
|
||||
_ => const ReminderSettings().snooze,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
UpcomingView _decodeUpcoming(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const UpcomingView();
|
||||
|
|
|
|||
|
|
@ -43,15 +43,62 @@ final class const UpcomingView({
|
|||
List<Object?> get props => [layout, coloring, filters];
|
||||
}
|
||||
|
||||
/// The person's settings of Todo's (D17): one sealed row of theirs, synced
|
||||
/// to every device (`todo_settings`); for now [upcoming]'s Display, D4's
|
||||
/// settings of tasks and reminders to join it.
|
||||
final class const TodoSettings({
|
||||
final UpcomingView upcoming = const UpcomingView(),
|
||||
/// The person's settings of tasks (ADR 0033, D4).
|
||||
final class const TaskSettings({
|
||||
/// The weekday weeks start on (`DateTime.monday` to `sunday`); none: the
|
||||
/// language's.
|
||||
final int? weekStart,
|
||||
|
||||
/// Whether a recurring task done opens its sub-tasks again, as Todoist's.
|
||||
final bool resetSubTasks = true,
|
||||
}) with Equatable {
|
||||
TodoSettings copyWith({UpcomingView? upcoming}) =>
|
||||
TodoSettings(upcoming: upcoming ?? this.upcoming);
|
||||
TaskSettings copyWith({int? Function()? weekStart, bool? resetSubTasks}) =>
|
||||
TaskSettings(
|
||||
weekStart: weekStart == null ? this.weekStart : weekStart(),
|
||||
resetSubTasks: resetSubTasks ?? this.resetSubTasks,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [upcoming];
|
||||
List<Object?> get props => [weekStart, resetSubTasks];
|
||||
}
|
||||
|
||||
/// The person's settings of reminders (D4, D5).
|
||||
final class const ReminderSettings({
|
||||
/// How many minutes before its time a task given a time is reminded of
|
||||
/// (0: at the time), where it has no reminder of its own; none: never.
|
||||
final int? automatic,
|
||||
|
||||
/// How many minutes a reminder snoozed comes back after.
|
||||
final int snooze = 15,
|
||||
}) with Equatable {
|
||||
ReminderSettings copyWith({int? Function()? automatic, int? snooze}) =>
|
||||
ReminderSettings(
|
||||
automatic: automatic == null ? this.automatic : automatic(),
|
||||
snooze: snooze ?? this.snooze,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [automatic, snooze];
|
||||
}
|
||||
|
||||
/// The person's settings of Todo's (D4, D17): one sealed row of theirs,
|
||||
/// synced to every device (`todo_settings`): [upcoming]'s Display, and
|
||||
/// those of [tasks] and [reminders].
|
||||
final class const TodoSettings({
|
||||
final UpcomingView upcoming = const UpcomingView(),
|
||||
final TaskSettings tasks = const TaskSettings(),
|
||||
final ReminderSettings reminders = const ReminderSettings(),
|
||||
}) with Equatable {
|
||||
TodoSettings copyWith({
|
||||
UpcomingView? upcoming,
|
||||
TaskSettings? tasks,
|
||||
ReminderSettings? reminders,
|
||||
}) => TodoSettings(
|
||||
upcoming: upcoming ?? this.upcoming,
|
||||
tasks: tasks ?? this.tasks,
|
||||
reminders: reminders ?? this.reminders,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [upcoming, tasks, reminders];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,17 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
|||
/// The person's tasks (ADR 0033, D6), each sealed with their key for its row
|
||||
/// (D5, [SealedRows]): which project a task is in, its date and whether it
|
||||
/// is done are in its payload, so the instance learns none of it.
|
||||
class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
||||
///
|
||||
/// A task given a time, added so or a time added to it, without reminders
|
||||
/// of its own gets the person's automatic one ([automaticReminder], minutes
|
||||
/// before its time, D4); one that had a time keeps what the person left it.
|
||||
class TaskRepository(
|
||||
final SealedRowGateway _gateway,
|
||||
final TodoKeys _keys, {
|
||||
|
||||
/// The person's automatic reminder, read as it is needed; none: never.
|
||||
final Future<int?> Function()? automaticReminder,
|
||||
}) {
|
||||
late final _rows = SealedRows<TaskContent>(
|
||||
_gateway,
|
||||
_keys,
|
||||
|
|
@ -30,10 +40,41 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
|||
|
||||
/// Keeps a new task and returns its ID; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> add(TaskContent content) => _rows.insert(content);
|
||||
Future<String> add(TaskContent content) async =>
|
||||
await _rows.insert(await _reminded(content, before: null));
|
||||
|
||||
Future<void> update(String id, TaskContent content) =>
|
||||
_rows.update(id, content);
|
||||
Future<void> update(String id, TaskContent content) async {
|
||||
final needs = _mayRemind(content);
|
||||
final before = needs
|
||||
? (await all()).where((task) => task.id == id).firstOrNull?.content
|
||||
: null;
|
||||
await _rows.update(
|
||||
id,
|
||||
needs ? await _reminded(content, before: before) : content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether [content] may get the automatic reminder: it has a time and
|
||||
/// no reminder of its own.
|
||||
bool _mayRemind(TaskContent content) =>
|
||||
automaticReminder != null &&
|
||||
content.date?.time != null &&
|
||||
content.reminders.isEmpty;
|
||||
|
||||
/// [content] with the automatic reminder where it gains a time ([before]
|
||||
/// had none).
|
||||
Future<TaskContent> _reminded(
|
||||
TaskContent content, {
|
||||
required TaskContent? before,
|
||||
}) async {
|
||||
if (!_mayRemind(content) || before?.date?.time != null) {
|
||||
return content;
|
||||
}
|
||||
return switch (await automaticReminder!()) {
|
||||
final minutes? => content.copyWith(reminders: [minutes]),
|
||||
null => content,
|
||||
};
|
||||
}
|
||||
|
||||
/// Marks [task] done now, or not done where [done] is false, and returns
|
||||
/// every task it changed as it was before, for [restore] to undo it.
|
||||
|
|
@ -42,9 +83,13 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
|||
/// its next day, its deadline as far, its time kept, with when it was done
|
||||
/// kept in its [TaskContent.completions], and its sub-tasks open again,
|
||||
/// as Todoist's do; done its last time, it is done as any other.
|
||||
///
|
||||
/// Where not [reopenSubTasks] (the person's setting, D4), its sub-tasks
|
||||
/// stay as they are.
|
||||
Future<List<StoredTask>> complete(
|
||||
StoredTask task, {
|
||||
bool done = true,
|
||||
bool reopenSubTasks = true,
|
||||
}) async {
|
||||
final content = task.content;
|
||||
if (content == null) {
|
||||
|
|
@ -68,7 +113,10 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
|||
final below = _withDescendants({task.id}, tasks)..remove(task.id);
|
||||
final reopened = [
|
||||
for (final sub in tasks)
|
||||
if (below.contains(sub.id) && (sub.content?.completed ?? false)) sub,
|
||||
if (reopenSubTasks &&
|
||||
below.contains(sub.id) &&
|
||||
(sub.content?.completed ?? false))
|
||||
sub,
|
||||
];
|
||||
await update(
|
||||
task.id,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -57,7 +58,7 @@ class _DateSheetState() extends State<_DateSheet> {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final today = Day.of(clock.now());
|
||||
final firstDay = MaterialLocalizations.of(context).firstDayOfWeekIndex;
|
||||
final firstDay = firstWeekdayOf(context);
|
||||
// DateTime's weekdays run 1 (Monday) to 7, Material's 0 (Sunday) to 6.
|
||||
int until(int weekday) => (weekday - today.toDate().weekday + 7) % 7;
|
||||
final nextWeek = today.plus(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -220,7 +221,7 @@ class _RepeatEditorState() extends State<_RepeatEditor> {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final firstDay = MaterialLocalizations.of(context).firstDayOfWeekIndex;
|
||||
final firstDay = firstWeekdayOf(context);
|
||||
final day = widget.day;
|
||||
Widget stepper(int value, int min, ValueChanged<int> changed) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
|
|
@ -268,11 +268,15 @@ class _SelectionActions(
|
|||
final context = _context;
|
||||
final tasks = _tasks;
|
||||
final chosen = _chosen.where((task) => !task.content.completed).toList();
|
||||
final reopen = context.read<TodoCubit>().state.settings.tasks.resetSubTasks;
|
||||
final before = <StoredTask>[];
|
||||
await _guard(() async {
|
||||
for (final task in chosen) {
|
||||
before.addAll(
|
||||
await tasks.complete(StoredTask(task.id, task.content)),
|
||||
await tasks.complete(
|
||||
StoredTask(task.id, task.content),
|
||||
reopenSubTasks: reopen,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue