feat(todo): recurring tasks, set through a picker (ADR 0033, D7)
A task's date may recur: every n days, weeks (on which weekdays), months (on its day, or its nth or last weekday), years, from its date or from when it is done, until a day or for n times. Done, it moves on to the next day after both its date and today, its deadline as far, its time kept, its done sub-tasks open again and when it was done kept in its payload; undo writes all of it back. The date sheet offers the rules that fit the task's day and a rule of the person's own; rows show a recurring date with its own icon, the task sheet the rule in words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
030c62ddde
commit
aa013f5a00
18 changed files with 1655 additions and 25 deletions
|
|
@ -94,5 +94,30 @@
|
|||
"todayEmptyHint": "Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.",
|
||||
"lockedRows": "{count, plural, =1{1 Eintrag lässt sich auf diesem Gerät nicht öffnen.} other{{count} Einträge lassen sich auf diesem Gerät nicht öffnen.}}",
|
||||
"inboxMissingTitle": "Noch kein Eingang",
|
||||
"inboxMissingHint": "Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat."
|
||||
"inboxMissingHint": "Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.",
|
||||
"repeatTitle": "Wiederholen",
|
||||
"repeatNone": "Keine Wiederholung",
|
||||
"repeatCustom": "Eigene Regel",
|
||||
"repeatEveryDay": "{count, plural, =1{Jeden Tag} other{Alle {count} Tage}}",
|
||||
"repeatEveryWeek": "{count, plural, =1{Jede Woche} other{Alle {count} Wochen}}",
|
||||
"repeatEveryMonth": "{count, plural, =1{Jeden Monat} other{Alle {count} Monate}}",
|
||||
"repeatEveryYear": "{count, plural, =1{Jedes Jahr} other{Alle {count} Jahre}}",
|
||||
"repeatWeekdays": "Jeden Werktag",
|
||||
"repeatOn": "{rule} am {on}",
|
||||
"repeatMonthDay": "{day}.",
|
||||
"repeatMonthWeek": "{nth, select, 1{ersten} 2{zweiten} 3{dritten} 4{vierten} other{letzten}} {weekday}",
|
||||
"repeatFromCompletion": "{rule}, ab Erledigung",
|
||||
"repeatUntil": "{rule}, bis {day}",
|
||||
"repeatLeft": "{rule}, {count, plural, =0{nur noch diesmal} =1{noch einmal} other{noch {count}-mal}}",
|
||||
"repeatEvery": "Alle",
|
||||
"repeatUnitDays": "Tage",
|
||||
"repeatUnitWeeks": "Wochen",
|
||||
"repeatUnitMonths": "Monate",
|
||||
"repeatUnitYears": "Jahre",
|
||||
"repeatCountFromCompletion": "Ab Erledigung zählen",
|
||||
"repeatCountFromCompletionHint": "Nicht ab ihrem Datum: später erledigt, kommt sie entsprechend später wieder.",
|
||||
"repeatEnds": "Endet",
|
||||
"repeatEndsNever": "Nie",
|
||||
"repeatEndsOn": "An einem Tag",
|
||||
"repeatEndsAfter": "{count, plural, =1{Nach einem Mal} other{Nach {count} Malen}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,5 +108,41 @@
|
|||
"lockedRows": "{count, plural, =1{1 item doesn't open on this device.} other{{count} items don't open on this device.}}",
|
||||
"@lockedRows": {"placeholders": {"count": {"type": "int"}}},
|
||||
"inboxMissingTitle": "No Inbox yet",
|
||||
"inboxMissingHint": "Your Inbox comes with the next sync, or once this device holds your account's keys."
|
||||
"inboxMissingHint": "Your Inbox comes with the next sync, or once this device holds your account's keys.",
|
||||
"repeatTitle": "Repeat",
|
||||
"repeatNone": "Doesn't repeat",
|
||||
"repeatCustom": "Custom",
|
||||
"repeatEveryDay": "{count, plural, =1{Every day} other{Every {count} days}}",
|
||||
"@repeatEveryDay": {"placeholders": {"count": {"type": "int"}}},
|
||||
"repeatEveryWeek": "{count, plural, =1{Every week} other{Every {count} weeks}}",
|
||||
"@repeatEveryWeek": {"placeholders": {"count": {"type": "int"}}},
|
||||
"repeatEveryMonth": "{count, plural, =1{Every month} other{Every {count} months}}",
|
||||
"@repeatEveryMonth": {"placeholders": {"count": {"type": "int"}}},
|
||||
"repeatEveryYear": "{count, plural, =1{Every year} other{Every {count} years}}",
|
||||
"@repeatEveryYear": {"placeholders": {"count": {"type": "int"}}},
|
||||
"repeatWeekdays": "Every weekday",
|
||||
"repeatOn": "{rule} on {on}",
|
||||
"@repeatOn": {"placeholders": {"rule": {"type": "String"}, "on": {"type": "String"}}},
|
||||
"repeatMonthDay": "day {day}",
|
||||
"@repeatMonthDay": {"placeholders": {"day": {"type": "int"}}},
|
||||
"repeatMonthWeek": "the {nth, select, 1{first} 2{second} 3{third} 4{fourth} other{last}} {weekday}",
|
||||
"@repeatMonthWeek": {"placeholders": {"nth": {"type": "String"}, "weekday": {"type": "String"}}},
|
||||
"repeatFromCompletion": "{rule}, from when it's done",
|
||||
"@repeatFromCompletion": {"placeholders": {"rule": {"type": "String"}}},
|
||||
"repeatUntil": "{rule}, until {day}",
|
||||
"@repeatUntil": {"placeholders": {"rule": {"type": "String"}, "day": {"type": "String"}}},
|
||||
"repeatLeft": "{rule}, {count, plural, =0{this time only} =1{once more} other{{count} more times}}",
|
||||
"@repeatLeft": {"placeholders": {"rule": {"type": "String"}, "count": {"type": "int"}}},
|
||||
"repeatEvery": "Every",
|
||||
"repeatUnitDays": "Days",
|
||||
"repeatUnitWeeks": "Weeks",
|
||||
"repeatUnitMonths": "Months",
|
||||
"repeatUnitYears": "Years",
|
||||
"repeatCountFromCompletion": "Count from when it's done",
|
||||
"repeatCountFromCompletionHint": "Not from its date: done late, it comes again as long after.",
|
||||
"repeatEnds": "Ends",
|
||||
"repeatEndsNever": "Never",
|
||||
"repeatEndsOn": "On a day",
|
||||
"repeatEndsAfter": "{count, plural, =1{After once} other{After {count} times}}",
|
||||
"@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -633,6 +633,156 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'**
|
||||
String get inboxMissingHint;
|
||||
|
||||
/// No description provided for @repeatTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Repeat'**
|
||||
String get repeatTitle;
|
||||
|
||||
/// No description provided for @repeatNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Doesn\'t repeat'**
|
||||
String get repeatNone;
|
||||
|
||||
/// No description provided for @repeatCustom.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Custom'**
|
||||
String get repeatCustom;
|
||||
|
||||
/// No description provided for @repeatEveryDay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Every day} other{Every {count} days}}'**
|
||||
String repeatEveryDay(int count);
|
||||
|
||||
/// No description provided for @repeatEveryWeek.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Every week} other{Every {count} weeks}}'**
|
||||
String repeatEveryWeek(int count);
|
||||
|
||||
/// No description provided for @repeatEveryMonth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Every month} other{Every {count} months}}'**
|
||||
String repeatEveryMonth(int count);
|
||||
|
||||
/// No description provided for @repeatEveryYear.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Every year} other{Every {count} years}}'**
|
||||
String repeatEveryYear(int count);
|
||||
|
||||
/// No description provided for @repeatWeekdays.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every weekday'**
|
||||
String get repeatWeekdays;
|
||||
|
||||
/// No description provided for @repeatOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{rule} on {on}'**
|
||||
String repeatOn(String rule, String on);
|
||||
|
||||
/// No description provided for @repeatMonthDay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'day {day}'**
|
||||
String repeatMonthDay(int day);
|
||||
|
||||
/// No description provided for @repeatMonthWeek.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'the {nth, select, 1{first} 2{second} 3{third} 4{fourth} other{last}} {weekday}'**
|
||||
String repeatMonthWeek(String nth, String weekday);
|
||||
|
||||
/// No description provided for @repeatFromCompletion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{rule}, from when it\'s done'**
|
||||
String repeatFromCompletion(String rule);
|
||||
|
||||
/// No description provided for @repeatUntil.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{rule}, until {day}'**
|
||||
String repeatUntil(String rule, String day);
|
||||
|
||||
/// No description provided for @repeatLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{rule}, {count, plural, =0{this time only} =1{once more} other{{count} more times}}'**
|
||||
String repeatLeft(String rule, int count);
|
||||
|
||||
/// No description provided for @repeatEvery.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every'**
|
||||
String get repeatEvery;
|
||||
|
||||
/// No description provided for @repeatUnitDays.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Days'**
|
||||
String get repeatUnitDays;
|
||||
|
||||
/// No description provided for @repeatUnitWeeks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weeks'**
|
||||
String get repeatUnitWeeks;
|
||||
|
||||
/// No description provided for @repeatUnitMonths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Months'**
|
||||
String get repeatUnitMonths;
|
||||
|
||||
/// No description provided for @repeatUnitYears.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Years'**
|
||||
String get repeatUnitYears;
|
||||
|
||||
/// No description provided for @repeatCountFromCompletion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Count from when it\'s done'**
|
||||
String get repeatCountFromCompletion;
|
||||
|
||||
/// No description provided for @repeatCountFromCompletionHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not from its date: done late, it comes again as long after.'**
|
||||
String get repeatCountFromCompletionHint;
|
||||
|
||||
/// No description provided for @repeatEnds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ends'**
|
||||
String get repeatEnds;
|
||||
|
||||
/// No description provided for @repeatEndsNever.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never'**
|
||||
String get repeatEndsNever;
|
||||
|
||||
/// No description provided for @repeatEndsOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On a day'**
|
||||
String get repeatEndsOn;
|
||||
|
||||
/// No description provided for @repeatEndsAfter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{After once} other{After {count} times}}'**
|
||||
String repeatEndsAfter(int count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -322,4 +322,149 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get inboxMissingHint =>
|
||||
'Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.';
|
||||
|
||||
@override
|
||||
String get repeatTitle => 'Wiederholen';
|
||||
|
||||
@override
|
||||
String get repeatNone => 'Keine Wiederholung';
|
||||
|
||||
@override
|
||||
String get repeatCustom => 'Eigene Regel';
|
||||
|
||||
@override
|
||||
String repeatEveryDay(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Alle $count Tage',
|
||||
one: 'Jeden Tag',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatEveryWeek(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Alle $count Wochen',
|
||||
one: 'Jede Woche',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatEveryMonth(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Alle $count Monate',
|
||||
one: 'Jeden Monat',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatEveryYear(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Alle $count Jahre',
|
||||
one: 'Jedes Jahr',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get repeatWeekdays => 'Jeden Werktag';
|
||||
|
||||
@override
|
||||
String repeatOn(String rule, String on) {
|
||||
return '$rule am $on';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatMonthDay(int day) {
|
||||
return '$day.';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatMonthWeek(String nth, String weekday) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
nth,
|
||||
{
|
||||
'1': 'ersten',
|
||||
'2': 'zweiten',
|
||||
'3': 'dritten',
|
||||
'4': 'vierten',
|
||||
'other': 'letzten',
|
||||
},
|
||||
);
|
||||
return '$_temp0 $weekday';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatFromCompletion(String rule) {
|
||||
return '$rule, ab Erledigung';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatUntil(String rule, String day) {
|
||||
return '$rule, bis $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatLeft(String rule, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'noch $count-mal',
|
||||
one: 'noch einmal',
|
||||
zero: 'nur noch diesmal',
|
||||
);
|
||||
return '$rule, $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get repeatEvery => 'Alle';
|
||||
|
||||
@override
|
||||
String get repeatUnitDays => 'Tage';
|
||||
|
||||
@override
|
||||
String get repeatUnitWeeks => 'Wochen';
|
||||
|
||||
@override
|
||||
String get repeatUnitMonths => 'Monate';
|
||||
|
||||
@override
|
||||
String get repeatUnitYears => 'Jahre';
|
||||
|
||||
@override
|
||||
String get repeatCountFromCompletion => 'Ab Erledigung zählen';
|
||||
|
||||
@override
|
||||
String get repeatCountFromCompletionHint =>
|
||||
'Nicht ab ihrem Datum: später erledigt, kommt sie entsprechend später wieder.';
|
||||
|
||||
@override
|
||||
String get repeatEnds => 'Endet';
|
||||
|
||||
@override
|
||||
String get repeatEndsNever => 'Nie';
|
||||
|
||||
@override
|
||||
String get repeatEndsOn => 'An einem Tag';
|
||||
|
||||
@override
|
||||
String repeatEndsAfter(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Nach $count Malen',
|
||||
one: 'Nach einem Mal',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,151 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get inboxMissingHint =>
|
||||
'Your Inbox comes with the next sync, or once this device holds your account\'s keys.';
|
||||
|
||||
@override
|
||||
String get repeatTitle => 'Repeat';
|
||||
|
||||
@override
|
||||
String get repeatNone => 'Doesn\'t repeat';
|
||||
|
||||
@override
|
||||
String get repeatCustom => 'Custom';
|
||||
|
||||
@override
|
||||
String repeatEveryDay(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Every $count days',
|
||||
one: 'Every day',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatEveryWeek(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Every $count weeks',
|
||||
one: 'Every week',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatEveryMonth(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Every $count months',
|
||||
one: 'Every month',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatEveryYear(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Every $count years',
|
||||
one: 'Every year',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get repeatWeekdays => 'Every weekday';
|
||||
|
||||
@override
|
||||
String repeatOn(String rule, String on) {
|
||||
return '$rule on $on';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatMonthDay(int day) {
|
||||
return 'day $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatMonthWeek(String nth, String weekday) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
nth,
|
||||
{
|
||||
'1': 'first',
|
||||
'2': 'second',
|
||||
'3': 'third',
|
||||
'4': 'fourth',
|
||||
'other': 'last',
|
||||
},
|
||||
);
|
||||
return 'the $_temp0 $weekday';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatFromCompletion(String rule) {
|
||||
return '$rule, from when it\'s done';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatUntil(String rule, String day) {
|
||||
return '$rule, until $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String repeatLeft(String rule, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count more times',
|
||||
one: 'once more',
|
||||
zero: 'this time only',
|
||||
);
|
||||
return '$rule, $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get repeatEvery => 'Every';
|
||||
|
||||
@override
|
||||
String get repeatUnitDays => 'Days';
|
||||
|
||||
@override
|
||||
String get repeatUnitWeeks => 'Weeks';
|
||||
|
||||
@override
|
||||
String get repeatUnitMonths => 'Months';
|
||||
|
||||
@override
|
||||
String get repeatUnitYears => 'Years';
|
||||
|
||||
@override
|
||||
String get repeatCountFromCompletion => 'Count from when it\'s done';
|
||||
|
||||
@override
|
||||
String get repeatCountFromCompletionHint =>
|
||||
'Not from its date: done late, it comes again as long after.';
|
||||
|
||||
@override
|
||||
String get repeatEnds => 'Ends';
|
||||
|
||||
@override
|
||||
String get repeatEndsNever => 'Never';
|
||||
|
||||
@override
|
||||
String get repeatEndsOn => 'On a day';
|
||||
|
||||
@override
|
||||
String repeatEndsAfter(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'After $count times',
|
||||
one: 'After once',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// The schema of a task's payload this app writes and reads.
|
||||
|
|
@ -8,7 +9,9 @@ const taskSchemaVersion = 1;
|
|||
|
||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, days as
|
||||
/// `YYYY-MM-DD`, a time as minutes after midnight, a duration in minutes,
|
||||
/// the priority as its number, when it was done in UTC.
|
||||
/// the priority as its number, when it was done in UTC; a recurrence (D7)
|
||||
/// within its date, by its unit's name and only what differs from its
|
||||
/// defaults.
|
||||
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||
'title': content.title,
|
||||
'projectId': content.projectId,
|
||||
|
|
@ -21,14 +24,30 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
|||
'day': '${date.day}',
|
||||
'time': ?date.time,
|
||||
'minutes': ?date.duration?.inMinutes,
|
||||
if (date.repeat case final repeat?) 'repeat': _encodeRepeat(repeat),
|
||||
},
|
||||
if (content.deadline case final deadline?) 'deadline': '$deadline',
|
||||
if (content.priority != Priority.none) 'priority': content.priority.number,
|
||||
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
|
||||
if (content.completedAt case final at?)
|
||||
'completedAt': at.toUtc().toIso8601String(),
|
||||
if (content.completions.isNotEmpty)
|
||||
'completions': [
|
||||
for (final at in content.completions) at.toUtc().toIso8601String(),
|
||||
],
|
||||
});
|
||||
|
||||
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
|
||||
'unit': repeat.unit.name,
|
||||
if (repeat.every != 1) 'every': repeat.every,
|
||||
if (repeat.weekdays.isNotEmpty) 'weekdays': [...repeat.weekdays]..sort(),
|
||||
'monthDay': ?repeat.monthDay,
|
||||
'monthWeek': ?repeat.monthWeek,
|
||||
if (repeat.fromCompletion) 'fromCompletion': true,
|
||||
if (repeat.until case final until?) 'until': '$until',
|
||||
'left': ?repeat.left,
|
||||
};
|
||||
|
||||
/// The task [payload] holds, opened; null for one of a newer schema or one
|
||||
/// it can't read: a task is never read half, so one whose date can't be
|
||||
/// read isn't read at all.
|
||||
|
|
@ -62,6 +81,10 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
|
|||
final String at => DateTime.parse(at).toLocal(),
|
||||
_ => null,
|
||||
},
|
||||
completions: [
|
||||
for (final at in json['completions'] as List<Object?>? ?? const [])
|
||||
DateTime.parse(at! as String).toLocal(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -78,5 +101,48 @@ TaskDate _date(Map<String, Object?> json) {
|
|||
final int minutes => Duration(minutes: minutes),
|
||||
_ => null,
|
||||
},
|
||||
repeat: switch (json['repeat']) {
|
||||
final Map<String, Object?> repeat => _repeat(repeat),
|
||||
null => null,
|
||||
_ => throw const FormatException('repeat'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A recurrence as [_encodeRepeat] wrote it; throws for one it can't
|
||||
/// follow, so that its task isn't read half.
|
||||
Repeat _repeat(Map<String, Object?> json) {
|
||||
final unit = RepeatUnit.values.byName(json['unit']! as String);
|
||||
final every = json['every'] as int? ?? 1;
|
||||
final weekdays = {
|
||||
for (final day in json['weekdays'] as List<Object?>? ?? const [])
|
||||
day! as int,
|
||||
};
|
||||
final monthDay = json['monthDay'] as int?;
|
||||
final monthWeek = json['monthWeek'] as int?;
|
||||
final left = json['left'] as int?;
|
||||
final until = switch (json['until']) {
|
||||
final String day => Day.tryParse(day) ?? (throw const FormatException()),
|
||||
null => null,
|
||||
_ => throw const FormatException('until'),
|
||||
};
|
||||
if (every < 1 ||
|
||||
weekdays.any((day) => day < 1 || day > 7) ||
|
||||
(monthDay != null && (monthDay < 1 || monthDay > 31)) ||
|
||||
(monthWeek != null &&
|
||||
monthWeek != -1 &&
|
||||
(monthWeek < 1 || monthWeek > 4)) ||
|
||||
(left != null && left < 0)) {
|
||||
throw const FormatException('repeat');
|
||||
}
|
||||
return Repeat(
|
||||
unit,
|
||||
every: every,
|
||||
weekdays: weekdays,
|
||||
monthDay: monthDay,
|
||||
monthWeek: monthWeek,
|
||||
fromCompletion: json['fromCompletion'] as bool? ?? false,
|
||||
until: until,
|
||||
left: left,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,13 +33,61 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
|||
Future<void> update(String id, TaskContent content) =>
|
||||
_rows.update(id, content);
|
||||
|
||||
/// Marks [task] done now, or not done where [done] is false.
|
||||
Future<void> complete(StoredTask task, {bool done = true}) async {
|
||||
if (task.content case final content?) {
|
||||
/// Marks [task] done now, or not done where [done] is false, and returns
|
||||
/// every task it changed as it was before, for [restore] to undo it.
|
||||
///
|
||||
/// A recurring task (ADR 0033, D7) done isn't marked done but moves on to
|
||||
/// its next day, its deadline as far, its time kept, with when it was done
|
||||
/// kept in its [TaskContent.completions], and its sub-tasks open again,
|
||||
/// as Todoist's do; done its last time, it is done as any other.
|
||||
Future<List<StoredTask>> complete(
|
||||
StoredTask task, {
|
||||
bool done = true,
|
||||
}) async {
|
||||
final content = task.content;
|
||||
if (content == null) {
|
||||
return const [];
|
||||
}
|
||||
final now = clock.now();
|
||||
final date = content.date;
|
||||
final repeat = date?.repeat;
|
||||
final next = done && date != null && repeat != null
|
||||
? repeat.next(date.day, today: Day.of(now))
|
||||
: null;
|
||||
if (date == null || repeat == null || next == null) {
|
||||
await update(
|
||||
task.id,
|
||||
content.copyWith(completedAt: () => done ? clock.now() : null),
|
||||
content.copyWith(completedAt: () => done ? now : null),
|
||||
);
|
||||
return [task];
|
||||
}
|
||||
final moved = date.day.daysUntil(next);
|
||||
final tasks = await all();
|
||||
final below = _withDescendants({task.id}, tasks)..remove(task.id);
|
||||
final reopened = [
|
||||
for (final sub in tasks)
|
||||
if (below.contains(sub.id) && (sub.content?.completed ?? false)) sub,
|
||||
];
|
||||
await update(
|
||||
task.id,
|
||||
content.copyWith(
|
||||
date: () => date.on(next).repeating(repeat.counted()),
|
||||
deadline: () => content.deadline?.plus(moved),
|
||||
completions: [...content.completions, now],
|
||||
),
|
||||
);
|
||||
for (final sub in reopened) {
|
||||
await update(sub.id, sub.content!.copyWith(completedAt: () => null));
|
||||
}
|
||||
return [task, ...reopened];
|
||||
}
|
||||
|
||||
/// Writes [tasks] back as they were, undoing what [complete] changed.
|
||||
Future<void> restore(List<StoredTask> tasks) async {
|
||||
for (final task in tasks) {
|
||||
if (task.content case final content?) {
|
||||
await update(task.id, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// What a recurrence counts in (ADR 0033, D7).
|
||||
enum RepeatUnit() {
|
||||
day,
|
||||
week,
|
||||
month,
|
||||
year,
|
||||
}
|
||||
|
||||
/// A task's recurrence (ADR 0033, D7), a rule set through a picker: [every]
|
||||
/// so many [unit]s, from its date or, where [fromCompletion], from the day
|
||||
/// it was done; until a day, or for [left] more times.
|
||||
final class const Repeat(
|
||||
final RepeatUnit unit, {
|
||||
final int every = 1,
|
||||
|
||||
/// The weekdays (`DateTime.monday` to `sunday`) a weekly one falls on;
|
||||
/// none: its date's.
|
||||
final Set<int> weekdays = const {},
|
||||
|
||||
/// The day of the month (1 to 31) a monthly or yearly one falls on, in a
|
||||
/// shorter month its last; none: its date's.
|
||||
final int? monthDay,
|
||||
|
||||
/// Where a monthly one falls on its date's weekday: the month's first to
|
||||
/// fourth (1 to 4), or its last (-1); none: on [monthDay].
|
||||
final int? monthWeek,
|
||||
|
||||
/// Counted from the day it was done, not from its date: always [every]
|
||||
/// units on, on no particular weekday or day.
|
||||
final bool fromCompletion = false,
|
||||
|
||||
/// The last day it may fall on.
|
||||
final Day? until,
|
||||
|
||||
/// How many times more it comes after the one now due; none: endlessly.
|
||||
final int? left,
|
||||
}) with Equatable {
|
||||
/// The day a task due on [date] moves on to when it is done on [today];
|
||||
/// none where the recurrence has ended. Counted from its date, it is the
|
||||
/// first after both: an overdue task isn't moved onto a day already
|
||||
/// past, nor onto the day it was done.
|
||||
Day? next(Day date, {required Day today}) {
|
||||
if (left == 0) {
|
||||
return null;
|
||||
}
|
||||
final Day next;
|
||||
if (fromCompletion) {
|
||||
next = _plain(today, today);
|
||||
} else {
|
||||
final after = date > today ? date : today;
|
||||
var day = _after(date, date);
|
||||
while (!(day > after)) {
|
||||
day = _after(day, date);
|
||||
}
|
||||
next = day;
|
||||
}
|
||||
return switch (until) {
|
||||
final last? when next > last => null,
|
||||
_ => next,
|
||||
};
|
||||
}
|
||||
|
||||
/// This recurrence once one of its times has come: [left] one fewer.
|
||||
Repeat counted() => switch (left) {
|
||||
final left? when left > 0 => _with(left: left - 1),
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// The first day on or after [day] it falls on, for a task given it
|
||||
/// anew: Monday for every weekday given on a Saturday.
|
||||
Day firstFrom(Day day) {
|
||||
if (fromCompletion) {
|
||||
return day;
|
||||
}
|
||||
switch (unit) {
|
||||
case RepeatUnit.week when weekdays.isNotEmpty:
|
||||
var first = day;
|
||||
while (!weekdays.contains(first.weekday)) {
|
||||
first = first.plus(1);
|
||||
}
|
||||
return first;
|
||||
case RepeatUnit.month when monthWeek != null:
|
||||
final here = _nthWeekday(day.year, day.month, day.weekday, monthWeek!);
|
||||
return here < day
|
||||
? _nthWeekday(day.year, day.month + 1, day.weekday, monthWeek!)
|
||||
: here;
|
||||
case RepeatUnit.month when monthDay != null:
|
||||
final here = _clamped(day.year, day.month, monthDay!);
|
||||
return here < day ? _clamped(day.year, day.month + 1, monthDay!) : here;
|
||||
case _:
|
||||
return day;
|
||||
}
|
||||
}
|
||||
|
||||
/// The first day after [day] it falls on, for a recurrence that started
|
||||
/// on [anchor] (whose week, weekday and day of the month it keeps).
|
||||
Day _after(Day day, Day anchor) {
|
||||
switch (unit) {
|
||||
case RepeatUnit.week when weekdays.isNotEmpty:
|
||||
final start = anchor.weekStart;
|
||||
var next = day.plus(1);
|
||||
while (!weekdays.contains(next.weekday) ||
|
||||
(start.daysUntil(next.weekStart) ~/ 7) % every != 0) {
|
||||
next = next.plus(1);
|
||||
}
|
||||
return next;
|
||||
case RepeatUnit.month when monthWeek != null:
|
||||
return _nthWeekday(
|
||||
day.year,
|
||||
day.month + every,
|
||||
anchor.weekday,
|
||||
monthWeek!,
|
||||
);
|
||||
case _:
|
||||
return _plain(day, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
/// [every] units after [day], on [anchor]'s day of the month (or
|
||||
/// [monthDay]) for months and years.
|
||||
Day _plain(Day day, Day anchor) => switch (unit) {
|
||||
RepeatUnit.day => day.plus(every),
|
||||
RepeatUnit.week => day.plus(7 * every),
|
||||
RepeatUnit.month => _clamped(
|
||||
day.year,
|
||||
day.month + every,
|
||||
fromCompletion ? day.day : monthDay ?? anchor.day,
|
||||
),
|
||||
RepeatUnit.year => _clamped(
|
||||
day.year + every,
|
||||
day.month,
|
||||
fromCompletion ? day.day : monthDay ?? anchor.day,
|
||||
),
|
||||
};
|
||||
|
||||
/// The [day] of [month] (past December: of the next year's), or the
|
||||
/// month's last day where it is shorter.
|
||||
static Day _clamped(int year, int month, int day) {
|
||||
final first = Day.of(DateTime(year, month));
|
||||
return Day(first.year, first.month, min(day, first.daysInMonth));
|
||||
}
|
||||
|
||||
/// The [nth] [weekday] of [month] (-1: its last).
|
||||
static Day _nthWeekday(int year, int month, int weekday, int nth) {
|
||||
final first = Day.of(DateTime(year, month));
|
||||
if (nth > 0) {
|
||||
return first.plus((weekday - first.weekday + 7) % 7 + 7 * (nth - 1));
|
||||
}
|
||||
final last = Day(first.year, first.month, first.daysInMonth);
|
||||
return last.plus(-((last.weekday - weekday + 7) % 7));
|
||||
}
|
||||
|
||||
Repeat _with({required int left}) => Repeat(
|
||||
unit,
|
||||
every: every,
|
||||
weekdays: weekdays,
|
||||
monthDay: monthDay,
|
||||
monthWeek: monthWeek,
|
||||
fromCompletion: fromCompletion,
|
||||
until: until,
|
||||
left: left,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
unit,
|
||||
every,
|
||||
// Sorted, so that sets in any order compare equal.
|
||||
[...weekdays]..sort(),
|
||||
monthDay,
|
||||
monthWeek,
|
||||
fromCompletion,
|
||||
until,
|
||||
left,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
|
||||
/// A day of the calendar, as a task's date and deadline name it (ADR 0033,
|
||||
/// D6): the person's own, the same wherever they are.
|
||||
|
|
@ -30,6 +31,21 @@ final class const Day(final int year, final int month, final int day)
|
|||
/// The day [days] later (or earlier, below zero).
|
||||
Day plus(int days) => Day.of(DateTime(year, month, day + days));
|
||||
|
||||
/// Its weekday, `DateTime.monday` (1) to `sunday` (7).
|
||||
int get weekday => toDate().weekday;
|
||||
|
||||
/// The Monday of its week.
|
||||
Day get weekStart => plus(1 - weekday);
|
||||
|
||||
/// How many days its month has.
|
||||
int get daysInMonth => DateTime(year, month + 1, 0).day;
|
||||
|
||||
/// Which of its month's weekdays like it it is: 1 for the first.
|
||||
int get weekOfMonth => (day - 1) ~/ 7 + 1;
|
||||
|
||||
/// Whether it is the last of its month's weekdays like it.
|
||||
bool get lastWeekOfMonth => day + 7 > daysInMonth;
|
||||
|
||||
/// How many days [other] comes after this one.
|
||||
int daysUntil(Day other) => DateTime.utc(
|
||||
other.year,
|
||||
|
|
@ -55,16 +71,25 @@ final class const Day(final int year, final int month, final int day)
|
|||
|
||||
/// When a task is to be done (ADR 0033, D6): a [day], and on it a [time]
|
||||
/// (minutes after midnight, the person's wall clock) with a [duration]
|
||||
/// where one is given. A time without its zone means the same hour wherever
|
||||
/// the person is, as a paper diary has it; reminders (step 4) may add the
|
||||
/// zone.
|
||||
/// where one is given, and how it recurs, if it does ([repeat], D7). A time
|
||||
/// without its zone means the same hour wherever the person is, as a paper
|
||||
/// diary has it; reminders (step 4) may add the zone.
|
||||
final class const TaskDate(
|
||||
final Day day, {
|
||||
final int? time,
|
||||
final Duration? duration,
|
||||
final Repeat? repeat,
|
||||
}) with Equatable {
|
||||
/// The same on [day].
|
||||
TaskDate on(Day day) =>
|
||||
TaskDate(day, time: time, duration: duration, repeat: repeat);
|
||||
|
||||
/// The same, recurring as [repeat] (none: not).
|
||||
TaskDate repeating(Repeat? repeat) =>
|
||||
TaskDate(day, time: time, duration: duration, repeat: repeat);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [day, time, duration];
|
||||
List<Object?> get props => [day, time, duration, repeat];
|
||||
}
|
||||
|
||||
/// How urgent a task is (D6): [p1] the most, [none] not marked.
|
||||
|
|
@ -116,6 +141,10 @@ final class const TaskContent({
|
|||
|
||||
/// When it was done; none: not yet.
|
||||
final DateTime? completedAt,
|
||||
|
||||
/// When a recurring task's past times were done, the oldest first (D6),
|
||||
/// for Reporting to count.
|
||||
final List<DateTime> completions = const [],
|
||||
}) with Equatable {
|
||||
/// Whether it is done.
|
||||
bool get completed => completedAt != null;
|
||||
|
|
@ -132,6 +161,7 @@ final class const TaskContent({
|
|||
Priority? priority,
|
||||
List<String>? labelIds,
|
||||
DateTime? Function()? completedAt,
|
||||
List<DateTime>? completions,
|
||||
}) => TaskContent(
|
||||
title: title ?? this.title,
|
||||
projectId: projectId ?? this.projectId,
|
||||
|
|
@ -144,6 +174,7 @@ final class const TaskContent({
|
|||
priority: priority ?? this.priority,
|
||||
labelIds: labelIds ?? this.labelIds,
|
||||
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
||||
completions: completions ?? this.completions,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -159,6 +190,7 @@ final class const TaskContent({
|
|||
priority,
|
||||
labelIds,
|
||||
completedAt,
|
||||
completions,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.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';
|
||||
|
|
@ -12,8 +14,8 @@ 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; or none, where
|
||||
/// [current] has one.
|
||||
/// calendar, with a time where the person adds one, recurring as they
|
||||
/// pick (D7, [pickRepeat]); or none, where [current] has one.
|
||||
Future<DateChoice?> pickTaskDate(
|
||||
BuildContext context, {
|
||||
TaskDate? current,
|
||||
|
|
@ -44,10 +46,12 @@ 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 final Repeat? _repeat = widget.current?.repeat;
|
||||
|
||||
void _choose(Day day) => Navigator.of(
|
||||
context,
|
||||
).pop<DateChoice>((date: TaskDate(day, time: _time, duration: _duration)));
|
||||
void _choose(Day day, {Repeat? repeat}) =>
|
||||
Navigator.of(context).pop<DateChoice>((
|
||||
date: TaskDate(day, time: _time, duration: _duration, repeat: repeat),
|
||||
));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -67,7 +71,7 @@ class _DateSheetState() extends State<_DateSheet> {
|
|||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
trailing: Text(dayText(context, day, today)),
|
||||
onTap: () => _choose(day),
|
||||
onTap: () => _choose(day, repeat: _repeat),
|
||||
);
|
||||
return SheetScaffold(
|
||||
title: l10n.dateTitle,
|
||||
|
|
@ -90,7 +94,7 @@ class _DateSheetState() extends State<_DateSheet> {
|
|||
lastDate: DateTime(today.year + 20),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
_choose(Day.of(picked));
|
||||
_choose(Day.of(picked), repeat: _repeat);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -120,6 +124,26 @@ class _DateSheetState() extends State<_DateSheet> {
|
|||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.repeat),
|
||||
title: Text(l10n.repeatTitle),
|
||||
subtitle: switch (_repeat) {
|
||||
final repeat? => Text(
|
||||
repeatText(context, repeat, widget.current?.day ?? today),
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
onTap: () async {
|
||||
final day = widget.current?.day ?? today;
|
||||
if (await pickRepeat(context, day: day, current: _repeat)
|
||||
case (
|
||||
:final repeat,
|
||||
)
|
||||
when context.mounted) {
|
||||
_choose(repeat?.firstFrom(day) ?? day, repeat: repeat);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (widget.current != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.calendarX),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A recurrence chosen ([Repeat] or none, to stop it); null from
|
||||
/// [pickRepeat] where the person changed their mind.
|
||||
typedef RepeatChoice = ({Repeat? repeat});
|
||||
|
||||
/// [repeat] in words (ADR 0033, D7), for a task due on [day]: "Every 2
|
||||
/// weeks on Mon, Thu, until 1 Mar".
|
||||
String repeatText(BuildContext context, Repeat repeat, Day day) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final every = repeat.every;
|
||||
final workdays = {for (var d = 1; d <= 5; d++) d};
|
||||
var rule = switch (repeat.unit) {
|
||||
RepeatUnit.week
|
||||
when every == 1 &&
|
||||
repeat.weekdays.length == 5 &&
|
||||
repeat.weekdays.containsAll(workdays) =>
|
||||
l10n.repeatWeekdays,
|
||||
RepeatUnit.day => l10n.repeatEveryDay(every),
|
||||
RepeatUnit.week => l10n.repeatEveryWeek(every),
|
||||
RepeatUnit.month => l10n.repeatEveryMonth(every),
|
||||
RepeatUnit.year => l10n.repeatEveryYear(every),
|
||||
};
|
||||
if (!repeat.fromCompletion) {
|
||||
final on = switch (repeat.unit) {
|
||||
RepeatUnit.week when rule == l10n.repeatWeekdays => null,
|
||||
RepeatUnit.week => [
|
||||
for (final weekday
|
||||
in repeat.weekdays.isEmpty
|
||||
? [day.weekday]
|
||||
: ([...repeat.weekdays]..sort()))
|
||||
weekdayName(locale, weekday, short: true),
|
||||
].join(', '),
|
||||
RepeatUnit.month => monthOn(context, repeat, day),
|
||||
RepeatUnit.year => DateFormat.MMMMd(locale).format(
|
||||
Day(day.year, day.month, repeat.monthDay ?? day.day).toDate(),
|
||||
),
|
||||
RepeatUnit.day => null,
|
||||
};
|
||||
if (on != null) {
|
||||
rule = l10n.repeatOn(rule, on);
|
||||
}
|
||||
} else {
|
||||
rule = l10n.repeatFromCompletion(rule);
|
||||
}
|
||||
if (repeat.until case final until?) {
|
||||
rule = l10n.repeatUntil(rule, dayText(context, until, Day.of(clock.now())));
|
||||
}
|
||||
if (repeat.left case final left?) {
|
||||
rule = l10n.repeatLeft(rule, left);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// Where a monthly [repeat] of a task due on [day] falls: "day 15", "the
|
||||
/// last Friday".
|
||||
String monthOn(BuildContext context, Repeat repeat, Day day) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return switch (repeat.monthWeek) {
|
||||
final nth? => l10n.repeatMonthWeek(
|
||||
nth < 0 ? 'last' : '$nth',
|
||||
weekdayName(locale, day.weekday),
|
||||
),
|
||||
null => l10n.repeatMonthDay(repeat.monthDay ?? day.day),
|
||||
};
|
||||
}
|
||||
|
||||
/// The name of [weekday] (`DateTime.monday` to `sunday`) in [locale].
|
||||
String weekdayName(String locale, int weekday, {bool short = false}) =>
|
||||
(short ? DateFormat.E(locale) : DateFormat.EEEE(locale))
|
||||
// 5 Jan 2026 was a Monday.
|
||||
.format(DateTime(2026, 1, 4 + weekday));
|
||||
|
||||
/// Asks how a task due on [day] recurs (ADR 0033, D7): the rules that fit
|
||||
/// its day (every day, every weekday, every week on its weekday, every
|
||||
/// month on its day or its weekday, every year), a rule of the person's
|
||||
/// own, or none where [current] is one.
|
||||
Future<RepeatChoice?> pickRepeat(
|
||||
BuildContext context, {
|
||||
required Day day,
|
||||
Repeat? current,
|
||||
}) => showAdaptiveSheet<RepeatChoice>(
|
||||
context,
|
||||
builder: (context) => _RepeatSheet(day: day, current: current),
|
||||
);
|
||||
|
||||
class const _RepeatSheet({required final Day day, final Repeat? current})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
void choose(Repeat? repeat) =>
|
||||
Navigator.of(context).pop<RepeatChoice>((repeat: repeat));
|
||||
final options = [
|
||||
const Repeat(RepeatUnit.day),
|
||||
const Repeat(
|
||||
RepeatUnit.week,
|
||||
weekdays: {
|
||||
DateTime.monday,
|
||||
DateTime.tuesday,
|
||||
DateTime.wednesday,
|
||||
DateTime.thursday,
|
||||
DateTime.friday,
|
||||
},
|
||||
),
|
||||
Repeat(RepeatUnit.week, weekdays: {day.weekday}),
|
||||
Repeat(RepeatUnit.month, monthDay: day.day),
|
||||
Repeat(
|
||||
RepeatUnit.month,
|
||||
monthWeek: day.lastWeekOfMonth && day.weekOfMonth > 4
|
||||
? -1
|
||||
: day.weekOfMonth,
|
||||
),
|
||||
Repeat(RepeatUnit.year, monthDay: day.day),
|
||||
];
|
||||
return SheetScaffold(
|
||||
title: l10n.repeatTitle,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final option in options)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
option == current ? LucideIcons.check : LucideIcons.repeat,
|
||||
),
|
||||
title: Text(repeatText(context, option, day)),
|
||||
onTap: () => choose(option),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.slidersHorizontal),
|
||||
title: Text(l10n.repeatCustom),
|
||||
subtitle: current == null || options.contains(current)
|
||||
? null
|
||||
: Text(repeatText(context, current!, day)),
|
||||
onTap: () async {
|
||||
final custom = await showAdaptiveSheet<Repeat>(
|
||||
context,
|
||||
builder: (context) => _RepeatEditor(day: day, current: current),
|
||||
);
|
||||
if (custom != null && context.mounted) {
|
||||
choose(custom);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (current != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.repeat1),
|
||||
title: Text(l10n.repeatNone),
|
||||
onTap: () => choose(null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Ends() {
|
||||
never,
|
||||
on,
|
||||
after,
|
||||
}
|
||||
|
||||
/// A rule of the person's own: every n units, on which weekdays or which
|
||||
/// day of the month, from the date or from when it is done, and when it
|
||||
/// ends.
|
||||
class const _RepeatEditor({required final Day day, final Repeat? current})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_RepeatEditor> createState() => _RepeatEditorState();
|
||||
}
|
||||
|
||||
class _RepeatEditorState() extends State<_RepeatEditor> {
|
||||
late RepeatUnit _unit = widget.current?.unit ?? RepeatUnit.week;
|
||||
late int _every = widget.current?.every ?? 1;
|
||||
late Set<int> _weekdays = switch (widget.current?.weekdays) {
|
||||
final days? when days.isNotEmpty => {...days},
|
||||
_ => {widget.day.weekday},
|
||||
};
|
||||
late bool _byWeekday = widget.current?.monthWeek != null;
|
||||
late bool _fromCompletion = widget.current?.fromCompletion ?? false;
|
||||
late _Ends _ends = switch (widget.current) {
|
||||
Repeat(until: _?) => _Ends.on,
|
||||
Repeat(left: _?) => _Ends.after,
|
||||
_ => _Ends.never,
|
||||
};
|
||||
late Day _until = widget.current?.until ?? widget.day.plus(30);
|
||||
late int _times = (widget.current?.left ?? 9) + 1;
|
||||
|
||||
Repeat get _repeat {
|
||||
final day = widget.day;
|
||||
return Repeat(
|
||||
_unit,
|
||||
every: _every,
|
||||
weekdays: _unit == RepeatUnit.week && !_fromCompletion ? _weekdays : {},
|
||||
monthDay: switch (_unit) {
|
||||
RepeatUnit.month when !_byWeekday => day.day,
|
||||
RepeatUnit.year => day.day,
|
||||
_ => null,
|
||||
},
|
||||
monthWeek: _unit == RepeatUnit.month && _byWeekday
|
||||
? (day.lastWeekOfMonth && day.weekOfMonth > 4 ? -1 : day.weekOfMonth)
|
||||
: null,
|
||||
fromCompletion: _fromCompletion,
|
||||
until: _ends == _Ends.on ? _until : null,
|
||||
left: _ends == _Ends.after ? _times - 1 : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final firstDay = MaterialLocalizations.of(context).firstDayOfWeekIndex;
|
||||
final day = widget.day;
|
||||
Widget stepper(int value, int min, ValueChanged<int> changed) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
onPressed: value > min ? () => changed(value - 1) : null,
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text('$value', textAlign: TextAlign.center),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
onPressed: value < 99 ? () => changed(value + 1) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
return SheetScaffold(
|
||||
title: l10n.repeatCustom,
|
||||
footer: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
_unit == RepeatUnit.week &&
|
||||
!_fromCompletion &&
|
||||
_weekdays.isEmpty
|
||||
? null
|
||||
: () => Navigator.of(context).pop(_repeat),
|
||||
child: Text(l10n.save),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(repeatText(context, _repeat, day)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(l10n.repeatEvery),
|
||||
trailing: stepper(_every, 1, (v) => setState(() => _every = v)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<RepeatUnit>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: RepeatUnit.day,
|
||||
label: Text(l10n.repeatUnitDays),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: RepeatUnit.week,
|
||||
label: Text(l10n.repeatUnitWeeks),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: RepeatUnit.month,
|
||||
label: Text(l10n.repeatUnitMonths),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: RepeatUnit.year,
|
||||
label: Text(l10n.repeatUnitYears),
|
||||
),
|
||||
],
|
||||
selected: {_unit},
|
||||
onSelectionChanged: (unit) => setState(() => _unit = unit.single),
|
||||
),
|
||||
),
|
||||
if (_unit == RepeatUnit.week && !_fromCompletion)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (var i = 0; i < 7; i++)
|
||||
// Material counts from 0 (Sunday), DateTime from 1
|
||||
// (Monday) to 7 (Sunday).
|
||||
if ((firstDay + i - 1) % 7 + 1 case final weekday)
|
||||
FilterChip(
|
||||
label: Text(weekdayName(locale, weekday, short: true)),
|
||||
selected: _weekdays.contains(weekday),
|
||||
onSelected: (on) => setState(
|
||||
() => _weekdays = on
|
||||
? {..._weekdays, weekday}
|
||||
: ({..._weekdays}..remove(weekday)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_unit == RepeatUnit.month && !_fromCompletion)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SegmentedButton<bool>(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text(
|
||||
monthOn(context, const Repeat(RepeatUnit.month), day),
|
||||
),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
label: Text(
|
||||
monthOn(
|
||||
context,
|
||||
Repeat(
|
||||
RepeatUnit.month,
|
||||
monthWeek: day.lastWeekOfMonth && day.weekOfMonth > 4
|
||||
? -1
|
||||
: day.weekOfMonth,
|
||||
),
|
||||
day,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_byWeekday},
|
||||
onSelectionChanged: (by) =>
|
||||
setState(() => _byWeekday = by.single),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(l10n.repeatCountFromCompletion),
|
||||
subtitle: Text(l10n.repeatCountFromCompletionHint),
|
||||
value: _fromCompletion,
|
||||
onChanged: (on) => setState(() => _fromCompletion = on),
|
||||
),
|
||||
ListTile(title: Text(l10n.repeatEnds)),
|
||||
RadioGroup<_Ends>(
|
||||
groupValue: _ends,
|
||||
onChanged: (ends) => setState(() => _ends = ends!),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile(
|
||||
value: _Ends.never,
|
||||
title: Text(l10n.repeatEndsNever),
|
||||
),
|
||||
RadioListTile(
|
||||
value: _Ends.on,
|
||||
title: Text(l10n.repeatEndsOn),
|
||||
secondary: TextButton(
|
||||
onPressed: () async {
|
||||
final today = Day.of(clock.now());
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _until.toDate(),
|
||||
firstDate: today.toDate(),
|
||||
lastDate: DateTime(today.year + 20),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_until = Day.of(picked);
|
||||
_ends = _Ends.on;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
DateFormat.yMMMd(locale).format(_until.toDate()),
|
||||
),
|
||||
),
|
||||
),
|
||||
RadioListTile(
|
||||
value: _Ends.after,
|
||||
title: Text(l10n.repeatEndsAfter(_times)),
|
||||
secondary: stepper(
|
||||
_times,
|
||||
1,
|
||||
(v) => setState(() {
|
||||
_times = v;
|
||||
_ends = _Ends.after;
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
|
|
@ -214,6 +215,12 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
final date? => dateText(context, date, today),
|
||||
null => l10n.dateNoneYet,
|
||||
}),
|
||||
subtitle: switch (content.date) {
|
||||
TaskDate(:final day, repeat: final repeat?) => Text(
|
||||
repeatText(context, repeat, day),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
trailing: content.date == null
|
||||
? null
|
||||
: clear(() => _change((c) => c.copyWith(date: () => null))),
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class const TaskTile({
|
|||
final details = [
|
||||
if (content.date case final date?)
|
||||
meta(
|
||||
LucideIcons.calendar,
|
||||
date.repeat == null ? LucideIcons.calendar : LucideIcons.repeat,
|
||||
dateText(context, date, today),
|
||||
dateColor(context, date.day, today, done: content.completed),
|
||||
),
|
||||
|
|
@ -136,12 +136,12 @@ class const TaskCheck({required final OpenTask task, super.key})
|
|||
onPressed: () async {
|
||||
final tasks = PersonScope.of<TodoPerson>(context).tasks;
|
||||
final stored = StoredTask(task.id, content);
|
||||
await tasks.complete(stored, done: !content.completed);
|
||||
final before = await tasks.complete(stored, done: !content.completed);
|
||||
if (!content.completed && context.mounted) {
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.taskCompleted,
|
||||
onUndo: () => unawaited(tasks.update(task.id, content)),
|
||||
onUndo: () => unawaited(tasks.restore(before)),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ Future<void> reschedule(BuildContext context, List<OpenTask> tasks) async {
|
|||
date.day,
|
||||
time: date.time ?? old?.time,
|
||||
duration: old?.duration,
|
||||
repeat: old?.repeat,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
|
|||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
|
|
@ -382,6 +384,54 @@ void main() {
|
|||
expect(title.style?.decoration, TextDecoration.lineThrough);
|
||||
});
|
||||
|
||||
testWidgets('a task set to repeat every day, done, comes again '
|
||||
'tomorrow, when it was done kept', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Stretch',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today),
|
||||
),
|
||||
);
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Stretch'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithIcon(ListTile, LucideIcons.calendar).last,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Repeat'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Every day'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Every day'), findsOneWidget, reason: 'in the sheet');
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'Stretch'),
|
||||
matching: find.byIcon(LucideIcons.repeat),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Stretch'), findsOneWidget, reason: 'still to do');
|
||||
expect(find.text('Tomorrow'), findsOneWidget);
|
||||
final stored = (await tester.runAsync(() => tasks().all()))!.single;
|
||||
expect(
|
||||
stored.content?.date,
|
||||
TaskDate(today.plus(1), repeat: const Repeat(RepeatUnit.day)),
|
||||
);
|
||||
expect(stored.content?.completions, hasLength(1));
|
||||
expect(stored.content?.completed, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Today puts what is overdue first; Reschedule moves it to '
|
||||
'the day picked', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// A recurrence (ADR 0033, D7): the day a task done moves on to, counted
|
||||
/// from its date or from the day it was done, and when it stops.
|
||||
void main() {
|
||||
// Monday 2 Nov 2026.
|
||||
const monday = Day(2026, 11, 2);
|
||||
|
||||
group('counted from its date', () {
|
||||
test('every n days, weeks, months and years', () {
|
||||
expect(
|
||||
const Repeat(RepeatUnit.day).next(monday, today: monday),
|
||||
const Day(2026, 11, 3),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.day, every: 3).next(monday, today: monday),
|
||||
const Day(2026, 11, 5),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.week, every: 2).next(monday, today: monday),
|
||||
const Day(2026, 11, 16),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month).next(monday, today: monday),
|
||||
const Day(2026, 12, 2),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.year).next(monday, today: monday),
|
||||
const Day(2027, 11, 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('on given weekdays, every week or every other one', () {
|
||||
const weekdays = Repeat(
|
||||
RepeatUnit.week,
|
||||
weekdays: {DateTime.monday, DateTime.wednesday, DateTime.friday},
|
||||
);
|
||||
expect(weekdays.next(monday, today: monday), const Day(2026, 11, 4));
|
||||
expect(
|
||||
weekdays.next(const Day(2026, 11, 6), today: monday),
|
||||
const Day(2026, 11, 9),
|
||||
);
|
||||
const otherWeek = Repeat(
|
||||
RepeatUnit.week,
|
||||
every: 2,
|
||||
weekdays: {DateTime.monday, DateTime.thursday},
|
||||
);
|
||||
expect(otherWeek.next(monday, today: monday), const Day(2026, 11, 5));
|
||||
// The week after is skipped: Monday 16th, not the 9th.
|
||||
expect(
|
||||
otherWeek.next(const Day(2026, 11, 5), today: monday),
|
||||
const Day(2026, 11, 16),
|
||||
);
|
||||
});
|
||||
|
||||
test("on a month's day, kept through shorter months", () {
|
||||
const lastDay = Repeat(RepeatUnit.month, monthDay: 31);
|
||||
final february = lastDay.next(const Day(2027, 1, 31), today: monday);
|
||||
expect(february, const Day(2027, 2, 28));
|
||||
expect(lastDay.next(february!, today: monday), const Day(2027, 3, 31));
|
||||
const leap = Repeat(RepeatUnit.year, monthDay: 29);
|
||||
expect(
|
||||
leap.next(const Day(2028, 2, 29), today: monday),
|
||||
const Day(2029, 2, 28),
|
||||
);
|
||||
});
|
||||
|
||||
test("on a month's nth or last weekday", () {
|
||||
// The first Monday of November 2026 is the 2nd.
|
||||
const first = Repeat(RepeatUnit.month, monthWeek: 1);
|
||||
expect(first.next(monday, today: monday), const Day(2026, 12, 7));
|
||||
const last = Repeat(RepeatUnit.month, monthWeek: -1);
|
||||
expect(
|
||||
last.next(const Day(2026, 11, 30), today: monday),
|
||||
const Day(2026, 12, 28),
|
||||
);
|
||||
});
|
||||
|
||||
test('past today where it was overdue, never on a day already done', () {
|
||||
const daily = Repeat(RepeatUnit.day);
|
||||
expect(
|
||||
daily.next(const Day(2026, 10, 28), today: monday),
|
||||
const Day(2026, 11, 3),
|
||||
);
|
||||
const weekly = Repeat(RepeatUnit.week);
|
||||
expect(
|
||||
weekly.next(const Day(2026, 10, 19), today: monday),
|
||||
const Day(2026, 11, 9),
|
||||
);
|
||||
// Done early, it moves on from its own date.
|
||||
expect(
|
||||
daily.next(const Day(2026, 11, 5), today: monday),
|
||||
const Day(2026, 11, 6),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('counted from the day it was done, its date aside', () {
|
||||
const fromDone = Repeat(RepeatUnit.day, every: 3, fromCompletion: true);
|
||||
expect(
|
||||
fromDone.next(const Day(2026, 10, 20), today: monday),
|
||||
const Day(2026, 11, 5),
|
||||
);
|
||||
expect(
|
||||
const Repeat(
|
||||
RepeatUnit.month,
|
||||
fromCompletion: true,
|
||||
).next(const Day(2026, 12, 25), today: monday),
|
||||
const Day(2026, 12, 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('stops after its last day or its last time', () {
|
||||
const until = Repeat(RepeatUnit.week, until: Day(2026, 11, 12));
|
||||
expect(until.next(monday, today: monday), const Day(2026, 11, 9));
|
||||
expect(until.next(const Day(2026, 11, 9), today: monday), isNull);
|
||||
const twice = Repeat(RepeatUnit.day, left: 1);
|
||||
expect(twice.next(monday, today: monday), const Day(2026, 11, 3));
|
||||
expect(twice.counted(), const Repeat(RepeatUnit.day, left: 0));
|
||||
expect(twice.counted().next(monday, today: monday), isNull);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.day).counted(),
|
||||
const Repeat(RepeatUnit.day),
|
||||
);
|
||||
});
|
||||
|
||||
test('starts on the first day it falls on', () {
|
||||
const weekdays = Repeat(
|
||||
RepeatUnit.week,
|
||||
weekdays: {
|
||||
DateTime.monday,
|
||||
DateTime.tuesday,
|
||||
DateTime.wednesday,
|
||||
DateTime.thursday,
|
||||
DateTime.friday,
|
||||
},
|
||||
);
|
||||
// Saturday 7th: Monday 9th.
|
||||
expect(weekdays.firstFrom(const Day(2026, 11, 7)), const Day(2026, 11, 9));
|
||||
expect(weekdays.firstFrom(monday), monday);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month, monthWeek: -1).firstFrom(monday),
|
||||
const Day(2026, 11, 30),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month, monthDay: 15).firstFrom(monday),
|
||||
const Day(2026, 11, 15),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month, monthDay: 1).firstFrom(monday),
|
||||
const Day(2026, 12, 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("the nth and last weekday of a day's month", () {
|
||||
expect(monday.weekOfMonth, 1);
|
||||
expect(const Day(2026, 11, 30).weekOfMonth, 5);
|
||||
expect(const Day(2026, 11, 30).lastWeekOfMonth, isTrue);
|
||||
expect(const Day(2026, 11, 23).lastWeekOfMonth, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
|
|
@ -31,11 +32,18 @@ void main() {
|
|||
Day(2026, 11, 2),
|
||||
time: 6 * 60 + 30,
|
||||
duration: Duration(minutes: 30),
|
||||
repeat: Repeat(
|
||||
RepeatUnit.week,
|
||||
every: 2,
|
||||
weekdays: {DateTime.monday, DateTime.thursday},
|
||||
until: Day(2027, 3, 1),
|
||||
),
|
||||
),
|
||||
deadline: const Day(2026, 11, 8),
|
||||
priority: Priority.p2,
|
||||
labelIds: const ['l1', 'l2'],
|
||||
completedAt: DateTime(2026, 11, 2, 7),
|
||||
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
|
||||
);
|
||||
|
||||
group('a task', () {
|
||||
|
|
@ -55,11 +63,25 @@ void main() {
|
|||
'sectionId': 's',
|
||||
'parentId': 't',
|
||||
'position': 3.0,
|
||||
'date': {'day': '2026-11-02', 'time': 390, 'minutes': 30},
|
||||
'date': {
|
||||
'day': '2026-11-02',
|
||||
'time': 390,
|
||||
'minutes': 30,
|
||||
'repeat': {
|
||||
'unit': 'week',
|
||||
'every': 2,
|
||||
'weekdays': [1, 4],
|
||||
'until': '2027-03-01',
|
||||
},
|
||||
},
|
||||
'deadline': '2026-11-08',
|
||||
'priority': 2,
|
||||
'labelIds': ['l1', 'l2'],
|
||||
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
|
||||
'completions': [
|
||||
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
|
||||
DateTime(2026, 10, 22, 6).toUtc().toIso8601String(),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
|
||||
|
|
@ -89,6 +111,26 @@ void main() {
|
|||
'date': {'day': '2026-11-02', 'time': 1440},
|
||||
},
|
||||
{...base, 'deadline': 'soon'},
|
||||
{
|
||||
...base,
|
||||
'completions': ['yesterday'],
|
||||
},
|
||||
for (final repeat in [
|
||||
{'unit': 'fortnight'},
|
||||
{'unit': 'day', 'every': 0},
|
||||
{
|
||||
'unit': 'week',
|
||||
'weekdays': [8],
|
||||
},
|
||||
{'unit': 'month', 'monthDay': 32},
|
||||
{'unit': 'month', 'monthWeek': 5},
|
||||
{'unit': 'day', 'left': -1},
|
||||
{'unit': 'day', 'until': 'never'},
|
||||
])
|
||||
{
|
||||
...base,
|
||||
'date': {'day': '2026-11-02', 'repeat': repeat},
|
||||
},
|
||||
{...base, 'date': 'today'},
|
||||
]) {
|
||||
expect(decodeTask(json(broken)), isNull, reason: '$broken');
|
||||
|
|
@ -96,6 +138,23 @@ void main() {
|
|||
expect(decodeTask(ByteData(0)), isNull);
|
||||
});
|
||||
|
||||
test('recurring in every way reads back as it was written', () {
|
||||
for (final repeat in const [
|
||||
Repeat(RepeatUnit.day),
|
||||
Repeat(RepeatUnit.day, every: 3, fromCompletion: true, left: 4),
|
||||
Repeat(RepeatUnit.month, monthDay: 31),
|
||||
Repeat(RepeatUnit.month, monthWeek: -1),
|
||||
Repeat(RepeatUnit.year, every: 2),
|
||||
]) {
|
||||
final task = TaskContent(
|
||||
title: 'A',
|
||||
projectId: 'p',
|
||||
date: const TaskDate(Day(2026, 11, 2)).repeating(repeat),
|
||||
);
|
||||
expect(decodeTask(encodeTask(task)), task, reason: '$repeat');
|
||||
}
|
||||
});
|
||||
|
||||
test('a priority it doesn’t know is none', () {
|
||||
expect(
|
||||
decodeTask(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
|
|
@ -80,6 +81,79 @@ void main() {
|
|||
expect(await phone.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('a recurring task done moves on with its deadline, its time kept and '
|
||||
'its sub-tasks open again; undone, all of it is as it was', () async {
|
||||
final phone = await device(alice);
|
||||
final weekly = milk.copyWith(
|
||||
date: () => milk.date!.repeating(
|
||||
const Repeat(RepeatUnit.week, left: 2),
|
||||
),
|
||||
deadline: () => const Day(2026, 11, 3),
|
||||
);
|
||||
final id = await phone.add(weekly);
|
||||
final doneAt = DateTime(2026, 10, 30);
|
||||
final sub = TaskContent(
|
||||
title: 'Oat',
|
||||
projectId: 'inbox',
|
||||
parentId: id,
|
||||
completedAt: doneAt,
|
||||
);
|
||||
final subId = await phone.add(sub);
|
||||
final other = await phone.add(milk.copyWith(completedAt: () => doneAt));
|
||||
|
||||
final at = DateTime(2026, 11, 2, 9, 30);
|
||||
final before = await withClock(
|
||||
Clock.fixed(at),
|
||||
() => phone.complete(StoredTask(id, weekly)),
|
||||
);
|
||||
|
||||
final after = {for (final t in await phone.all()) t.id: t.content};
|
||||
expect(
|
||||
after[id],
|
||||
weekly.copyWith(
|
||||
date: () => const TaskDate(
|
||||
Day(2026, 11, 9),
|
||||
time: 9 * 60,
|
||||
repeat: Repeat(RepeatUnit.week, left: 1),
|
||||
),
|
||||
deadline: () => const Day(2026, 11, 10),
|
||||
completions: [at],
|
||||
),
|
||||
);
|
||||
expect(after[subId]?.completed, isFalse);
|
||||
expect(after[other]?.completed, isTrue);
|
||||
|
||||
await phone.restore(before);
|
||||
expect(
|
||||
{for (final t in await phone.all()) t.id: t.content},
|
||||
{
|
||||
id: weekly,
|
||||
subId: sub,
|
||||
other: milk.copyWith(completedAt: () => doneAt),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a recurring task done its last time is done, its sub-tasks as they '
|
||||
'were', () async {
|
||||
final phone = await device(alice);
|
||||
final last = milk.copyWith(
|
||||
date: () => milk.date!.repeating(
|
||||
const Repeat(RepeatUnit.day, until: Day(2026, 11, 2)),
|
||||
),
|
||||
);
|
||||
final id = await phone.add(last);
|
||||
final at = DateTime(2026, 11, 2, 9, 30);
|
||||
await withClock(
|
||||
Clock.fixed(at),
|
||||
() => phone.complete(StoredTask(id, last)),
|
||||
);
|
||||
expect(
|
||||
(await phone.all()).single.content,
|
||||
last.copyWith(completedAt: () => at),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'deleting a task deletes its sub-tasks and theirs, and no other',
|
||||
() async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue