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
14 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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue