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:
parent
d40eca6e50
commit
f5b657dfad
15 changed files with 603 additions and 70 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue