feat(todo): reminders at a time of their own planned and set in a task's sheet, durations in its date sheet

planReminders plans each reminder at a time of its own of an open task,
whatever its date, under an ID of its own; the notification says
"Reminder" for a task without a time. The task's sheet always has its
reminders' row: those before its time where it has one, those at a time
of their own each with its remove button and "Remind at a time" (a day,
then a time), the permission asked as a first one is chosen. The date
sheet sets a duration once there is a time (none, 15 minutes to 4
hours, as Todoist's), a time cleared taking it with it. Recorded as
ADR 0033, D18 (the developer's, 3 Nov 2026).

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-03 13:56:19 +01:00
commit f5b657dfad
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 603 additions and 70 deletions

File diff suppressed because one or more lines are too long

View file

@ -124,6 +124,15 @@
"remindersChannel": "Erinnerungen an Aufgaben",
"remindersNotAllowed": "Benachrichtigungen sind für Halkyon Todo aus: Erinnerungen erscheinen, sobald du sie in den Systemeinstellungen erlaubst.",
"remindersOnThisDevice": "Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.",
"reminderBody": "Erinnerung",
"remindersAtTimes": "Zu einer eigenen Uhrzeit",
"reminderAddTime": "Zu einer Uhrzeit erinnern",
"reminderRemove": "Erinnerung entfernen",
"durationTitle": "Dauer",
"durationNone": "Keine Dauer",
"durationHours": "{hours} Std.",
"durationMinutes": "{minutes} Min.",
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
"weekPrevious": "Vorige Woche",
"weekNext": "Folgende Woche",
"searchTitle": "Suchen",

View file

@ -343,6 +343,39 @@
"remindersChannel": "Task reminders",
"remindersNotAllowed": "Notifications are off for Halkyon Todo: reminders show once you allow them in the system's settings.",
"remindersOnThisDevice": "Reminders come on each of your devices that opens your tasks.",
"reminderBody": "Reminder",
"remindersAtTimes": "At a time of their own",
"reminderAddTime": "Remind at a time",
"reminderRemove": "Remove the reminder",
"durationTitle": "Duration",
"durationNone": "No duration",
"durationHours": "{hours} h",
"@durationHours": {
"placeholders": {
"hours": {
"type": "int"
}
}
},
"durationMinutes": "{minutes} min",
"@durationMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"durationHoursMinutes": "{hours} h {minutes} min",
"@durationHoursMinutes": {
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"weekPrevious": "Previous week",
"weekNext": "Following week",
"searchTitle": "Search",

View file

@ -844,6 +844,60 @@ abstract class AppLocalizations {
/// **'Reminders come on each of your devices that opens your tasks.'**
String get remindersOnThisDevice;
/// No description provided for @reminderBody.
///
/// In en, this message translates to:
/// **'Reminder'**
String get reminderBody;
/// No description provided for @remindersAtTimes.
///
/// In en, this message translates to:
/// **'At a time of their own'**
String get remindersAtTimes;
/// No description provided for @reminderAddTime.
///
/// In en, this message translates to:
/// **'Remind at a time'**
String get reminderAddTime;
/// No description provided for @reminderRemove.
///
/// In en, this message translates to:
/// **'Remove the reminder'**
String get reminderRemove;
/// No description provided for @durationTitle.
///
/// In en, this message translates to:
/// **'Duration'**
String get durationTitle;
/// No description provided for @durationNone.
///
/// In en, this message translates to:
/// **'No duration'**
String get durationNone;
/// No description provided for @durationHours.
///
/// In en, this message translates to:
/// **'{hours} h'**
String durationHours(int hours);
/// No description provided for @durationMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min'**
String durationMinutes(int minutes);
/// No description provided for @durationHoursMinutes.
///
/// In en, this message translates to:
/// **'{hours} h {minutes} min'**
String durationHoursMinutes(int hours, int minutes);
/// No description provided for @weekPrevious.
///
/// In en, this message translates to:

View file

@ -527,6 +527,39 @@ class AppLocalizationsDe extends AppLocalizations {
String get remindersOnThisDevice =>
'Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.';
@override
String get reminderBody => 'Erinnerung';
@override
String get remindersAtTimes => 'Zu einer eigenen Uhrzeit';
@override
String get reminderAddTime => 'Zu einer Uhrzeit erinnern';
@override
String get reminderRemove => 'Erinnerung entfernen';
@override
String get durationTitle => 'Dauer';
@override
String get durationNone => 'Keine Dauer';
@override
String durationHours(int hours) {
return '$hours Std.';
}
@override
String durationMinutes(int minutes) {
return '$minutes Min.';
}
@override
String durationHoursMinutes(int hours, int minutes) {
return '$hours Std. $minutes Min.';
}
@override
String get weekPrevious => 'Vorige Woche';

View file

@ -527,6 +527,39 @@ class AppLocalizationsEn extends AppLocalizations {
String get remindersOnThisDevice =>
'Reminders come on each of your devices that opens your tasks.';
@override
String get reminderBody => 'Reminder';
@override
String get remindersAtTimes => 'At a time of their own';
@override
String get reminderAddTime => 'Remind at a time';
@override
String get reminderRemove => 'Remove the reminder';
@override
String get durationTitle => 'Duration';
@override
String get durationNone => 'No duration';
@override
String durationHours(int hours) {
return '$hours h';
}
@override
String durationMinutes(int minutes) {
return '$minutes min';
}
@override
String durationHoursMinutes(int hours, int minutes) {
return '$hours h $minutes min';
}
@override
String get weekPrevious => 'Previous week';

View file

@ -2,23 +2,25 @@ import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A reminder the device is to show (ADR 0033, D5): of the task [taskId]
/// titled [title], due at [time] (minutes after midnight, its wall clock),
/// [minutesBefore] it, at [at] (local time); [id] the same on every plan, so
/// that the system's notification is replaced, not doubled.
/// titled [title], due at [time] (minutes after midnight, its wall clock;
/// none for a task without a time), [minutesBefore] it (none for one at a
/// time of its own, D18), at [at] (local time); [id] the same on every
/// plan, so that the system's notification is replaced, not doubled.
final class const PlannedReminder({
required final int id,
required final String taskId,
required final DateTime at,
required final int minutesBefore,
required final String title,
required final int time,
final int? minutesBefore,
final int? time,
}) with Equatable {
@override
List<Object?> get props => [id, taskId, at, minutesBefore, title, time];
}
/// The reminders to schedule [now] (D5): every reminder of each open task
/// that has a time on its date, the nearest first, those still ahead within
/// that has a time on its date, and each at a time of its own (D18) of any
/// open task, the nearest first, those still ahead within
/// [horizon], no more than [limit] (iOS keeps 64 pending; a recurring
/// task's later times are planned once it moves on). A task's time is its
/// wall clock, so the reminder is at that time wherever the device is now.
@ -29,7 +31,24 @@ List<PlannedReminder> planReminders(
int limit = 48,
}) {
final end = now.add(horizon);
bool ahead(DateTime at) => at.isAfter(now) && !at.isAfter(end);
final plan = [
for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent(
completed: false,
:final reminderTimes,
:final title,
:final date,
))
for (final at in reminderTimes.toSet())
if (ahead(at.at))
PlannedReminder(
id: reminderId(id, 'at ${at.day} ${at.time}'),
taskId: id,
at: at.at,
title: title,
time: date?.time,
),
for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent(
completed: false,
@ -45,7 +64,7 @@ List<PlannedReminder> planReminders(
0,
time - before,
)
case final at when at.isAfter(now) && !at.isAfter(end))
case final at when ahead(at))
PlannedReminder(
id: reminderId(id, before),
taskId: id,
@ -58,12 +77,13 @@ List<PlannedReminder> planReminders(
return plan.take(limit).toList();
}
/// The system's notification ID of the task [taskId]'s reminder [before]
/// minutes ahead: 31 bits of an FNV-1a hash, the same in every run (Dart's
/// `hashCode` of a string isn't).
int reminderId(String taskId, int before) {
/// The system's notification ID of the task [taskId]'s reminder [which]:
/// so many minutes ahead, or at a time of its own (D18); 31 bits of an
/// FNV-1a hash, the same in every run (Dart's `hashCode` of a string
/// isn't).
int reminderId(String taskId, Object which) {
var hash = 0x811c9dc5;
for (final unit in '$taskId/$before'.codeUnits) {
for (final unit in '$taskId/$which'.codeUnits) {
hash = ((hash ^ unit) * 0x01000193) & 0xffffffff;
}
return hash & 0x7fffffff;

View file

@ -1,5 +1,9 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The reminders offered, in minutes before a task's time.
@ -14,27 +18,92 @@ String reminderText(AppLocalizations l10n, int before) => switch (before) {
_ => l10n.reminderMinutesBefore(before),
};
/// Asks which reminders a task with a time has (ADR 0033, D5, D8), from
/// [current]; null where the person changed their mind.
Future<List<int>?> pickReminders(BuildContext context, List<int> current) =>
showAdaptiveSheet<List<int>>(
context,
builder: (context) => _ReminderSheet(current: current),
);
/// A reminder at a time of its own (D18) in words: its day as a task's row
/// names it, and its time.
String reminderTimeText(BuildContext context, ReminderTime at, Day today) =>
dateText(context, TaskDate(at.day, time: at.time), today);
class const _ReminderSheet({required final List<int> current})
extends StatefulWidget {
/// A task's reminders as its sheet lists them: those before its time, then
/// those at times of their own.
String remindersText(
BuildContext context,
TaskContent content,
Day today,
) {
final l10n = AppLocalizations.of(context);
final texts = [
if (content.date?.time != null)
for (final before in content.reminders) reminderText(l10n, before),
for (final at in content.reminderTimes)
reminderTimeText(context, at, today),
];
return texts.isEmpty ? l10n.remindersNone : texts.join(', ');
}
/// A task's reminders chosen: minutes `before` its time, and `at` times of
/// their own (D18).
typedef ReminderChoice = ({List<int> before, List<ReminderTime> at});
/// Asks which reminders a task has (ADR 0033, D5, D8, D18): so long before
/// its time where it has one ([timed]), from [before], and at times of
/// their own, from [at], each added from a day (the task's [day] at first)
/// and a time; null where the person changed their mind.
Future<ReminderChoice?> pickReminders(
BuildContext context, {
required List<int> before,
required List<ReminderTime> at,
required bool timed,
Day? day,
}) => showAdaptiveSheet<ReminderChoice>(
context,
builder: (context) =>
_ReminderSheet(before: before, at: at, timed: timed, day: day),
);
class const _ReminderSheet({
required final List<int> before,
required final List<ReminderTime> at,
required final bool timed,
required final Day? day,
}) extends StatefulWidget {
@override
State<_ReminderSheet> createState() => _ReminderSheetState();
}
class _ReminderSheetState() extends State<_ReminderSheet> {
late final Set<int> _chosen = {...widget.current};
late final Set<int> _chosen = {...widget.before};
late final Set<ReminderTime> _at = {...widget.at};
Future<void> _addTime() async {
final today = Day.of(clock.now());
final day = widget.day ?? today;
final picked = await showDatePicker(
context: context,
initialDate: (day < today ? today : day).toDate(),
firstDate: today.toDate(),
lastDate: DateTime(today.year + 20),
);
if (picked == null || !mounted) {
return;
}
final time = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 9, minute: 0),
);
if (time != null) {
setState(
() =>
_at.add(ReminderTime(Day.of(picked), time.hour * 60 + time.minute)),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final offered = {...reminderChoices, ...widget.current}.toList()..sort();
final today = Day.of(clock.now());
final offered = {...reminderChoices, ...widget.before}.toList()..sort();
final times = _at.toList()..sort();
return SheetScaffold(
title: l10n.remindersTitle,
footer: Padding(
@ -42,7 +111,10 @@ class _ReminderSheetState() extends State<_ReminderSheet> {
child: Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => Navigator.of(context).pop([..._chosen]..sort()),
onPressed: () => Navigator.of(context).pop<ReminderChoice>((
before: widget.timed ? ([..._chosen]..sort()) : widget.before,
at: times,
)),
child: Text(l10n.save),
),
),
@ -50,14 +122,31 @@ class _ReminderSheetState() extends State<_ReminderSheet> {
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final before in offered)
CheckboxListTile(
title: Text(reminderText(l10n, before)),
value: _chosen.contains(before),
onChanged: (on) => setState(
() => on! ? _chosen.add(before) : _chosen.remove(before),
if (widget.timed)
for (final before in offered)
CheckboxListTile(
title: Text(reminderText(l10n, before)),
value: _chosen.contains(before),
onChanged: (on) => setState(
() => on! ? _chosen.add(before) : _chosen.remove(before),
),
),
if (widget.timed) const Divider(),
for (final at in times)
ListTile(
leading: const Icon(LucideIcons.alarmClock),
title: Text(reminderTimeText(context, at, today)),
trailing: IconButton(
tooltip: l10n.reminderRemove,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _at.remove(at)),
),
),
ListTile(
leading: const Icon(LucideIcons.alarmClockPlus),
title: Text(l10n.reminderAddTime),
onTap: _addTime,
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(

View file

@ -72,7 +72,10 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
id: reminder.id,
at: reminder.at,
title: reminder.title,
body: l10n.reminderDue(timeText(context, reminder.time)),
body: switch (reminder.time) {
final time? => l10n.reminderDue(timeText(context, time)),
null => l10n.reminderBody,
},
),
],
channel: l10n.remindersChannel,

View file

@ -15,8 +15,9 @@ typedef DateChoice = ({TaskDate? date});
/// Asks for a task's date (ADR 0033, D8): today, tomorrow, the coming
/// weekend, next week (the day the language starts it on), a day from the
/// calendar, with a time where the person adds one, recurring as they
/// pick (D7, [pickRepeat]); or none, where [current] has one.
/// calendar, with a time where the person adds one and with it a duration
/// (D18, as Todoist's), recurring as they pick (D7, [pickRepeat]); or
/// none, where [current] has one.
Future<DateChoice?> pickTaskDate(
BuildContext context, {
TaskDate? current,
@ -46,12 +47,17 @@ class const _DateSheet({required final TaskDate? current})
class _DateSheetState() extends State<_DateSheet> {
late int? _time = widget.current?.time;
late final Duration? _duration = widget.current?.duration;
late Duration? _duration = widget.current?.duration;
late final Repeat? _repeat = widget.current?.repeat;
void _choose(Day day, {Repeat? repeat}) =>
Navigator.of(context).pop<DateChoice>((
date: TaskDate(day, time: _time, duration: _duration, repeat: repeat),
date: TaskDate(
day,
time: _time,
duration: _time == null ? null : _duration,
repeat: repeat,
),
));
@override
@ -105,7 +111,10 @@ class _DateSheetState() extends State<_DateSheet> {
trailing: switch (_time) {
final time? => InputChip(
label: Text(timeText(context, time)),
onDeleted: () => setState(() => _time = null),
onDeleted: () => setState(() {
_time = null;
_duration = null;
}),
),
null => null,
},
@ -125,6 +134,25 @@ class _DateSheetState() extends State<_DateSheet> {
}
},
),
if (_time != null)
ListTile(
leading: const Icon(LucideIcons.hourglass),
title: Text(l10n.durationTitle),
trailing: switch (_duration) {
final duration? => InputChip(
label: Text(durationText(l10n, duration)),
onDeleted: () => setState(() => _duration = null),
),
null => null,
},
onTap: () async {
if (await _pickDuration(context, _duration) case (
:final duration,
)) {
setState(() => _duration = duration);
}
},
),
ListTile(
leading: const Icon(LucideIcons.repeat),
title: Text(l10n.repeatTitle),
@ -156,3 +184,44 @@ class _DateSheetState() extends State<_DateSheet> {
);
}
}
/// The durations offered, in minutes (D18, as Todoist's).
const durationChoices = [15, 30, 45, 60, 90, 120, 180, 240];
/// Asks how long a task with a time lasts: none, or one of
/// [durationChoices] (or [current]); null where the person changed their
/// mind.
Future<({Duration? duration})?> _pickDuration(
BuildContext context,
Duration? current,
) => showAdaptiveSheet<({Duration? duration})>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final offered = {
...durationChoices,
?current?.inMinutes,
}.toList()..sort();
Widget option(Duration? duration, String label) => ListTile(
title: Text(label),
trailing: duration == current ? const Icon(LucideIcons.check) : null,
onTap: () => Navigator.of(
context,
).pop<({Duration? duration})>((duration: duration)),
);
return SheetScaffold(
title: l10n.durationTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
option(null, l10n.durationNone),
for (final minutes in offered)
option(
Duration(minutes: minutes),
durationText(l10n, Duration(minutes: minutes)),
),
],
),
);
},
);

View file

@ -513,37 +513,44 @@ class _TaskSheetState() extends State<_TaskSheet> {
}
},
),
if (content.date?.time != null)
ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(
content.reminders.isEmpty
? l10n.remindersNone
: [
for (final before in content.reminders)
reminderText(l10n, before),
].join(', '),
),
onTap: () async {
final chosen = await pickReminders(context, content.reminders);
if (chosen == null || !context.mounted) {
return;
ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(remindersText(context, content, today)),
onTap: () async {
final timed = content.date?.time != null;
final chosen = await pickReminders(
context,
before: content.reminders,
at: content.reminderTimes,
timed: timed,
day: content.date?.day,
);
if (chosen == null || !context.mounted) {
return;
}
// Asked once there is something to remind of, not before.
final had =
(timed && content.reminders.isNotEmpty) ||
content.reminderTimes.isNotEmpty;
if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) {
final reminders = PersonScope.of<TodoPerson>(
context,
).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
// Asked once there is something to remind of, not before.
if (content.reminders.isEmpty && chosen.isNotEmpty) {
final reminders = PersonScope.of<TodoPerson>(
context,
).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
}
await _change((c) => c.copyWith(reminders: chosen));
},
),
}
await _change(
(c) => c.copyWith(
reminders: chosen.before,
reminderTimes: chosen.at,
),
);
},
),
ListTile(
leading: Icon(
LucideIcons.flag,

View file

@ -43,6 +43,17 @@ String dateText(BuildContext context, TaskDate date, Day today) {
};
}
/// [duration] in words: `30 min`, `2 h`, `1 h 30 min` (D18).
String durationText(AppLocalizations l10n, Duration duration) {
final hours = duration.inMinutes ~/ 60;
final minutes = duration.inMinutes % 60;
return switch ((hours, minutes)) {
(0, _) => l10n.durationMinutes(minutes),
(_, 0) => l10n.durationHours(hours),
_ => l10n.durationHoursMinutes(hours, minutes),
};
}
/// [color] for text and icons on the screen's surface: its darker outline
/// in light mode, its fill in dark mode, so it reads on both.
Color paletteText(BuildContext context, PaletteColor color) =>

View file

@ -1470,7 +1470,13 @@ void main() {
await goTab(tester, 'Inbox');
await tester.tap(find.text('Undated'));
await tester.pumpAndSettle();
expect(find.text('No reminders'), findsNothing, reason: 'no time');
await tester.tap(find.text('No reminders'));
await tester.pumpAndSettle();
expect(find.text('At its time'), findsNothing, reason: 'no time');
expect(find.text('Remind at a time'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await tester.pumpAndSettle();
expect(reminders.asked, 0, reason: 'nothing to remind of');
await closeTaskSheet(tester);
await tester.pumpAndSettle();
@ -1499,6 +1505,75 @@ void main() {
);
});
testWidgets("a task's sheet adds a reminder at a time of its own, "
'without a time of the task (D18)', (tester) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(title: 'Stretch', projectId: inbox()),
);
await goTab(tester, 'Inbox');
await tester.tap(find.text('Stretch'));
await tester.pumpAndSettle();
await tester.tap(find.text('No reminders'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remind at a time'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
expect(find.text('Today 09:00'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await tester.pumpAndSettle();
expect(reminders.asked, 1);
expect(find.text('Today 09:00'), findsOneWidget);
final stored = await tester.runAsync(() => tasks().all());
expect(
stored!.single.content?.reminderTimes,
[ReminderTime(today, 9 * 60)],
);
});
testWidgets("a timed task's date sheet sets how long it lasts (D18)", (
tester,
) async {
final tomorrow = Day.of(DateTime.now()).plus(1);
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Write',
projectId: inbox(),
date: TaskDate(tomorrow, time: 14 * 60),
),
);
await goTab(tester, 'Inbox');
await tester.tap(find.text('Write'));
await tester.pumpAndSettle();
await tester.tap(find.text('Tomorrow 14:00').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Duration'));
await tester.pumpAndSettle();
await tester.tap(find.text('1 h 30 min'));
await tester.pumpAndSettle();
await tester.tap(find.text('Tomorrow').first);
await tester.pumpAndSettle();
expect(find.text('Tomorrow 14:00–15:30'), findsWidgets);
final stored = await tester.runAsync(() => tasks().all());
expect(
stored!.single.content?.date,
TaskDate(
tomorrow,
time: 14 * 60,
duration: const Duration(minutes: 90),
),
);
});
testWidgets("Today names a task's project and section on the line of "
'its date, at its end', (tester) async {
final today = Day.of(DateTime.now());

View file

@ -14,6 +14,7 @@ void main() {
String id, {
TaskDate? date,
List<int> reminders = const [30],
List<ReminderTime> reminderTimes = const [],
DateTime? completedAt,
}) => StoredTask(
id,
@ -22,6 +23,7 @@ void main() {
projectId: 'p',
date: date,
reminders: reminders,
reminderTimes: reminderTimes,
completedAt: completedAt,
),
);
@ -93,6 +95,53 @@ void main() {
expect([for (final r in plan) r.taskId], ['1', '2', '3']);
});
test('a reminder at a time of its own (D18), whatever the date, among '
'the others by when it comes', () {
final plan = planReminders([
task(
'timed',
date: const TaskDate(today, time: 10 * 60),
reminders: [0],
reminderTimes: const [ReminderTime(today, 9 * 60)],
),
task(
'undated',
reminders: const [],
reminderTimes: const [
ReminderTime(today, 13 * 60),
ReminderTime(today, 14 * 60),
ReminderTime(today, 7 * 60),
],
),
task(
'day only',
date: const TaskDate(Day(2026, 11, 5)),
reminderTimes: const [ReminderTime(Day(2026, 11, 3), 8 * 60 + 30)],
),
task(
'done',
completedAt: now,
reminderTimes: const [ReminderTime(today, 12 * 60)],
),
], now: now);
expect(
[for (final r in plan) (r.taskId, r.at, r.minutesBefore, r.time)],
[
('timed', DateTime(2026, 11, 2, 9), null, 10 * 60),
('timed', DateTime(2026, 11, 2, 10), 0, 10 * 60),
('undated', DateTime(2026, 11, 2, 13), null, null),
('undated', DateTime(2026, 11, 2, 14), null, null),
('day only', DateTime(2026, 11, 3, 8, 30), null, null),
],
reason: 'the one at 7:00 is past',
);
expect(
plan.map((r) => r.id).toSet(),
hasLength(plan.length),
reason: "each its own notification, apart from the task's others",
);
});
test("a reminder's ID is the same on every plan, and differs between a "
"task's reminders and between tasks", () {
PlannedReminder only(String id, int before) => planReminders([