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:
Tizian 「ティツィアーン」 2026-11-01 18:41:12 +01:00
commit 7d1de304c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 1878 additions and 81 deletions

View file

@ -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"
}

View file

@ -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)."
}
}

View file

@ -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

View file

@ -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';
}

View file

@ -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`).

View file

@ -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']),
),
);

View file

@ -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];
}

View file

@ -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]!)];
}

View file

@ -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,
};

View file

@ -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

View file

@ -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),
),
],
);

View file

@ -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);

View file

@ -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(

View file

@ -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),
],
);
});
});
}