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
15 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", "remindersChannel": "Erinnerungen an Aufgaben",
"remindersNotAllowed": "Benachrichtigungen sind für Halkyon Todo aus: Erinnerungen erscheinen, sobald du sie in den Systemeinstellungen erlaubst.", "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.", "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", "weekPrevious": "Vorige Woche",
"weekNext": "Folgende Woche", "weekNext": "Folgende Woche",
"searchTitle": "Suchen", "searchTitle": "Suchen",

View file

@ -343,6 +343,39 @@
"remindersChannel": "Task reminders", "remindersChannel": "Task reminders",
"remindersNotAllowed": "Notifications are off for Halkyon Todo: reminders show once you allow them in the system's settings.", "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.", "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", "weekPrevious": "Previous week",
"weekNext": "Following week", "weekNext": "Following week",
"searchTitle": "Search", "searchTitle": "Search",

View file

@ -844,6 +844,60 @@ abstract class AppLocalizations {
/// **'Reminders come on each of your devices that opens your tasks.'** /// **'Reminders come on each of your devices that opens your tasks.'**
String get remindersOnThisDevice; 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. /// No description provided for @weekPrevious.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -527,6 +527,39 @@ class AppLocalizationsDe extends AppLocalizations {
String get remindersOnThisDevice => String get remindersOnThisDevice =>
'Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.'; '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 @override
String get weekPrevious => 'Vorige Woche'; String get weekPrevious => 'Vorige Woche';

View file

@ -527,6 +527,39 @@ class AppLocalizationsEn extends AppLocalizations {
String get remindersOnThisDevice => String get remindersOnThisDevice =>
'Reminders come on each of your devices that opens your tasks.'; '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 @override
String get weekPrevious => 'Previous week'; 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'; 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] /// 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), /// titled [title], due at [time] (minutes after midnight, its wall clock;
/// [minutesBefore] it, at [at] (local time); [id] the same on every plan, so /// none for a task without a time), [minutesBefore] it (none for one at a
/// that the system's notification is replaced, not doubled. /// 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({ final class const PlannedReminder({
required final int id, required final int id,
required final String taskId, required final String taskId,
required final DateTime at, required final DateTime at,
required final int minutesBefore,
required final String title, required final String title,
required final int time, final int? minutesBefore,
final int? time,
}) with Equatable { }) with Equatable {
@override @override
List<Object?> get props => [id, taskId, at, minutesBefore, title, time]; List<Object?> get props => [id, taskId, at, minutesBefore, title, time];
} }
/// The reminders to schedule [now] (D5): every reminder of each open task /// 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 /// [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 /// 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. /// wall clock, so the reminder is at that time wherever the device is now.
@ -29,7 +31,24 @@ List<PlannedReminder> planReminders(
int limit = 48, int limit = 48,
}) { }) {
final end = now.add(horizon); final end = now.add(horizon);
bool ahead(DateTime at) => at.isAfter(now) && !at.isAfter(end);
final plan = [ 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) for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent( if (content case TaskContent(
completed: false, completed: false,
@ -45,7 +64,7 @@ List<PlannedReminder> planReminders(
0, 0,
time - before, time - before,
) )
case final at when at.isAfter(now) && !at.isAfter(end)) case final at when ahead(at))
PlannedReminder( PlannedReminder(
id: reminderId(id, before), id: reminderId(id, before),
taskId: id, taskId: id,
@ -58,12 +77,13 @@ List<PlannedReminder> planReminders(
return plan.take(limit).toList(); return plan.take(limit).toList();
} }
/// The system's notification ID of the task [taskId]'s reminder [before] /// The system's notification ID of the task [taskId]'s reminder [which]:
/// minutes ahead: 31 bits of an FNV-1a hash, the same in every run (Dart's /// so many minutes ahead, or at a time of its own (D18); 31 bits of an
/// `hashCode` of a string isn't). /// FNV-1a hash, the same in every run (Dart's `hashCode` of a string
int reminderId(String taskId, int before) { /// isn't).
int reminderId(String taskId, Object which) {
var hash = 0x811c9dc5; var hash = 0x811c9dc5;
for (final unit in '$taskId/$before'.codeUnits) { for (final unit in '$taskId/$which'.codeUnits) {
hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; hash = ((hash ^ unit) * 0x01000193) & 0xffffffff;
} }
return hash & 0x7fffffff; 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/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:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The reminders offered, in minutes before a task's time. /// 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), _ => l10n.reminderMinutesBefore(before),
}; };
/// Asks which reminders a task with a time has (ADR 0033, D5, D8), from /// A reminder at a time of its own (D18) in words: its day as a task's row
/// [current]; null where the person changed their mind. /// names it, and its time.
Future<List<int>?> pickReminders(BuildContext context, List<int> current) => String reminderTimeText(BuildContext context, ReminderTime at, Day today) =>
showAdaptiveSheet<List<int>>( dateText(context, TaskDate(at.day, time: at.time), today);
context,
builder: (context) => _ReminderSheet(current: current),
);
class const _ReminderSheet({required final List<int> current}) /// A task's reminders as its sheet lists them: those before its time, then
extends StatefulWidget { /// 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 @override
State<_ReminderSheet> createState() => _ReminderSheetState(); State<_ReminderSheet> createState() => _ReminderSheetState();
} }
class _ReminderSheetState() extends State<_ReminderSheet> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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( return SheetScaffold(
title: l10n.remindersTitle, title: l10n.remindersTitle,
footer: Padding( footer: Padding(
@ -42,7 +111,10 @@ class _ReminderSheetState() extends State<_ReminderSheet> {
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: FilledButton( 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), child: Text(l10n.save),
), ),
), ),
@ -50,14 +122,31 @@ class _ReminderSheetState() extends State<_ReminderSheet> {
body: Column( body: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
for (final before in offered) if (widget.timed)
CheckboxListTile( for (final before in offered)
title: Text(reminderText(l10n, before)), CheckboxListTile(
value: _chosen.contains(before), title: Text(reminderText(l10n, before)),
onChanged: (on) => setState( value: _chosen.contains(before),
() => on! ? _chosen.add(before) : _chosen.remove(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(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text( child: Text(

View file

@ -72,7 +72,10 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
id: reminder.id, id: reminder.id,
at: reminder.at, at: reminder.at,
title: reminder.title, 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, 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 /// 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 /// 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 /// calendar, with a time where the person adds one and with it a duration
/// pick (D7, [pickRepeat]); or none, where [current] has one. /// (D18, as Todoist's), recurring as they pick (D7, [pickRepeat]); or
/// none, where [current] has one.
Future<DateChoice?> pickTaskDate( Future<DateChoice?> pickTaskDate(
BuildContext context, { BuildContext context, {
TaskDate? current, TaskDate? current,
@ -46,12 +47,17 @@ class const _DateSheet({required final TaskDate? current})
class _DateSheetState() extends State<_DateSheet> { class _DateSheetState() extends State<_DateSheet> {
late int? _time = widget.current?.time; 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; late final Repeat? _repeat = widget.current?.repeat;
void _choose(Day day, {Repeat? repeat}) => void _choose(Day day, {Repeat? repeat}) =>
Navigator.of(context).pop<DateChoice>(( 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 @override
@ -105,7 +111,10 @@ class _DateSheetState() extends State<_DateSheet> {
trailing: switch (_time) { trailing: switch (_time) {
final time? => InputChip( final time? => InputChip(
label: Text(timeText(context, time)), label: Text(timeText(context, time)),
onDeleted: () => setState(() => _time = null), onDeleted: () => setState(() {
_time = null;
_duration = null;
}),
), ),
null => 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( ListTile(
leading: const Icon(LucideIcons.repeat), leading: const Icon(LucideIcons.repeat),
title: Text(l10n.repeatTitle), 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(
ListTile( leading: const Icon(LucideIcons.bell),
leading: const Icon(LucideIcons.bell), title: Text(remindersText(context, content, today)),
title: Text( onTap: () async {
content.reminders.isEmpty final timed = content.date?.time != null;
? l10n.remindersNone final chosen = await pickReminders(
: [ context,
for (final before in content.reminders) before: content.reminders,
reminderText(l10n, before), at: content.reminderTimes,
].join(', '), timed: timed,
), day: content.date?.day,
onTap: () async { );
final chosen = await pickReminders(context, content.reminders); if (chosen == null || !context.mounted) {
if (chosen == null || !context.mounted) { return;
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) { await _change(
final reminders = PersonScope.of<TodoPerson>( (c) => c.copyWith(
context, reminders: chosen.before,
).reminders; reminderTimes: chosen.at,
final messenger = ScaffoldMessenger.of(context); ),
if (!await reminders.askPermission()) { );
messenger.showSnackBar( },
SnackBar(content: Text(l10n.remindersNotAllowed)), ),
);
}
}
await _change((c) => c.copyWith(reminders: chosen));
},
),
ListTile( ListTile(
leading: Icon( leading: Icon(
LucideIcons.flag, 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 /// [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. /// in light mode, its fill in dark mode, so it reads on both.
Color paletteText(BuildContext context, PaletteColor color) => Color paletteText(BuildContext context, PaletteColor color) =>

View file

@ -1470,7 +1470,13 @@ void main() {
await goTab(tester, 'Inbox'); await goTab(tester, 'Inbox');
await tester.tap(find.text('Undated')); await tester.tap(find.text('Undated'));
await tester.pumpAndSettle(); 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 closeTaskSheet(tester);
await tester.pumpAndSettle(); 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 " testWidgets("Today names a task's project and section on the line of "
'its date, at its end', (tester) async { 'its date, at its end', (tester) async {
final today = Day.of(DateTime.now()); final today = Day.of(DateTime.now());

View file

@ -14,6 +14,7 @@ void main() {
String id, { String id, {
TaskDate? date, TaskDate? date,
List<int> reminders = const [30], List<int> reminders = const [30],
List<ReminderTime> reminderTimes = const [],
DateTime? completedAt, DateTime? completedAt,
}) => StoredTask( }) => StoredTask(
id, id,
@ -22,6 +23,7 @@ void main() {
projectId: 'p', projectId: 'p',
date: date, date: date,
reminders: reminders, reminders: reminders,
reminderTimes: reminderTimes,
completedAt: completedAt, completedAt: completedAt,
), ),
); );
@ -93,6 +95,53 @@ void main() {
expect([for (final r in plan) r.taskId], ['1', '2', '3']); 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 " test("a reminder's ID is the same on every plan, and differs between a "
"task's reminders and between tasks", () { "task's reminders and between tasks", () {
PlannedReminder only(String id, int before) => planReminders([ PlannedReminder only(String id, int before) => planReminders([

View file

@ -242,7 +242,8 @@ alternative was put to them, the choice is theirs.
changes as the person switches keyboards while typing. Symbols hold in every language. changes as the person switches keyboards while typing. Symbols hold in every language.
As built (`parseQuickAdd`, `QuickAddWords.en` and `.de`): a `#` naming no project As built (`parseQuickAdd`, `QuickAddWords.en` and `.de`): a `#` naming no project
stays text, an `@` naming no label makes one as the task is added; only the first of stays text, an `@` naming no label makes one as the task is added; only the first of
each kind counts, the rest stays in the title; a time alone is its next to come each kind counts, the rest stays in the title (but for labels and, since D18,
reminders); a time alone is its next to come
(tomorrow once it has passed today), a weekday alone the coming one (today too), (tomorrow once it has passed today), a weekday alone the coming one (today too),
`next` one after today; a month needs its day (`may` alone is a word), a number alone `next` one after today; a month needs its day (`may` alone is a word), a number alone
is none; a recurrence starts on its first day from today (`every weekday` on a Sunday: is none; a recurrence starts on its first day from today (`every weekday` on a Sunday:
@ -345,6 +346,53 @@ alternative was put to them, the choice is theirs.
on Upcoming's day (`moveTask`, shared with `DayDropArea`). A later time of a on Upcoming's day (`moveTask`, shared with `DayDropArea`). A later time of a
repeating task doesn't move: only the task itself, at its next time, does. Dragging repeating task doesn't move: only the task itself, at its next time, does. Dragging
past the three days shown waits until someone misses it. past the three days shown waits until someone misses it.
- **D18 Quick add as Todoist's newsletter writes it** (the developer's, 3 Nov 2026, the
four recommended, after comparing a newsletter's examples): *Check work emails every
weekday at 9am !30m*, *Prepare document on Monday at 10am for 2h*, *Stop watching that
viral cat video !1pm !2pm !3pm !4pm*, *File my tax return every year on 1st March*.
- **`on` before a day** is taken with it (German `am`, `on` and `am` being words of the
tables, `QuickAddWords.on`), as `at` (`um`) before a time is; before anything that is
no day it stays text (*Work on report*, *Treffen am Bahnhof*). `am` after a number is
still the morning (*9 am*), read first as a time.
- **Durations**: `for` (`für`) and an amount with its unit, `for 2h`, `for 30m`, `for
1h30m`, `for 90 min`, `for 2 hours`, `für 2 Std`, `für 30 Min`, `für 1,5 Std.`, a
decimal point or comma either way, a day at most, into `TaskDate.duration`. The
units are the tables' (`durationUnits`), read by a pattern over the text, as a word
splits at a comma. **Only with a time**: a duration lasts from one, so without one
(*Read for 2h*, *Read tomorrow for 2h*) it stays text, as `!30m` on a day without a
time does (recommended, over giving the task a time to hold it, which would invent
one). The date sheet sets it once there is a time (*Duration*: none, 15, 30, 45
minutes, 1, 1½, 2, 3, 4 hours, as Todoist's time sheet offers), a time cleared taking
it with it; a row and the sheet show the span (*Tomorrow 14:00–15:30*) and the 3-day
grid a block as long, a block dragged keeping it (D17).
- **Every `!30m` counts**, each once, sorted (`[30, 60]`), as do the `@labels`; the
other kinds still count once. With a time, each is so long before it; without a date,
the task is due at the latest and each reminds that long from now (*Leave !30m !1h*:
due in an hour, reminded in 30 minutes and then); on a day without a time they stay
text. The title's highlighting marks each token, whatever its kind.
- **Reminders at a time of their own**: `!1pm`, `!13:00`, `!tomorrow 9am`, `!Monday
8:30`, `!morgen um 9 Uhr`, as many as typed, into a task's `reminderTimes`
(`ReminderTime`: a day and minutes after midnight, the wall clock without a zone,
as `TaskDate`'s, so one comes at that hour wherever the device is). **A time without
a day** is on the task's day where it has one (a recurrence's first), else today, or
tomorrow where that time has passed, as a time alone is (D15). Each is a moment of
its own: it neither needs a date nor follows one, a recurring task's coming once,
not every time. A `!` before no time (*!tomorrow*, *!9*) stays text. Planned with the
rest (`planReminders`, an ID of their own each), the notification saying *Due at
14:00* for a task with a time, *Reminder* for one without; the task's sheet now
always has its reminders' row, the reminders before its time only where it has
one, and those at a time of their own each with its ✕ and *Remind at a time* (a day,
then a time), the permission asked as a first one is chosen.
- **The payload's schema stays 1** (as D17's view): `reminderTimes`, as `[{"day":
"2026-11-03", "time": 780}]`, left out where there are none, so older payloads read
with none. And from now on **a task's payload keeps what this version doesn't know**
(`TaskContent.unknown`, the fields at its top that it doesn't read, written back
beneath the ones it does, which win): Todo isn't released yet, so every version out
keeps a newer one's fields as it changes a task, where Learn's and Train's payloads
drop them. Recommended over bumping the schema for each field, which would show a
task locked on every device not yet updated. A field a newer version adds within the
date, or one whose meaning hangs on fields an older one changes, isn't kept so; such
a change still bumps the schema. No server model changes: the payload is sealed.
## Steps ## Steps