feat(todo): each project's Display: completed tasks, grouping, sorting and filters, synced (ADR 0033, D17)
- A Display button beside a project's menu (the Inbox's too), a dot on it while the project shows otherwise than at first, opens its sheet: completed tasks, grouping (date, deadline, added date, priority, label), sorting (name, date, deadline, added date, priority), filters by date, deadline, priority and label, and Reset all. - The choices live in the project's sealed payload (ProjectView, written only once set), so every device shows the project alike. - Grouped, the groups stand in the sections' place under pinned headings; rows are dragged only while sorted by hand by sections. - Show done tasks moves from the menu into Display. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4898d0a04a
commit
7d1de304c8
14 changed files with 1878 additions and 81 deletions
|
|
@ -14,8 +14,6 @@
|
|||
"settingsVersion": "Version {version} (Build {build})",
|
||||
"clear": "Entfernen",
|
||||
"colorLabel": "Farbe",
|
||||
"completedShow": "Erledigte zeigen",
|
||||
"completedHide": "Erledigte ausblenden",
|
||||
"dateTitle": "Datum",
|
||||
"dateNextWeek": "Nächste Woche",
|
||||
"dateWeekend": "Dieses Wochenende",
|
||||
|
|
@ -168,5 +166,41 @@
|
|||
"deadlineTitle": "Frist",
|
||||
"orderChanged": "Reihenfolge geändert",
|
||||
"addTaskCancel": "Hier ablegen zum Abbrechen",
|
||||
"taskMovedTo": "Verschoben auf {day}"
|
||||
"taskMovedTo": "Verschoben auf {day}",
|
||||
"display": "Anzeige",
|
||||
"displayCompleted": "Erledigte Aufgaben",
|
||||
"displaySort": "Sortieren",
|
||||
"displayGrouping": "Gruppierung",
|
||||
"displaySorting": "Sortierung",
|
||||
"displayFilter": "Filter",
|
||||
"displayDeadline": "Frist",
|
||||
"displayLabel": "Label",
|
||||
"displayReset": "Alles zurücksetzen",
|
||||
"displayDone": "Fertig",
|
||||
"displayAll": "Alle",
|
||||
"displayAny": "Beliebig",
|
||||
"groupingNone": "Keine",
|
||||
"groupingDate": "Datum",
|
||||
"groupingDeadline": "Frist",
|
||||
"groupingAdded": "Hinzugefügt am",
|
||||
"groupingPriority": "Priorität",
|
||||
"groupingLabel": "Label",
|
||||
"sortingManual": "Manuell",
|
||||
"sortingName": "Name",
|
||||
"sortingDate": "Datum",
|
||||
"sortingDeadline": "Frist",
|
||||
"sortingAdded": "Hinzugefügt am",
|
||||
"sortingPriority": "Priorität",
|
||||
"rangeToday": "Heute",
|
||||
"rangeThisWeek": "Diese Woche",
|
||||
"rangeNext7": "Nächste 7 Tage",
|
||||
"rangeThisMonth": "Dieser Monat",
|
||||
"rangeNext30": "Nächste 30 Tage",
|
||||
"rangeNoDate": "Kein Datum",
|
||||
"rangeNoDeadline": "Keine Frist",
|
||||
"viewNothingTitle": "Nichts passt",
|
||||
"viewNothingHint": "Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.",
|
||||
"displaySave": "Speichern",
|
||||
"displayCancel": "Abbrechen",
|
||||
"groupNoLabel": "Ohne Label"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@
|
|||
},
|
||||
"clear": "Clear",
|
||||
"colorLabel": "Colour",
|
||||
"completedShow": "Show done tasks",
|
||||
"completedHide": "Hide done tasks",
|
||||
"dateTitle": "Date",
|
||||
"dateNextWeek": "Next week",
|
||||
"dateWeekend": "This weekend",
|
||||
|
|
@ -466,5 +464,149 @@
|
|||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": "Display",
|
||||
"@display": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayCompleted": "Completed tasks",
|
||||
"@displayCompleted": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displaySort": "Sort",
|
||||
"@displaySort": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayGrouping": "Grouping",
|
||||
"@displayGrouping": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displaySorting": "Sorting",
|
||||
"@displaySorting": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayFilter": "Filter",
|
||||
"@displayFilter": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayDeadline": "Deadline",
|
||||
"@displayDeadline": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayLabel": "Label",
|
||||
"@displayLabel": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayReset": "Reset all",
|
||||
"@displayReset": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayDone": "Done",
|
||||
"@displayDone": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayAll": "All",
|
||||
"@displayAll": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayAny": "Any",
|
||||
"@displayAny": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupingNone": "None",
|
||||
"@groupingNone": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupingDate": "Date",
|
||||
"@groupingDate": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupingDeadline": "Deadline",
|
||||
"@groupingDeadline": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupingAdded": "Added date",
|
||||
"@groupingAdded": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupingPriority": "Priority",
|
||||
"@groupingPriority": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupingLabel": "Label",
|
||||
"@groupingLabel": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"sortingManual": "Manual",
|
||||
"@sortingManual": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"sortingName": "Name",
|
||||
"@sortingName": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"sortingDate": "Date",
|
||||
"@sortingDate": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"sortingDeadline": "Deadline",
|
||||
"@sortingDeadline": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"sortingAdded": "Added date",
|
||||
"@sortingAdded": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"sortingPriority": "Priority",
|
||||
"@sortingPriority": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeToday": "Today",
|
||||
"@rangeToday": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeThisWeek": "This week",
|
||||
"@rangeThisWeek": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeNext7": "Next 7 days",
|
||||
"@rangeNext7": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeThisMonth": "This month",
|
||||
"@rangeThisMonth": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeNext30": "Next 30 days",
|
||||
"@rangeNext30": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeNoDate": "No date",
|
||||
"@rangeNoDate": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"rangeNoDeadline": "No deadline",
|
||||
"@rangeNoDeadline": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"viewNothingTitle": "Nothing matches",
|
||||
"@viewNothingTitle": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"viewNothingHint": "This view's filters leave out every task here. Display changes them.",
|
||||
"@viewNothingHint": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displaySave": "Save",
|
||||
"@displaySave": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"displayCancel": "Cancel",
|
||||
"@displayCancel": {
|
||||
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
|
||||
},
|
||||
"groupNoLabel": "No label",
|
||||
"@groupNoLabel": {
|
||||
"description": "A project's group of tasks without a label (ADR 0033, D17)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,18 +184,6 @@ abstract class AppLocalizations {
|
|||
/// **'Colour'**
|
||||
String get colorLabel;
|
||||
|
||||
/// No description provided for @completedShow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show done tasks'**
|
||||
String get completedShow;
|
||||
|
||||
/// No description provided for @completedHide.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide done tasks'**
|
||||
String get completedHide;
|
||||
|
||||
/// No description provided for @dateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1113,6 +1101,222 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Moved to {day}'**
|
||||
String taskMovedTo(String day);
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display'**
|
||||
String get display;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Completed tasks'**
|
||||
String get displayCompleted;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sort'**
|
||||
String get displaySort;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Grouping'**
|
||||
String get displayGrouping;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sorting'**
|
||||
String get displaySorting;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filter'**
|
||||
String get displayFilter;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deadline'**
|
||||
String get displayDeadline;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label'**
|
||||
String get displayLabel;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset all'**
|
||||
String get displayReset;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get displayDone;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get displayAll;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Any'**
|
||||
String get displayAny;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get groupingNone;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date'**
|
||||
String get groupingDate;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deadline'**
|
||||
String get groupingDeadline;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added date'**
|
||||
String get groupingAdded;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Priority'**
|
||||
String get groupingPriority;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label'**
|
||||
String get groupingLabel;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manual'**
|
||||
String get sortingManual;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name'**
|
||||
String get sortingName;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date'**
|
||||
String get sortingDate;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deadline'**
|
||||
String get sortingDeadline;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added date'**
|
||||
String get sortingAdded;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Priority'**
|
||||
String get sortingPriority;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Today'**
|
||||
String get rangeToday;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This week'**
|
||||
String get rangeThisWeek;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next 7 days'**
|
||||
String get rangeNext7;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This month'**
|
||||
String get rangeThisMonth;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next 30 days'**
|
||||
String get rangeNext30;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No date'**
|
||||
String get rangeNoDate;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No deadline'**
|
||||
String get rangeNoDeadline;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing matches'**
|
||||
String get viewNothingTitle;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This view\'s filters leave out every task here. Display changes them.'**
|
||||
String get viewNothingHint;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get displaySave;
|
||||
|
||||
/// A project's Display sheet and what it shows (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get displayCancel;
|
||||
|
||||
/// A project's group of tasks without a label (ADR 0033, D17).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No label'**
|
||||
String get groupNoLabel;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -55,12 +55,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get colorLabel => 'Farbe';
|
||||
|
||||
@override
|
||||
String get completedShow => 'Erledigte zeigen';
|
||||
|
||||
@override
|
||||
String get completedHide => 'Erledigte ausblenden';
|
||||
|
||||
@override
|
||||
String get dateTitle => 'Datum';
|
||||
|
||||
|
|
@ -718,4 +712,113 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String taskMovedTo(String day) {
|
||||
return 'Verschoben auf $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String get display => 'Anzeige';
|
||||
|
||||
@override
|
||||
String get displayCompleted => 'Erledigte Aufgaben';
|
||||
|
||||
@override
|
||||
String get displaySort => 'Sortieren';
|
||||
|
||||
@override
|
||||
String get displayGrouping => 'Gruppierung';
|
||||
|
||||
@override
|
||||
String get displaySorting => 'Sortierung';
|
||||
|
||||
@override
|
||||
String get displayFilter => 'Filter';
|
||||
|
||||
@override
|
||||
String get displayDeadline => 'Frist';
|
||||
|
||||
@override
|
||||
String get displayLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get displayReset => 'Alles zurücksetzen';
|
||||
|
||||
@override
|
||||
String get displayDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String get displayAll => 'Alle';
|
||||
|
||||
@override
|
||||
String get displayAny => 'Beliebig';
|
||||
|
||||
@override
|
||||
String get groupingNone => 'Keine';
|
||||
|
||||
@override
|
||||
String get groupingDate => 'Datum';
|
||||
|
||||
@override
|
||||
String get groupingDeadline => 'Frist';
|
||||
|
||||
@override
|
||||
String get groupingAdded => 'Hinzugefügt am';
|
||||
|
||||
@override
|
||||
String get groupingPriority => 'Priorität';
|
||||
|
||||
@override
|
||||
String get groupingLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get sortingManual => 'Manuell';
|
||||
|
||||
@override
|
||||
String get sortingName => 'Name';
|
||||
|
||||
@override
|
||||
String get sortingDate => 'Datum';
|
||||
|
||||
@override
|
||||
String get sortingDeadline => 'Frist';
|
||||
|
||||
@override
|
||||
String get sortingAdded => 'Hinzugefügt am';
|
||||
|
||||
@override
|
||||
String get sortingPriority => 'Priorität';
|
||||
|
||||
@override
|
||||
String get rangeToday => 'Heute';
|
||||
|
||||
@override
|
||||
String get rangeThisWeek => 'Diese Woche';
|
||||
|
||||
@override
|
||||
String get rangeNext7 => 'Nächste 7 Tage';
|
||||
|
||||
@override
|
||||
String get rangeThisMonth => 'Dieser Monat';
|
||||
|
||||
@override
|
||||
String get rangeNext30 => 'Nächste 30 Tage';
|
||||
|
||||
@override
|
||||
String get rangeNoDate => 'Kein Datum';
|
||||
|
||||
@override
|
||||
String get rangeNoDeadline => 'Keine Frist';
|
||||
|
||||
@override
|
||||
String get viewNothingTitle => 'Nichts passt';
|
||||
|
||||
@override
|
||||
String get viewNothingHint =>
|
||||
'Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.';
|
||||
|
||||
@override
|
||||
String get displaySave => 'Speichern';
|
||||
|
||||
@override
|
||||
String get displayCancel => 'Abbrechen';
|
||||
|
||||
@override
|
||||
String get groupNoLabel => 'Ohne Label';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,12 +55,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get colorLabel => 'Colour';
|
||||
|
||||
@override
|
||||
String get completedShow => 'Show done tasks';
|
||||
|
||||
@override
|
||||
String get completedHide => 'Hide done tasks';
|
||||
|
||||
@override
|
||||
String get dateTitle => 'Date';
|
||||
|
||||
|
|
@ -717,6 +711,115 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String taskMovedTo(String day) {
|
||||
return 'Moved to $day';
|
||||
}
|
||||
|
||||
@override
|
||||
String get display => 'Display';
|
||||
|
||||
@override
|
||||
String get displayCompleted => 'Completed tasks';
|
||||
|
||||
@override
|
||||
String get displaySort => 'Sort';
|
||||
|
||||
@override
|
||||
String get displayGrouping => 'Grouping';
|
||||
|
||||
@override
|
||||
String get displaySorting => 'Sorting';
|
||||
|
||||
@override
|
||||
String get displayFilter => 'Filter';
|
||||
|
||||
@override
|
||||
String get displayDeadline => 'Deadline';
|
||||
|
||||
@override
|
||||
String get displayLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get displayReset => 'Reset all';
|
||||
|
||||
@override
|
||||
String get displayDone => 'Done';
|
||||
|
||||
@override
|
||||
String get displayAll => 'All';
|
||||
|
||||
@override
|
||||
String get displayAny => 'Any';
|
||||
|
||||
@override
|
||||
String get groupingNone => 'None';
|
||||
|
||||
@override
|
||||
String get groupingDate => 'Date';
|
||||
|
||||
@override
|
||||
String get groupingDeadline => 'Deadline';
|
||||
|
||||
@override
|
||||
String get groupingAdded => 'Added date';
|
||||
|
||||
@override
|
||||
String get groupingPriority => 'Priority';
|
||||
|
||||
@override
|
||||
String get groupingLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get sortingManual => 'Manual';
|
||||
|
||||
@override
|
||||
String get sortingName => 'Name';
|
||||
|
||||
@override
|
||||
String get sortingDate => 'Date';
|
||||
|
||||
@override
|
||||
String get sortingDeadline => 'Deadline';
|
||||
|
||||
@override
|
||||
String get sortingAdded => 'Added date';
|
||||
|
||||
@override
|
||||
String get sortingPriority => 'Priority';
|
||||
|
||||
@override
|
||||
String get rangeToday => 'Today';
|
||||
|
||||
@override
|
||||
String get rangeThisWeek => 'This week';
|
||||
|
||||
@override
|
||||
String get rangeNext7 => 'Next 7 days';
|
||||
|
||||
@override
|
||||
String get rangeThisMonth => 'This month';
|
||||
|
||||
@override
|
||||
String get rangeNext30 => 'Next 30 days';
|
||||
|
||||
@override
|
||||
String get rangeNoDate => 'No date';
|
||||
|
||||
@override
|
||||
String get rangeNoDeadline => 'No deadline';
|
||||
|
||||
@override
|
||||
String get viewNothingTitle => 'Nothing matches';
|
||||
|
||||
@override
|
||||
String get viewNothingHint =>
|
||||
'This view\'s filters leave out every task here. Display changes them.';
|
||||
|
||||
@override
|
||||
String get displaySave => 'Save';
|
||||
|
||||
@override
|
||||
String get displayCancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get groupNoLabel => 'No label';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// The schema of a project's payload this app writes and reads.
|
||||
|
|
@ -14,8 +16,49 @@ ByteData encodeProject(ProjectContent content) =>
|
|||
'name': content.name,
|
||||
'color': ?content.color?.name,
|
||||
'position': content.position,
|
||||
// As at first, nothing: a project's Display is written once set (D17).
|
||||
if (!content.view.isDefault) 'view': _encodeView(content.view),
|
||||
});
|
||||
|
||||
/// [view] by its values' names, those left as at first left out.
|
||||
Map<String, Object?> _encodeView(ProjectView view) => {
|
||||
if (view.layout != ProjectLayout.list) 'layout': view.layout.name,
|
||||
if (view.completed) 'completed': true,
|
||||
if (view.grouping != TaskGrouping.none) 'grouping': view.grouping.name,
|
||||
if (view.sorting != TaskSorting.manual) 'sorting': view.sorting.name,
|
||||
if (view.date != DayRange.all) 'date': view.date.name,
|
||||
if (view.deadline != DayRange.all) 'deadline': view.deadline.name,
|
||||
if (view.priorities.isNotEmpty)
|
||||
'priorities': [for (final p in view.priorities) p.name],
|
||||
if (view.labelIds.isNotEmpty) 'labelIds': [...view.labelIds],
|
||||
};
|
||||
|
||||
/// The view [json] holds; a value this version doesn't know, as at first.
|
||||
ProjectView _decodeView(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const ProjectView();
|
||||
}
|
||||
T named<T extends Enum>(List<T> values, String key, T fallback) =>
|
||||
values.asNameMap()[json[key]] ?? fallback;
|
||||
final priorities = Priority.values.asNameMap();
|
||||
return ProjectView(
|
||||
layout: named(ProjectLayout.values, 'layout', ProjectLayout.list),
|
||||
completed: json['completed'] == true,
|
||||
grouping: named(TaskGrouping.values, 'grouping', TaskGrouping.none),
|
||||
sorting: named(TaskSorting.values, 'sorting', TaskSorting.manual),
|
||||
date: named(DayRange.values, 'date', DayRange.all),
|
||||
deadline: named(DayRange.values, 'deadline', DayRange.all),
|
||||
priorities: {
|
||||
for (final name in json['priorities'] as List<Object?>? ?? const [])
|
||||
?priorities[name],
|
||||
},
|
||||
labelIds: {
|
||||
for (final id in json['labelIds'] as List<Object?>? ?? const [])
|
||||
if (id is String) id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The project [payload] holds, opened; null for one of a newer schema or one
|
||||
/// it can't read. A colour it doesn't know is none.
|
||||
ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
||||
|
|
@ -26,5 +69,6 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
|||
name: json['name']! as String,
|
||||
color: PaletteColor.byName(json['color'] as String?),
|
||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||
view: _decodeView(json['view']),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// What a project is (ADR 0033, D6), as its sealed payload holds it: its
|
||||
/// [name], its [color] from halkyon_ui's palette (D9; none: the theme's),
|
||||
/// and its [position] among the person's projects, smaller first. The Inbox
|
||||
/// its [position] among the person's projects, smaller first, and how it
|
||||
/// shows its tasks ([view], D17). The Inbox
|
||||
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
|
||||
/// shows under the app's own word for it, whatever [name] holds.
|
||||
final class const ProjectContent({
|
||||
required final String name,
|
||||
final PaletteColor? color,
|
||||
final double position = 0,
|
||||
|
||||
/// How the project shows its tasks (D17).
|
||||
final ProjectView view = const ProjectView(),
|
||||
}) with Equatable {
|
||||
/// The Inbox's content, as the first device writes it.
|
||||
static const inbox = ProjectContent(name: '');
|
||||
|
|
@ -18,12 +23,14 @@ final class const ProjectContent({
|
|||
String? name,
|
||||
PaletteColor? Function()? color,
|
||||
double? position,
|
||||
ProjectView? view,
|
||||
}) => ProjectContent(
|
||||
name: name ?? this.name,
|
||||
color: color == null ? this.color : color(),
|
||||
position: position ?? this.position,
|
||||
view: view ?? this.view,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, color, position];
|
||||
List<Object?> get props => [name, color, position, view];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
||||
/// How a project's tasks are laid out (ADR 0033, D17).
|
||||
enum ProjectLayout() {
|
||||
list,
|
||||
board,
|
||||
calendar,
|
||||
}
|
||||
|
||||
/// What a project's list groups its tasks by (D17); none: its sections.
|
||||
enum TaskGrouping() {
|
||||
none,
|
||||
date,
|
||||
deadline,
|
||||
addedDate,
|
||||
priority,
|
||||
label,
|
||||
}
|
||||
|
||||
/// What a project's list sorts its tasks by (D17); manual: as placed.
|
||||
enum TaskSorting() {
|
||||
manual,
|
||||
name,
|
||||
date,
|
||||
deadline,
|
||||
addedDate,
|
||||
priority,
|
||||
}
|
||||
|
||||
/// The days a filter by date or deadline keeps (D17).
|
||||
enum DayRange() {
|
||||
all,
|
||||
today,
|
||||
thisWeek,
|
||||
next7Days,
|
||||
thisMonth,
|
||||
next30Days,
|
||||
|
||||
/// Only tasks without one.
|
||||
none;
|
||||
|
||||
/// Whether [day] (none: the task has none) falls in this range on
|
||||
/// [today], the week starting on [weekStart].
|
||||
bool holds(Day? day, {required Day today, required Day weekStart}) {
|
||||
if (this == DayRange.all) {
|
||||
return true;
|
||||
}
|
||||
if (day == null) {
|
||||
return this == DayRange.none;
|
||||
}
|
||||
return switch (this) {
|
||||
DayRange.all => true,
|
||||
DayRange.none => false,
|
||||
DayRange.today => day == today,
|
||||
DayRange.thisWeek => !(day < weekStart) && day < weekStart.plus(7),
|
||||
DayRange.next7Days => !(day < today) && day < today.plus(7),
|
||||
DayRange.thisMonth => day.year == today.year && day.month == today.month,
|
||||
DayRange.next30Days => !(day < today) && day < today.plus(30),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A project's Display (ADR 0033, D17, as Todoist's): its [layout], whether
|
||||
/// it shows its done tasks, how it groups and sorts them, and the tasks it
|
||||
/// keeps, by date, deadline, priority and label. Kept in the project's
|
||||
/// sealed row, so every device of the person's shows the project alike.
|
||||
final class const ProjectView({
|
||||
final ProjectLayout layout = ProjectLayout.list,
|
||||
final bool completed = false,
|
||||
final TaskGrouping grouping = TaskGrouping.none,
|
||||
final TaskSorting sorting = TaskSorting.manual,
|
||||
final DayRange date = DayRange.all,
|
||||
final DayRange deadline = DayRange.all,
|
||||
|
||||
/// The priorities kept; none: all.
|
||||
final Set<Priority> priorities = const {},
|
||||
|
||||
/// The labels kept, a task with any of them; none: all.
|
||||
final Set<String> labelIds = const {},
|
||||
}) with Equatable {
|
||||
/// Whether nothing is changed from how a project shows at first.
|
||||
bool get isDefault => this == const ProjectView();
|
||||
|
||||
/// Whether its rows can be dragged into another order: sorted by hand
|
||||
/// and grouped by sections.
|
||||
bool get canReorder =>
|
||||
sorting == TaskSorting.manual && grouping == TaskGrouping.none;
|
||||
|
||||
/// Whether it keeps [content] on [today], the week starting on
|
||||
/// [weekStart]. Done tasks are [completed]'s to keep or not.
|
||||
bool shows(
|
||||
TaskContent content, {
|
||||
required Day today,
|
||||
required Day weekStart,
|
||||
}) =>
|
||||
date.holds(content.date?.day, today: today, weekStart: weekStart) &&
|
||||
deadline.holds(content.deadline, today: today, weekStart: weekStart) &&
|
||||
(priorities.isEmpty || priorities.contains(content.priority)) &&
|
||||
(labelIds.isEmpty || content.labelIds.any(labelIds.contains));
|
||||
|
||||
/// Orders two tasks as it sorts them; a tie, or a task without what is
|
||||
/// sorted by (last), as placed by hand.
|
||||
int compare(OpenTask a, OpenTask b) {
|
||||
int nullsLast<T extends Comparable<Object>>(T? x, T? y) => switch ((
|
||||
x,
|
||||
y,
|
||||
)) {
|
||||
(null, null) => 0,
|
||||
(null, _) => 1,
|
||||
(_, null) => -1,
|
||||
(final x?, final y?) => x.compareTo(y),
|
||||
};
|
||||
final first = switch (sorting) {
|
||||
TaskSorting.manual => 0,
|
||||
TaskSorting.name => a.content.title.toLowerCase().compareTo(
|
||||
b.content.title.toLowerCase(),
|
||||
),
|
||||
TaskSorting.date => nullsLast(_dateKey(a), _dateKey(b)),
|
||||
TaskSorting.deadline => nullsLast(
|
||||
a.content.deadline?.toDate(),
|
||||
b.content.deadline?.toDate(),
|
||||
),
|
||||
TaskSorting.addedDate => nullsLast(addedAt(a.id), addedAt(b.id)),
|
||||
TaskSorting.priority => a.content.priority.index.compareTo(
|
||||
b.content.priority.index,
|
||||
),
|
||||
};
|
||||
return first != 0 ? first : byPosition(a, b);
|
||||
}
|
||||
|
||||
/// A task's date and time as one moment, a day without a time first on
|
||||
/// it.
|
||||
static DateTime? _dateKey(OpenTask task) => switch (task.content.date) {
|
||||
final date? => date.day.toDate().add(Duration(minutes: date.time ?? -1)),
|
||||
null => null,
|
||||
};
|
||||
|
||||
ProjectView copyWith({
|
||||
ProjectLayout? layout,
|
||||
bool? completed,
|
||||
TaskGrouping? grouping,
|
||||
TaskSorting? sorting,
|
||||
DayRange? date,
|
||||
DayRange? deadline,
|
||||
Set<Priority>? priorities,
|
||||
Set<String>? labelIds,
|
||||
}) => ProjectView(
|
||||
layout: layout ?? this.layout,
|
||||
completed: completed ?? this.completed,
|
||||
grouping: grouping ?? this.grouping,
|
||||
sorting: sorting ?? this.sorting,
|
||||
date: date ?? this.date,
|
||||
deadline: deadline ?? this.deadline,
|
||||
priorities: priorities ?? this.priorities,
|
||||
labelIds: labelIds ?? this.labelIds,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
layout,
|
||||
completed,
|
||||
grouping,
|
||||
sorting,
|
||||
date,
|
||||
deadline,
|
||||
priorities,
|
||||
labelIds,
|
||||
];
|
||||
}
|
||||
|
||||
/// What a group of a project's list gathers (D17).
|
||||
sealed class const TaskGroupKey() with Equatable;
|
||||
|
||||
/// Tasks dated, or due by their deadline, before today.
|
||||
final class const OverdueGroup() extends TaskGroupKey {
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Tasks of one day (by date, deadline or when added); none: without one.
|
||||
final class const DayGroup(final Day? day) extends TaskGroupKey {
|
||||
@override
|
||||
List<Object?> get props => [day];
|
||||
}
|
||||
|
||||
/// Tasks of one priority.
|
||||
final class const PriorityGroup(final Priority priority) extends TaskGroupKey {
|
||||
@override
|
||||
List<Object?> get props => [priority];
|
||||
}
|
||||
|
||||
/// Tasks with one label; none: without any.
|
||||
final class const LabelGroup(final String? labelId) extends TaskGroupKey {
|
||||
@override
|
||||
List<Object?> get props => [labelId];
|
||||
}
|
||||
|
||||
/// A group of a project's list and its tasks, in their order.
|
||||
typedef TaskGroup = ({TaskGroupKey key, List<OpenTask> tasks});
|
||||
|
||||
/// [tasks], in their order, grouped by [grouping] on [today] (D17): by date
|
||||
/// or deadline overdue first, then each day, none last; by when added each
|
||||
/// day; by priority P1 first; by label in [labelOrder], a task under each
|
||||
/// of its labels, those without last. Groups without tasks are left out.
|
||||
List<TaskGroup> groupTasks(
|
||||
List<OpenTask> tasks,
|
||||
TaskGrouping grouping, {
|
||||
required Day today,
|
||||
List<String> labelOrder = const [],
|
||||
}) {
|
||||
final groups = <TaskGroupKey, List<OpenTask>>{};
|
||||
void put(TaskGroupKey key, OpenTask task) => (groups[key] ??= []).add(task);
|
||||
Day? addedDay(OpenTask task) => switch (addedAt(task.id)) {
|
||||
final at? => Day.of(at),
|
||||
null => null,
|
||||
};
|
||||
TaskGroupKey byDay(Day? day, {bool overdue = true}) =>
|
||||
overdue && day != null && day < today
|
||||
? const OverdueGroup()
|
||||
: DayGroup(day);
|
||||
for (final task in tasks) {
|
||||
switch (grouping) {
|
||||
case TaskGrouping.none:
|
||||
put(const DayGroup(null), task);
|
||||
case TaskGrouping.date:
|
||||
put(byDay(task.content.date?.day), task);
|
||||
case TaskGrouping.deadline:
|
||||
put(byDay(task.content.deadline), task);
|
||||
case TaskGrouping.addedDate:
|
||||
put(byDay(addedDay(task), overdue: false), task);
|
||||
case TaskGrouping.priority:
|
||||
put(PriorityGroup(task.content.priority), task);
|
||||
case TaskGrouping.label:
|
||||
if (task.content.labelIds.isEmpty) {
|
||||
put(const LabelGroup(null), task);
|
||||
}
|
||||
for (final id in task.content.labelIds) {
|
||||
put(LabelGroup(id), task);
|
||||
}
|
||||
}
|
||||
}
|
||||
int rank(TaskGroupKey key) => switch (key) {
|
||||
OverdueGroup() => -1 << 40,
|
||||
DayGroup(:final day?) => day.toDate().millisecondsSinceEpoch ~/ 86400000,
|
||||
DayGroup(day: null) => 1 << 40,
|
||||
PriorityGroup(:final priority) => priority.index,
|
||||
LabelGroup(:final labelId?) => switch (labelOrder.indexOf(labelId)) {
|
||||
-1 => 1 << 39,
|
||||
final index => index,
|
||||
},
|
||||
LabelGroup(labelId: null) => 1 << 40,
|
||||
};
|
||||
final keys = groups.keys.toList()..sort((a, b) => rank(a).compareTo(rank(b)));
|
||||
return [for (final key in keys) (key: key, tasks: groups[key]!)];
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.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_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The button opening a project's Display (ADR 0033, D17), a dot on it
|
||||
/// while the project shows otherwise than at first.
|
||||
class const DisplayButton({required final String projectId, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final view = context.select<TodoCubit, ProjectView>(
|
||||
(cubit) =>
|
||||
cubit.state.projectOf(projectId)?.content?.view ??
|
||||
const ProjectView(),
|
||||
);
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context).display,
|
||||
icon: Badge(
|
||||
smallSize: 8,
|
||||
isLabelVisible: !view.isDefault,
|
||||
child: const Icon(LucideIcons.slidersHorizontal),
|
||||
),
|
||||
onPressed: () => showDisplaySheet(context, projectId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A project's Display (D17, as Todoist's): its done tasks shown or not,
|
||||
/// how it groups and sorts, and what it filters by date, deadline, priority
|
||||
/// and label; each change written to the project at once, synced to every
|
||||
/// device, Reset all putting it back as at first.
|
||||
Future<void> showDisplaySheet(BuildContext context, String projectId) =>
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _DisplaySheet(projectId: projectId),
|
||||
);
|
||||
|
||||
class const _DisplaySheet({required final String projectId})
|
||||
extends StatelessWidget {
|
||||
Future<void> _set(BuildContext context, ProjectView view) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
final project = context.read<TodoCubit>().state.projectOf(projectId);
|
||||
if (project?.content case final content?) {
|
||||
try {
|
||||
await todo.projects.update(projectId, content.copyWith(view: view));
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final view =
|
||||
state.projectOf(projectId)?.content?.view ?? const ProjectView();
|
||||
final labels = {
|
||||
for (final label in state.labels)
|
||||
if (label.content case final content?) label.id: content.name,
|
||||
};
|
||||
Widget heading(String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
Widget choice(
|
||||
IconData icon,
|
||||
String title,
|
||||
String value,
|
||||
Future<void> Function() onTap,
|
||||
) => ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(value),
|
||||
onTap: onTap,
|
||||
);
|
||||
return SheetScaffold(
|
||||
title: l10n.display,
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.circleCheck),
|
||||
title: Text(l10n.displayCompleted),
|
||||
value: view.completed,
|
||||
onChanged: (on) => _set(context, view.copyWith(completed: on)),
|
||||
),
|
||||
heading(l10n.displaySort),
|
||||
choice(
|
||||
LucideIcons.group,
|
||||
l10n.displayGrouping,
|
||||
groupingText(l10n, view.grouping),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displayGrouping,
|
||||
TaskGrouping.values,
|
||||
view.grouping,
|
||||
(grouping) => groupingText(l10n, grouping),
|
||||
)
|
||||
case final grouping?) {
|
||||
if (context.mounted) {
|
||||
await _set(context, view.copyWith(grouping: grouping));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.arrowUpDown,
|
||||
l10n.displaySorting,
|
||||
sortingText(l10n, view.sorting),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displaySorting,
|
||||
TaskSorting.values,
|
||||
view.sorting,
|
||||
(sorting) => sortingText(l10n, sorting),
|
||||
)
|
||||
case final sorting?) {
|
||||
if (context.mounted) {
|
||||
await _set(context, view.copyWith(sorting: sorting));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
heading(l10n.displayFilter),
|
||||
choice(
|
||||
LucideIcons.calendar,
|
||||
l10n.dateTitle,
|
||||
rangeText(l10n, view.date),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.dateTitle,
|
||||
DayRange.values,
|
||||
view.date,
|
||||
(range) => rangeText(l10n, range),
|
||||
)
|
||||
case final range?) {
|
||||
if (context.mounted) {
|
||||
await _set(context, view.copyWith(date: range));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.target,
|
||||
l10n.displayDeadline,
|
||||
rangeText(l10n, view.deadline, deadline: true),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displayDeadline,
|
||||
DayRange.values,
|
||||
view.deadline,
|
||||
(range) => rangeText(l10n, range, deadline: true),
|
||||
)
|
||||
case final range?) {
|
||||
if (context.mounted) {
|
||||
await _set(context, view.copyWith(deadline: range));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.flag,
|
||||
l10n.priorityTitle,
|
||||
view.priorities.isEmpty
|
||||
? l10n.displayAll
|
||||
: [
|
||||
for (final priority in Priority.values)
|
||||
if (view.priorities.contains(priority))
|
||||
priorityText(l10n, priority),
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await _pickPriorities(context, view.priorities)
|
||||
case final picked?) {
|
||||
if (context.mounted) {
|
||||
await _set(context, view.copyWith(priorities: picked));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.tag,
|
||||
l10n.displayLabel,
|
||||
view.labelIds.isEmpty
|
||||
? l10n.displayAny
|
||||
: [
|
||||
for (final id in view.labelIds) ?labels[id],
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await pickLabels(context, [...view.labelIds])
|
||||
case final picked?) {
|
||||
if (context.mounted) {
|
||||
await _set(context, view.copyWith(labelIds: {...picked}));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: view.isDefault
|
||||
? null
|
||||
: () => _set(context, const ProjectView()),
|
||||
child: Text(l10n.displayReset),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.displayDone),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for one of [values], [current] ticked.
|
||||
Future<T?> _pickOne<T>(
|
||||
BuildContext context,
|
||||
String title,
|
||||
List<T> values,
|
||||
T current,
|
||||
String Function(T value) text,
|
||||
) => showDialog<T>(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
title: Text(title),
|
||||
children: [
|
||||
for (final value in values)
|
||||
ListTile(
|
||||
title: Text(text(value)),
|
||||
trailing: value == current ? const Icon(LucideIcons.check) : null,
|
||||
onTap: () => Navigator.of(context).pop(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// Asks for the priorities kept, [current] ticked; none ticked keeps all.
|
||||
Future<Set<Priority>?> _pickPriorities(
|
||||
BuildContext context,
|
||||
Set<Priority> current,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
var picked = {...current};
|
||||
return showDialog<Set<Priority>>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: Text(l10n.priorityTitle),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final priority in Priority.values)
|
||||
CheckboxListTile(
|
||||
secondary: Icon(
|
||||
LucideIcons.flag,
|
||||
color: priorityColor(context, priority),
|
||||
),
|
||||
title: Text(priorityText(l10n, priority)),
|
||||
value: picked.contains(priority),
|
||||
onChanged: (on) => setState(() {
|
||||
picked = {...picked};
|
||||
if (on ?? false) {
|
||||
picked.add(priority);
|
||||
} else {
|
||||
picked.remove(priority);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.displayCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(picked),
|
||||
child: Text(l10n.displaySave),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [grouping]'s name.
|
||||
String groupingText(AppLocalizations l10n, TaskGrouping grouping) =>
|
||||
switch (grouping) {
|
||||
TaskGrouping.none => l10n.groupingNone,
|
||||
TaskGrouping.date => l10n.groupingDate,
|
||||
TaskGrouping.deadline => l10n.groupingDeadline,
|
||||
TaskGrouping.addedDate => l10n.groupingAdded,
|
||||
TaskGrouping.priority => l10n.groupingPriority,
|
||||
TaskGrouping.label => l10n.groupingLabel,
|
||||
};
|
||||
|
||||
/// [sorting]'s name.
|
||||
String sortingText(AppLocalizations l10n, TaskSorting sorting) =>
|
||||
switch (sorting) {
|
||||
TaskSorting.manual => l10n.sortingManual,
|
||||
TaskSorting.name => l10n.sortingName,
|
||||
TaskSorting.date => l10n.sortingDate,
|
||||
TaskSorting.deadline => l10n.sortingDeadline,
|
||||
TaskSorting.addedDate => l10n.sortingAdded,
|
||||
TaskSorting.priority => l10n.sortingPriority,
|
||||
};
|
||||
|
||||
/// [range]'s name, for a filter by date or, where [deadline], by deadline.
|
||||
String rangeText(
|
||||
AppLocalizations l10n,
|
||||
DayRange range, {
|
||||
bool deadline = false,
|
||||
}) => switch (range) {
|
||||
DayRange.all => l10n.displayAll,
|
||||
DayRange.today => l10n.rangeToday,
|
||||
DayRange.thisWeek => l10n.rangeThisWeek,
|
||||
DayRange.next7Days => l10n.rangeNext7,
|
||||
DayRange.thisMonth => l10n.rangeThisMonth,
|
||||
DayRange.next30Days => l10n.rangeNext30,
|
||||
DayRange.none => deadline ? l10n.rangeNoDeadline : l10n.rangeNoDate,
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_app/halkyon_app.dart';
|
|||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -14,58 +15,49 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The screen of the project [projectId] (the Inbox's too): its tasks, a
|
||||
/// button to add one there, and a menu to show the done ones, add a
|
||||
/// section and, but for the Inbox, edit or delete the project.
|
||||
/// button to add one there, its Display (D17: its done tasks, grouping,
|
||||
/// sorting, filters), and a menu to add a section and, but for the Inbox,
|
||||
/// edit or delete the project.
|
||||
class const ProjectScreenBody({
|
||||
required final String title,
|
||||
required final String? projectId,
|
||||
final List<PopupMenuEntry<VoidCallback>> Function(BuildContext context)? menu,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<ProjectScreenBody> createState() => _ProjectScreenBodyState();
|
||||
}
|
||||
|
||||
class _ProjectScreenBodyState() extends State<ProjectScreenBody> {
|
||||
var _completed = false;
|
||||
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final projectId = widget.projectId;
|
||||
return TabScreen(
|
||||
title: widget.title,
|
||||
title: title,
|
||||
actions: [
|
||||
if (projectId != null)
|
||||
if (projectId case final projectId?) ...[
|
||||
DisplayButton(projectId: projectId),
|
||||
PopupMenuButton<VoidCallback>(
|
||||
tooltip: l10n.projectMenu,
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => setState(() => _completed = !_completed),
|
||||
child: MenuEntry(
|
||||
_completed ? LucideIcons.eyeOff : LucideIcons.circleCheck,
|
||||
_completed ? l10n.completedHide : l10n.completedShow,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => addSection(context, projectId),
|
||||
child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd),
|
||||
),
|
||||
selectTasksEntry(context),
|
||||
...?widget.menu?.call(context),
|
||||
...?menu?.call(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
floatingActionButton: projectId == null
|
||||
? null
|
||||
: AddTaskButton(onPressed: () => showQuickAddIn(context, projectId)),
|
||||
floatingActionButton: switch (projectId) {
|
||||
final id? => AddTaskButton(
|
||||
onPressed: () => showQuickAddIn(context, id),
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
body: Column(
|
||||
children: [
|
||||
const KeysResetBanner(),
|
||||
Expanded(
|
||||
child: switch (projectId) {
|
||||
final id? => ProjectTasks(projectId: id, completed: _completed),
|
||||
final id? => ProjectTasks(projectId: id),
|
||||
// No Inbox yet: made by the first device holding the
|
||||
// account's keys, or still on its way here.
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
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/quick_add_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.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';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -19,18 +25,20 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// A project's tasks as a list (ADR 0033, D8): those in no section first,
|
||||
/// then each section under its name, count and description, with a menu to
|
||||
/// add a task there, rename it or delete it; done tasks too where
|
||||
/// [completed]. Sub-tasks show in their task's sheet, counted on its row.
|
||||
/// the project's Display asks. Sub-tasks show in their task's sheet, counted on its row.
|
||||
/// A section's heading stays atop the list while its tasks scroll beneath
|
||||
/// it, until the next one pushes it away (the developer's, 1 Nov, D14);
|
||||
/// the rows are built as they scroll into view. A row held is dragged into
|
||||
/// another place, or a little to the right to become the sub-task of the
|
||||
/// row it is dropped below, and the add button dropped between two rows
|
||||
/// adds a task there (D16).
|
||||
class const ProjectTasks({
|
||||
required final String projectId,
|
||||
final bool completed = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
///
|
||||
/// What it shows, in what order and grouped how, is the project's Display
|
||||
/// ([ProjectView], D17): grouped otherwise than by sections, its groups
|
||||
/// stand in their place; its rows are dragged only while it is sorted by
|
||||
/// hand and grouped by sections.
|
||||
class const ProjectTasks({required final String projectId, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -38,7 +46,24 @@ class const ProjectTasks({
|
|||
if (!state.loaded) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
final loose = tasksIn(state.tasks, projectId, completed: completed);
|
||||
final view =
|
||||
state.projectOf(projectId)?.content?.view ?? const ProjectView();
|
||||
final today = Day.of(clock.now());
|
||||
final weekStart = WeekStrip.weekOf(
|
||||
today,
|
||||
MaterialLocalizations.of(context).firstDayOfWeekIndex,
|
||||
);
|
||||
// The project's tasks of [sectionId] its Display keeps, in its order.
|
||||
List<OpenTask> kept({String? sectionId}) => [
|
||||
for (final task in tasksIn(
|
||||
state.tasks,
|
||||
projectId,
|
||||
sectionId: sectionId,
|
||||
completed: view.completed,
|
||||
))
|
||||
if (view.shows(task.content, today: today, weekStart: weekStart)) task,
|
||||
]..sort(view.compare);
|
||||
final loose = kept();
|
||||
final sections = state.sectionsOf(projectId);
|
||||
if (loose.isEmpty &&
|
||||
sections.isEmpty &&
|
||||
|
|
@ -56,16 +81,19 @@ class const ProjectTasks({
|
|||
final groups = [
|
||||
(sectionId: null, tasks: loose),
|
||||
for (final section in sections)
|
||||
(
|
||||
sectionId: section.id,
|
||||
tasks: tasksIn(
|
||||
state.tasks,
|
||||
projectId,
|
||||
sectionId: section.id,
|
||||
completed: completed,
|
||||
),
|
||||
),
|
||||
(sectionId: section.id, tasks: kept(sectionId: section.id)),
|
||||
];
|
||||
if (view.grouping != TaskGrouping.none) {
|
||||
return _grouped(
|
||||
context,
|
||||
state,
|
||||
view,
|
||||
[
|
||||
for (final group in groups) ...group.tasks,
|
||||
]..sort(view.compare),
|
||||
today,
|
||||
);
|
||||
}
|
||||
final last = groups.last;
|
||||
return NewTaskDropArea(
|
||||
// Below every row: last in the last group.
|
||||
|
|
@ -85,11 +113,24 @@ class const ProjectTasks({
|
|||
SliverList.list(
|
||||
children: [
|
||||
for (final (index, task) in loose.indexed)
|
||||
_row(state, groups.first, index, task),
|
||||
_row(
|
||||
state,
|
||||
groups.first,
|
||||
index,
|
||||
task,
|
||||
reorder: view.canReorder,
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final (index, section) in sections.indexed)
|
||||
_section(context, state, section, groups[index], groups[index + 1]),
|
||||
_section(
|
||||
context,
|
||||
state,
|
||||
section,
|
||||
groups[index],
|
||||
groups[index + 1],
|
||||
reorder: view.canReorder,
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
|
|
@ -100,8 +141,93 @@ class const ProjectTasks({
|
|||
);
|
||||
}
|
||||
|
||||
/// [tasks] grouped by [view]'s grouping (D17), each group under a heading
|
||||
/// that stays atop the list while its rows scroll beneath it; nothing
|
||||
/// dragged, the order being the grouping's.
|
||||
Widget _grouped(
|
||||
BuildContext context,
|
||||
TodoState state,
|
||||
ProjectView view,
|
||||
List<OpenTask> tasks,
|
||||
Day today,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.listFilter,
|
||||
title: l10n.viewNothingTitle,
|
||||
hint: l10n.viewNothingHint,
|
||||
),
|
||||
);
|
||||
}
|
||||
final labels = {
|
||||
for (final label in state.labels)
|
||||
if (label.content case final content?) label.id: content.name,
|
||||
};
|
||||
String title(TaskGroupKey key) => switch (key) {
|
||||
OverdueGroup() => l10n.overdue,
|
||||
DayGroup(:final day?) => dayHeading(context, day, today),
|
||||
DayGroup(day: null) => switch (view.grouping) {
|
||||
TaskGrouping.deadline => l10n.rangeNoDeadline,
|
||||
_ => l10n.rangeNoDate,
|
||||
},
|
||||
PriorityGroup(:final priority) => priorityText(l10n, priority),
|
||||
LabelGroup(:final labelId?) => labels[labelId] ?? '',
|
||||
LabelGroup(labelId: null) => l10n.groupNoLabel,
|
||||
};
|
||||
final groups = groupTasks(
|
||||
tasks,
|
||||
view.grouping,
|
||||
today: today,
|
||||
labelOrder: [for (final label in state.labels) label.id],
|
||||
);
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
for (final (index, group) in groups.indexed)
|
||||
SliverMainAxisGroup(
|
||||
slivers: [
|
||||
if (index > 0)
|
||||
const SliverToBoxAdapter(child: Divider(height: 24)),
|
||||
PinnedHeaderSliver(
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surface,
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
'${title(group.key)} ${group.tasks.length}',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: group.tasks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
TaskTile(task: group.tasks[index]),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// [task], the [index]th of [group], dragged and dropped on (D16).
|
||||
Widget _row(TodoState state, _Group group, int index, OpenTask task) {
|
||||
Widget _row(
|
||||
TodoState state,
|
||||
_Group group,
|
||||
int index,
|
||||
OpenTask task, {
|
||||
required bool reorder,
|
||||
}) {
|
||||
if (!reorder) {
|
||||
return TaskTile(task: task);
|
||||
}
|
||||
final tasks = group.tasks;
|
||||
final subTasks = subTasksOf(state.tasks, task.id);
|
||||
return TaskDropTarget(
|
||||
|
|
@ -137,8 +263,9 @@ class const ProjectTasks({
|
|||
TodoState state,
|
||||
StoredSection section,
|
||||
_Group previous,
|
||||
_Group group,
|
||||
) {
|
||||
_Group group, {
|
||||
required bool reorder,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final content = section.content!;
|
||||
|
|
@ -228,7 +355,7 @@ class const ProjectTasks({
|
|||
SliverList.builder(
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_row(state, group, index, tasks[index]),
|
||||
_row(state, group, index, tasks[index], reorder: reorder),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
|
|||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.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';
|
||||
|
|
@ -358,6 +359,144 @@ void main() {
|
|||
expect(taskRows.byId.values.single.keyId, isNotNull);
|
||||
});
|
||||
|
||||
group("a project's Display (D17)", () {
|
||||
Finder row(String title) => find.widgetWithText(ListTile, title);
|
||||
|
||||
Future<void> display(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Display'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> pick(
|
||||
WidgetTester tester,
|
||||
String setting,
|
||||
String value,
|
||||
) async {
|
||||
await tester.tap(find.widgetWithText(ListTile, setting));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(ListTile, value).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> done(WidgetTester tester) async {
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> inboxOf(WidgetTester tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
for (final (index, (title, priority)) in [
|
||||
('cherry', Priority.none),
|
||||
('Apple', Priority.p1),
|
||||
('banana', Priority.p2),
|
||||
].indexed) {
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: title,
|
||||
projectId: inbox(),
|
||||
position: index.toDouble(),
|
||||
priority: priority,
|
||||
),
|
||||
);
|
||||
}
|
||||
await goTab(tester, 'Inbox');
|
||||
}
|
||||
|
||||
List<String> order(WidgetTester tester) => ['cherry', 'Apple', 'banana']
|
||||
..sort(
|
||||
(a, b) => tester
|
||||
.getCenter(row(a))
|
||||
.dy
|
||||
.compareTo(tester.getCenter(row(b)).dy),
|
||||
);
|
||||
|
||||
testWidgets('sorting by name orders the list, kept in the project for '
|
||||
'every device; a dot marks the button; Reset all puts it back', (
|
||||
tester,
|
||||
) async {
|
||||
await inboxOf(tester);
|
||||
expect(order(tester), ['cherry', 'Apple', 'banana']);
|
||||
expect(
|
||||
tester.widget<Badge>(find.byType(Badge).first).isLabelVisible,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await display(tester);
|
||||
await pick(tester, 'Sorting', 'Name');
|
||||
await done(tester);
|
||||
|
||||
expect(order(tester), ['Apple', 'banana', 'cherry']);
|
||||
final projects = await tester.runAsync(
|
||||
() => ProjectRepository(projectRows, keys).all(),
|
||||
);
|
||||
expect(
|
||||
projects!
|
||||
.singleWhere((project) => project.id == inbox())
|
||||
.content
|
||||
?.view
|
||||
.sorting,
|
||||
TaskSorting.name,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Badge>(find.byType(Badge).first).isLabelVisible,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await display(tester);
|
||||
await tester.tap(find.text('Reset all'));
|
||||
await tester.pumpAndSettle();
|
||||
await done(tester);
|
||||
expect(order(tester), ['cherry', 'Apple', 'banana']);
|
||||
});
|
||||
|
||||
testWidgets('grouped by priority, each priority under its heading', (
|
||||
tester,
|
||||
) async {
|
||||
await inboxOf(tester);
|
||||
|
||||
await display(tester);
|
||||
await pick(tester, 'Grouping', 'Priority');
|
||||
await done(tester);
|
||||
|
||||
expect(find.text('Priority 1 1'), findsOneWidget);
|
||||
expect(find.text('Priority 2 1'), findsOneWidget);
|
||||
expect(find.text('No priority 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a filter by priority leaves out the others; none left, it '
|
||||
'says so', (tester) async {
|
||||
await inboxOf(tester);
|
||||
|
||||
await display(tester);
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Priority'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(ListTile, 'Priority 1'), findsOneWidget);
|
||||
await done(tester);
|
||||
|
||||
expect(row('Apple'), findsOneWidget);
|
||||
expect(row('banana'), findsNothing);
|
||||
expect(row('cherry'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('rows are dragged only while sorted by hand', (tester) async {
|
||||
await inboxOf(tester);
|
||||
await display(tester);
|
||||
await pick(tester, 'Sorting', 'Priority');
|
||||
await done(tester);
|
||||
expect(find.byType(DraggableTask), findsNothing);
|
||||
|
||||
await display(tester);
|
||||
await pick(tester, 'Sorting', 'Manual');
|
||||
await done(tester);
|
||||
expect(find.byType(DraggableTask), findsNWidgets(3));
|
||||
});
|
||||
});
|
||||
|
||||
group('quick add reads what is typed (D15)', () {
|
||||
Future<void> open(WidgetTester tester, String text) async {
|
||||
await tester.tap(find.byTooltip('Add task'));
|
||||
|
|
@ -573,9 +712,11 @@ void main() {
|
|||
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
await tester.tap(find.byTooltip('Display'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Show done tasks'));
|
||||
await tester.tap(find.text('Completed tasks'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
final title = tester.widget<Text>(find.text('Water plants'));
|
||||
expect(title.style?.decoration, TextDecoration.lineThrough);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'dart:typed_data';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// A project's payload (ADR 0033, D6): UTF-8 JSON with its schema's version,
|
||||
|
|
@ -26,6 +28,45 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("a project's Display reads back as it was set, by names; left "
|
||||
'as at first, nothing of it is written (D17)', () {
|
||||
const view = ProjectView(
|
||||
completed: true,
|
||||
grouping: TaskGrouping.label,
|
||||
sorting: TaskSorting.priority,
|
||||
date: DayRange.next7Days,
|
||||
deadline: DayRange.none,
|
||||
priorities: {Priority.p1, Priority.none},
|
||||
labelIds: {'l1'},
|
||||
);
|
||||
const home = ProjectContent(name: 'Home', view: view);
|
||||
expect(decodeProject(encodeProject(home)), home);
|
||||
|
||||
final written = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(encodeProject(home))),
|
||||
) as Map<String, Object?>;
|
||||
expect(written['view'], {
|
||||
'completed': true,
|
||||
'grouping': 'label',
|
||||
'sorting': 'priority',
|
||||
'date': 'next7Days',
|
||||
'deadline': 'none',
|
||||
'priorities': ['p1', 'none'],
|
||||
'labelIds': ['l1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('a Display value this version does not know reads as at first', () {
|
||||
final read = decodeProject(
|
||||
json({
|
||||
'schemaVersion': 1,
|
||||
'name': 'Home',
|
||||
'view': {'layout': 'timeline', 'sorting': 'mood', 'completed': true},
|
||||
}),
|
||||
);
|
||||
expect(read?.view, const ProjectView(completed: true));
|
||||
});
|
||||
|
||||
test('the payload names its schema and the colour by its name', () {
|
||||
final written = jsonDecode(
|
||||
utf8.decode(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
|
||||
/// A project's Display (ADR 0033, D17): what it filters, how it sorts and
|
||||
/// groups.
|
||||
void main() {
|
||||
// Sunday, 1 Nov 2026; weeks from Monday.
|
||||
const today = Day(2026, 11, 1);
|
||||
const weekStart = Day(2026, 10, 26);
|
||||
|
||||
OpenTask task(
|
||||
String id, {
|
||||
double position = 0,
|
||||
Day? day,
|
||||
int? time,
|
||||
Day? deadline,
|
||||
Priority priority = Priority.none,
|
||||
List<String> labels = const [],
|
||||
String? section,
|
||||
}) => (
|
||||
id: id,
|
||||
content: TaskContent(
|
||||
title: id,
|
||||
projectId: 'p',
|
||||
sectionId: section,
|
||||
position: position,
|
||||
date: day == null ? null : TaskDate(day, time: time),
|
||||
deadline: deadline,
|
||||
priority: priority,
|
||||
labelIds: labels,
|
||||
),
|
||||
);
|
||||
|
||||
List<String> ids(List<OpenTask> tasks) => [for (final t in tasks) t.id];
|
||||
|
||||
List<String> filtered(List<OpenTask> tasks, ProjectView view) => ids([
|
||||
for (final t in tasks)
|
||||
if (view.shows(t.content, today: today, weekStart: weekStart)) t,
|
||||
]);
|
||||
|
||||
test('the default view filters nothing, sorts by hand, groups by '
|
||||
'sections, done tasks hidden', () {
|
||||
const view = ProjectView();
|
||||
expect(view.isDefault, isTrue);
|
||||
expect(view.layout, ProjectLayout.list);
|
||||
expect(view.completed, isFalse);
|
||||
expect(view.grouping, TaskGrouping.none);
|
||||
expect(view.sorting, TaskSorting.manual);
|
||||
expect(view.canReorder, isTrue);
|
||||
expect(view.copyWith(sorting: TaskSorting.name).canReorder, isFalse);
|
||||
expect(view.copyWith(grouping: TaskGrouping.date).canReorder, isFalse);
|
||||
expect(view.copyWith(completed: true).isDefault, isFalse);
|
||||
});
|
||||
|
||||
group('filters by date', () {
|
||||
final tasks = [
|
||||
task('none'),
|
||||
task('past', day: today.plus(-1)),
|
||||
task('today', day: today),
|
||||
task('in 3', day: today.plus(3)),
|
||||
task('in 10', day: today.plus(10)),
|
||||
task('in 29', day: today.plus(29)),
|
||||
task('in 31', day: today.plus(31)),
|
||||
];
|
||||
|
||||
for (final (range, expected) in [
|
||||
(
|
||||
DayRange.all,
|
||||
['none', 'past', 'today', 'in 3', 'in 10', 'in 29', 'in 31'],
|
||||
),
|
||||
(DayRange.today, ['today']),
|
||||
(DayRange.thisWeek, ['past', 'today']),
|
||||
(DayRange.next7Days, ['today', 'in 3']),
|
||||
(DayRange.thisMonth, ['today', 'in 3', 'in 10', 'in 29']),
|
||||
(DayRange.next30Days, ['today', 'in 3', 'in 10', 'in 29']),
|
||||
(DayRange.none, ['none']),
|
||||
]) {
|
||||
test(range.name, () {
|
||||
expect(filtered(tasks, ProjectView(date: range)), expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('filters by deadline, priority and label, all at once', () {
|
||||
final tasks = [
|
||||
task('a', deadline: today.plus(2), priority: Priority.p1, labels: ['x']),
|
||||
task('b', deadline: today.plus(2), priority: Priority.p2, labels: ['x']),
|
||||
task('c', priority: Priority.p1, labels: ['x']),
|
||||
task('d', deadline: today, priority: Priority.p1, labels: ['y']),
|
||||
];
|
||||
expect(
|
||||
filtered(tasks, const ProjectView(deadline: DayRange.next7Days)),
|
||||
['a', 'b', 'd'],
|
||||
);
|
||||
expect(filtered(tasks, const ProjectView(deadline: DayRange.none)), ['c']);
|
||||
expect(
|
||||
filtered(tasks, const ProjectView(priorities: {Priority.p1})),
|
||||
['a', 'c', 'd'],
|
||||
);
|
||||
expect(
|
||||
filtered(tasks, const ProjectView(priorities: {Priority.none})),
|
||||
isEmpty,
|
||||
);
|
||||
expect(filtered(tasks, const ProjectView(labelIds: {'y'})), ['d']);
|
||||
expect(
|
||||
filtered(
|
||||
tasks,
|
||||
const ProjectView(
|
||||
deadline: DayRange.next7Days,
|
||||
priorities: {Priority.p1},
|
||||
labelIds: {'x'},
|
||||
),
|
||||
),
|
||||
['a'],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'sorts by name, date, deadline, priority; ties and the rest by hand',
|
||||
() {
|
||||
final tasks = [
|
||||
task('cherry', position: 1, day: today, time: 9 * 60),
|
||||
task('Apple', position: 2, deadline: today.plus(1)),
|
||||
task('banana', position: 3, day: today, priority: Priority.p1),
|
||||
task('date', day: today.plus(1), priority: Priority.p2),
|
||||
];
|
||||
List<String> sorted(TaskSorting sorting) =>
|
||||
ids([...tasks]..sort(ProjectView(sorting: sorting).compare));
|
||||
expect(sorted(TaskSorting.manual), ['date', 'cherry', 'Apple', 'banana']);
|
||||
expect(sorted(TaskSorting.name), ['Apple', 'banana', 'cherry', 'date']);
|
||||
expect(
|
||||
sorted(TaskSorting.date),
|
||||
['banana', 'cherry', 'date', 'Apple'],
|
||||
reason: 'a day without a time first on it, none last',
|
||||
);
|
||||
expect(sorted(TaskSorting.deadline), [
|
||||
'Apple',
|
||||
'date',
|
||||
'cherry',
|
||||
'banana',
|
||||
]);
|
||||
expect(sorted(TaskSorting.priority), [
|
||||
'banana',
|
||||
'date',
|
||||
'cherry',
|
||||
'Apple',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
group('groups', () {
|
||||
test('by date: overdue, each day, no date last', () {
|
||||
final groups = groupTasks(
|
||||
[
|
||||
task('later', day: today.plus(3)),
|
||||
task('none'),
|
||||
task('past', day: today.plus(-2)),
|
||||
task('now', day: today),
|
||||
],
|
||||
TaskGrouping.date,
|
||||
today: today,
|
||||
);
|
||||
expect(
|
||||
[for (final g in groups) g.key],
|
||||
[
|
||||
const OverdueGroup(),
|
||||
const DayGroup(today),
|
||||
DayGroup(today.plus(3)),
|
||||
const DayGroup(null),
|
||||
],
|
||||
);
|
||||
expect(ids(groups.first.tasks), ['past']);
|
||||
});
|
||||
|
||||
test('by priority, P1 first; by label, a task under each of its '
|
||||
'labels, those without last', () {
|
||||
final tasks = [
|
||||
task('low'),
|
||||
task('high', priority: Priority.p1, labels: ['b', 'a']),
|
||||
task('mid', priority: Priority.p3, labels: ['a']),
|
||||
];
|
||||
expect(
|
||||
[
|
||||
for (final g in groupTasks(
|
||||
tasks,
|
||||
TaskGrouping.priority,
|
||||
today: today,
|
||||
))
|
||||
(g.key, ids(g.tasks).join(',')),
|
||||
],
|
||||
[
|
||||
(const PriorityGroup(Priority.p1), 'high'),
|
||||
(const PriorityGroup(Priority.p3), 'mid'),
|
||||
(const PriorityGroup(Priority.none), 'low'),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
[
|
||||
for (final g in groupTasks(
|
||||
tasks,
|
||||
TaskGrouping.label,
|
||||
today: today,
|
||||
labelOrder: ['a', 'b'],
|
||||
))
|
||||
(g.key, ids(g.tasks).join(',')),
|
||||
],
|
||||
[
|
||||
(const LabelGroup('a'), 'high,mid'),
|
||||
(const LabelGroup('b'), 'high'),
|
||||
(const LabelGroup(null), 'low'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('by deadline, as by date', () {
|
||||
final groups = groupTasks(
|
||||
[task('a', deadline: today), task('b')],
|
||||
TaskGrouping.deadline,
|
||||
today: today,
|
||||
);
|
||||
expect(
|
||||
[for (final g in groups) g.key],
|
||||
[
|
||||
const DayGroup(today),
|
||||
const DayGroup(null),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue