feat(todo): Today, Upcoming, Inbox, projects, quick add and the task sheet (ADR 0033, D8, D9)

The tabs hold the person's tasks: Today what is overdue (Reschedule
moving it all to a day, times kept) and what is due today; Upcoming the
overdue ones and the next four weeks day by day; Inbox and each
project's screen from Browse the tasks above and in their sections, done
ones on asking; Browse the projects with their open counts and the
labels. A task's row shows its check in its priority's colour, its date
in the colour of how near it is, its deadline, its sub-tasks done and
its labels; quick add takes its title, date, priority, project, labels
and description and stays for the next; its sheet edits all of it, adds
sub-tasks and deletes it with them after asking. Colours are the
palette's, cerise for overdue and P1 against the strawberry wash.

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 10:25:34 +01:00
commit 030c62ddde
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
26 changed files with 3647 additions and 121 deletions

View file

@ -15,11 +15,84 @@
"shellTabUpcoming": "Demnächst",
"shellTabInbox": "Eingang",
"shellTabBrowse": "Übersicht",
"todayLaterTitle": "Hier stehen die Aufgaben von heute",
"upcomingLaterTitle": "Hier stehen die nächsten Tage",
"inboxLaterTitle": "Hier stehen Aufgaben ohne Projekt",
"browseLaterTitle": "Hier stehen deine Projekte und Labels",
"tasksLaterHint": "Aufgaben kommen mit einer späteren Version von Halkyon Todo.",
"settingsTitle": "Einstellungen",
"settingsVersion": "Version {version} (Build {build})"
"settingsVersion": "Version {version} (Build {build})",
"clear": "Entfernen",
"colorLabel": "Farbe",
"completedShow": "Erledigte zeigen",
"completedHide": "Erledigte ausblenden",
"dateTitle": "Datum",
"dateNextWeek": "Nächste Woche",
"dateWeekend": "Dieses Wochenende",
"datePick": "Tag wählen",
"dateTime": "Uhrzeit",
"dateNone": "Kein Datum",
"dateNoneYet": "Kein Datum",
"dayToday": "Heute",
"dayTomorrow": "Morgen",
"dayYesterday": "Gestern",
"dayHeadingNamed": "{date} · {name}",
"deadlineOn": "Frist {day}",
"deadlineNone": "Keine Frist",
"keysMissing": "Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.",
"labelsTitle": "Labels",
"labelsNone": "Noch keine Labels.",
"labelsNoneSet": "Keine Labels",
"labelsCount": "{count, plural, =1{1 Label} other{{count} Labels}}",
"labelAdd": "Neues Label",
"labelEdit": "Label bearbeiten",
"labelDelete": "Label löschen",
"labelDeleteTitle": "Label löschen?",
"labelDeleteMessage": "„{name}“ wird von jeder Aufgabe entfernt, die es hat.",
"nameLabel": "Name",
"save": "Speichern",
"overdue": "Überfällig",
"reschedule": "Verschieben",
"priorityTitle": "Priorität",
"priority1": "Priorität 1",
"priority2": "Priorität 2",
"priority3": "Priorität 3",
"priority4": "Keine Priorität",
"projectsTitle": "Projekte",
"projectsNone": "Noch keine eigenen Projekte.",
"projectAdd": "Neues Projekt",
"projectEdit": "Projekt bearbeiten",
"projectDelete": "Projekt löschen",
"projectDeleteTitle": "Projekt löschen?",
"projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
"projectEmptyTitle": "Hier ist nichts zu tun",
"projectEmptyHint": "Füge mit dem Plus eine Aufgabe hinzu.",
"projectGone": "Dieses Projekt wurde gelöscht.",
"projectMenu": "Mehr",
"projectPickTitle": "Projekt",
"sectionAdd": "Neuer Abschnitt",
"sectionEdit": "Abschnitt umbenennen",
"sectionDelete": "Abschnitt löschen",
"sectionDeleteTitle": "Abschnitt löschen?",
"sectionDeleteMessage": "„{name}“ wird mit seinen Aufgaben gelöscht.",
"sectionMenu": "Abschnitt",
"subTasks": "Unteraufgaben",
"subTasksDone": "Unteraufgaben {done}/{count}",
"subTaskAdd": "Unteraufgabe hinzufügen",
"taskTitle": "Aufgabe",
"taskAddTitle": "Aufgabe hinzufügen",
"taskAdd": "Hinzufügen",
"taskAddOn": "Aufgabe für {day} hinzufügen",
"taskTitleHint": "Was ist zu tun?",
"taskDescription": "Beschreibung",
"taskDescriptionHint": "Beschreibung",
"taskComplete": "Als erledigt markieren",
"taskReopen": "Als unerledigt markieren",
"taskCompleted": "Aufgabe erledigt",
"taskDelete": "Aufgabe löschen",
"taskDeleteTitle": "Aufgabe löschen?",
"taskDeleteMessage": "„{title}“ wird auf all deinen Geräten gelöscht.",
"taskDeleteWithSubTasks": "„{title}“ wird mit {count, plural, =1{seiner Unteraufgabe} other{seinen {count} Unteraufgaben}} auf all deinen Geräten gelöscht.",
"taskGone": "Diese Aufgabe wurde gelöscht.",
"todayEmptyTitle": "Heute ist nichts fällig",
"todayEmptyHint": "Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.",
"lockedRows": "{count, plural, =1{1 Eintrag lässt sich auf diesem Gerät nicht öffnen.} other{{count} Einträge lassen sich auf diesem Gerät nicht öffnen.}}",
"inboxMissingTitle": "Noch kein Eingang",
"inboxMissingHint": "Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat."
}

View file

@ -17,12 +17,96 @@
"shellTabUpcoming": "Upcoming",
"shellTabInbox": "Inbox",
"shellTabBrowse": "Browse",
"todayLaterTitle": "Today's tasks come here",
"upcomingLaterTitle": "The days ahead come here",
"inboxLaterTitle": "Tasks without a project come here",
"browseLaterTitle": "Your projects and labels come here",
"tasksLaterHint": "Tasks come with a later version of Halkyon Todo.",
"settingsTitle": "Settings",
"settingsVersion": "Version {version} (build {build})",
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}}
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
"clear": "Clear",
"colorLabel": "Colour",
"completedShow": "Show done tasks",
"completedHide": "Hide done tasks",
"dateTitle": "Date",
"dateNextWeek": "Next week",
"dateWeekend": "This weekend",
"datePick": "Pick a day",
"dateTime": "Time",
"dateNone": "No date",
"dateNoneYet": "No date",
"dayToday": "Today",
"dayTomorrow": "Tomorrow",
"dayYesterday": "Yesterday",
"dayHeadingNamed": "{date} · {name}",
"@dayHeadingNamed": {"placeholders": {"date": {"type": "String"}, "name": {"type": "String"}}},
"deadlineOn": "Deadline {day}",
"@deadlineOn": {"placeholders": {"day": {"type": "String"}}},
"deadlineNone": "No deadline",
"keysMissing": "This device can't save: it doesn't hold your account's keys. Sign in with your password or recovery phrase to add them.",
"labelsTitle": "Labels",
"labelsNone": "No labels yet.",
"labelsNoneSet": "No labels",
"labelsCount": "{count, plural, =1{1 label} other{{count} labels}}",
"@labelsCount": {"placeholders": {"count": {"type": "int"}}},
"labelAdd": "New label",
"labelEdit": "Edit label",
"labelDelete": "Delete label",
"labelDeleteTitle": "Delete the label?",
"labelDeleteMessage": "\"{name}\" is taken off every task that has it.",
"@labelDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"nameLabel": "Name",
"save": "Save",
"overdue": "Overdue",
"reschedule": "Reschedule",
"priorityTitle": "Priority",
"priority1": "Priority 1",
"priority2": "Priority 2",
"priority3": "Priority 3",
"priority4": "No priority",
"projectsTitle": "Projects",
"projectsNone": "No projects of your own yet.",
"projectAdd": "New project",
"projectEdit": "Edit project",
"projectDelete": "Delete project",
"projectDeleteTitle": "Delete the project?",
"projectDeleteMessage": "\"{name}\" is deleted with its sections and tasks, on all your devices.",
"@projectDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"projectEmptyTitle": "Nothing to do here",
"projectEmptyHint": "Add a task with the + button.",
"projectGone": "This project was deleted.",
"projectMenu": "More",
"projectPickTitle": "Project",
"sectionAdd": "New section",
"sectionEdit": "Rename section",
"sectionDelete": "Delete section",
"sectionDeleteTitle": "Delete the section?",
"sectionDeleteMessage": "\"{name}\" is deleted with its tasks.",
"@sectionDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"sectionMenu": "Section",
"subTasks": "Sub-tasks",
"subTasksDone": "Sub-tasks {done}/{count}",
"@subTasksDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
"subTaskAdd": "Add sub-task",
"taskTitle": "Task",
"taskAddTitle": "Add task",
"taskAdd": "Add",
"taskAddOn": "Add a task on {day}",
"@taskAddOn": {"placeholders": {"day": {"type": "String"}}},
"taskTitleHint": "What's to be done?",
"taskDescription": "Description",
"taskDescriptionHint": "Description",
"taskComplete": "Mark as done",
"taskReopen": "Mark as not done",
"taskCompleted": "Task done",
"taskDelete": "Delete task",
"taskDeleteTitle": "Delete the task?",
"taskDeleteMessage": "\"{title}\" is deleted on all your devices.",
"@taskDeleteMessage": {"placeholders": {"title": {"type": "String"}}},
"taskDeleteWithSubTasks": "\"{title}\" is deleted with its {count, plural, =1{sub-task} other{{count} sub-tasks}}, on all your devices.",
"@taskDeleteWithSubTasks": {"placeholders": {"title": {"type": "String"}, "count": {"type": "int"}}},
"taskGone": "This task was deleted.",
"todayEmptyTitle": "Nothing due today",
"todayEmptyHint": "Tasks dated today, and those overdue, show here.",
"lockedRows": "{count, plural, =1{1 item doesn't open on this device.} other{{count} items don't open on this device.}}",
"@lockedRows": {"placeholders": {"count": {"type": "int"}}},
"inboxMissingTitle": "No Inbox yet",
"inboxMissingHint": "Your Inbox comes with the next sync, or once this device holds your account's keys."
}

View file

@ -160,36 +160,6 @@ abstract class AppLocalizations {
/// **'Browse'**
String get shellTabBrowse;
/// No description provided for @todayLaterTitle.
///
/// In en, this message translates to:
/// **'Today\'s tasks come here'**
String get todayLaterTitle;
/// No description provided for @upcomingLaterTitle.
///
/// In en, this message translates to:
/// **'The days ahead come here'**
String get upcomingLaterTitle;
/// No description provided for @inboxLaterTitle.
///
/// In en, this message translates to:
/// **'Tasks without a project come here'**
String get inboxLaterTitle;
/// No description provided for @browseLaterTitle.
///
/// In en, this message translates to:
/// **'Your projects and labels come here'**
String get browseLaterTitle;
/// No description provided for @tasksLaterHint.
///
/// In en, this message translates to:
/// **'Tasks come with a later version of Halkyon Todo.'**
String get tasksLaterHint;
/// No description provided for @settingsTitle.
///
/// In en, this message translates to:
@ -201,6 +171,468 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Version {version} (build {build})'**
String settingsVersion(String version, String build);
/// No description provided for @clear.
///
/// In en, this message translates to:
/// **'Clear'**
String get clear;
/// No description provided for @colorLabel.
///
/// In en, this message translates to:
/// **'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:
/// **'Date'**
String get dateTitle;
/// No description provided for @dateNextWeek.
///
/// In en, this message translates to:
/// **'Next week'**
String get dateNextWeek;
/// No description provided for @dateWeekend.
///
/// In en, this message translates to:
/// **'This weekend'**
String get dateWeekend;
/// No description provided for @datePick.
///
/// In en, this message translates to:
/// **'Pick a day'**
String get datePick;
/// No description provided for @dateTime.
///
/// In en, this message translates to:
/// **'Time'**
String get dateTime;
/// No description provided for @dateNone.
///
/// In en, this message translates to:
/// **'No date'**
String get dateNone;
/// No description provided for @dateNoneYet.
///
/// In en, this message translates to:
/// **'No date'**
String get dateNoneYet;
/// No description provided for @dayToday.
///
/// In en, this message translates to:
/// **'Today'**
String get dayToday;
/// No description provided for @dayTomorrow.
///
/// In en, this message translates to:
/// **'Tomorrow'**
String get dayTomorrow;
/// No description provided for @dayYesterday.
///
/// In en, this message translates to:
/// **'Yesterday'**
String get dayYesterday;
/// No description provided for @dayHeadingNamed.
///
/// In en, this message translates to:
/// **'{date} · {name}'**
String dayHeadingNamed(String date, String name);
/// No description provided for @deadlineOn.
///
/// In en, this message translates to:
/// **'Deadline {day}'**
String deadlineOn(String day);
/// No description provided for @deadlineNone.
///
/// In en, this message translates to:
/// **'No deadline'**
String get deadlineNone;
/// No description provided for @keysMissing.
///
/// In en, this message translates to:
/// **'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.'**
String get keysMissing;
/// No description provided for @labelsTitle.
///
/// In en, this message translates to:
/// **'Labels'**
String get labelsTitle;
/// No description provided for @labelsNone.
///
/// In en, this message translates to:
/// **'No labels yet.'**
String get labelsNone;
/// No description provided for @labelsNoneSet.
///
/// In en, this message translates to:
/// **'No labels'**
String get labelsNoneSet;
/// No description provided for @labelsCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 label} other{{count} labels}}'**
String labelsCount(int count);
/// No description provided for @labelAdd.
///
/// In en, this message translates to:
/// **'New label'**
String get labelAdd;
/// No description provided for @labelEdit.
///
/// In en, this message translates to:
/// **'Edit label'**
String get labelEdit;
/// No description provided for @labelDelete.
///
/// In en, this message translates to:
/// **'Delete label'**
String get labelDelete;
/// No description provided for @labelDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the label?'**
String get labelDeleteTitle;
/// No description provided for @labelDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{name}\" is taken off every task that has it.'**
String labelDeleteMessage(String name);
/// No description provided for @nameLabel.
///
/// In en, this message translates to:
/// **'Name'**
String get nameLabel;
/// No description provided for @save.
///
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @overdue.
///
/// In en, this message translates to:
/// **'Overdue'**
String get overdue;
/// No description provided for @reschedule.
///
/// In en, this message translates to:
/// **'Reschedule'**
String get reschedule;
/// No description provided for @priorityTitle.
///
/// In en, this message translates to:
/// **'Priority'**
String get priorityTitle;
/// No description provided for @priority1.
///
/// In en, this message translates to:
/// **'Priority 1'**
String get priority1;
/// No description provided for @priority2.
///
/// In en, this message translates to:
/// **'Priority 2'**
String get priority2;
/// No description provided for @priority3.
///
/// In en, this message translates to:
/// **'Priority 3'**
String get priority3;
/// No description provided for @priority4.
///
/// In en, this message translates to:
/// **'No priority'**
String get priority4;
/// No description provided for @projectsTitle.
///
/// In en, this message translates to:
/// **'Projects'**
String get projectsTitle;
/// No description provided for @projectsNone.
///
/// In en, this message translates to:
/// **'No projects of your own yet.'**
String get projectsNone;
/// No description provided for @projectAdd.
///
/// In en, this message translates to:
/// **'New project'**
String get projectAdd;
/// No description provided for @projectEdit.
///
/// In en, this message translates to:
/// **'Edit project'**
String get projectEdit;
/// No description provided for @projectDelete.
///
/// In en, this message translates to:
/// **'Delete project'**
String get projectDelete;
/// No description provided for @projectDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the project?'**
String get projectDeleteTitle;
/// No description provided for @projectDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
String projectDeleteMessage(String name);
/// No description provided for @projectEmptyTitle.
///
/// In en, this message translates to:
/// **'Nothing to do here'**
String get projectEmptyTitle;
/// No description provided for @projectEmptyHint.
///
/// In en, this message translates to:
/// **'Add a task with the + button.'**
String get projectEmptyHint;
/// No description provided for @projectGone.
///
/// In en, this message translates to:
/// **'This project was deleted.'**
String get projectGone;
/// No description provided for @projectMenu.
///
/// In en, this message translates to:
/// **'More'**
String get projectMenu;
/// No description provided for @projectPickTitle.
///
/// In en, this message translates to:
/// **'Project'**
String get projectPickTitle;
/// No description provided for @sectionAdd.
///
/// In en, this message translates to:
/// **'New section'**
String get sectionAdd;
/// No description provided for @sectionEdit.
///
/// In en, this message translates to:
/// **'Rename section'**
String get sectionEdit;
/// No description provided for @sectionDelete.
///
/// In en, this message translates to:
/// **'Delete section'**
String get sectionDelete;
/// No description provided for @sectionDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the section?'**
String get sectionDeleteTitle;
/// No description provided for @sectionDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{name}\" is deleted with its tasks.'**
String sectionDeleteMessage(String name);
/// No description provided for @sectionMenu.
///
/// In en, this message translates to:
/// **'Section'**
String get sectionMenu;
/// No description provided for @subTasks.
///
/// In en, this message translates to:
/// **'Sub-tasks'**
String get subTasks;
/// No description provided for @subTasksDone.
///
/// In en, this message translates to:
/// **'Sub-tasks {done}/{count}'**
String subTasksDone(int done, int count);
/// No description provided for @subTaskAdd.
///
/// In en, this message translates to:
/// **'Add sub-task'**
String get subTaskAdd;
/// No description provided for @taskTitle.
///
/// In en, this message translates to:
/// **'Task'**
String get taskTitle;
/// No description provided for @taskAddTitle.
///
/// In en, this message translates to:
/// **'Add task'**
String get taskAddTitle;
/// No description provided for @taskAdd.
///
/// In en, this message translates to:
/// **'Add'**
String get taskAdd;
/// No description provided for @taskAddOn.
///
/// In en, this message translates to:
/// **'Add a task on {day}'**
String taskAddOn(String day);
/// No description provided for @taskTitleHint.
///
/// In en, this message translates to:
/// **'What\'s to be done?'**
String get taskTitleHint;
/// No description provided for @taskDescription.
///
/// In en, this message translates to:
/// **'Description'**
String get taskDescription;
/// No description provided for @taskDescriptionHint.
///
/// In en, this message translates to:
/// **'Description'**
String get taskDescriptionHint;
/// No description provided for @taskComplete.
///
/// In en, this message translates to:
/// **'Mark as done'**
String get taskComplete;
/// No description provided for @taskReopen.
///
/// In en, this message translates to:
/// **'Mark as not done'**
String get taskReopen;
/// No description provided for @taskCompleted.
///
/// In en, this message translates to:
/// **'Task done'**
String get taskCompleted;
/// No description provided for @taskDelete.
///
/// In en, this message translates to:
/// **'Delete task'**
String get taskDelete;
/// No description provided for @taskDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the task?'**
String get taskDeleteTitle;
/// No description provided for @taskDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{title}\" is deleted on all your devices.'**
String taskDeleteMessage(String title);
/// No description provided for @taskDeleteWithSubTasks.
///
/// In en, this message translates to:
/// **'\"{title}\" is deleted with its {count, plural, =1{sub-task} other{{count} sub-tasks}}, on all your devices.'**
String taskDeleteWithSubTasks(String title, int count);
/// No description provided for @taskGone.
///
/// In en, this message translates to:
/// **'This task was deleted.'**
String get taskGone;
/// No description provided for @todayEmptyTitle.
///
/// In en, this message translates to:
/// **'Nothing due today'**
String get todayEmptyTitle;
/// No description provided for @todayEmptyHint.
///
/// In en, this message translates to:
/// **'Tasks dated today, and those overdue, show here.'**
String get todayEmptyHint;
/// No description provided for @lockedRows.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 item doesn\'t open on this device.} other{{count} items don\'t open on this device.}}'**
String lockedRows(int count);
/// No description provided for @inboxMissingTitle.
///
/// In en, this message translates to:
/// **'No Inbox yet'**
String get inboxMissingTitle;
/// No description provided for @inboxMissingHint.
///
/// In en, this message translates to:
/// **'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'**
String get inboxMissingHint;
}
class _AppLocalizationsDelegate

View file

@ -41,22 +41,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get shellTabBrowse => 'Übersicht';
@override
String get todayLaterTitle => 'Hier stehen die Aufgaben von heute';
@override
String get upcomingLaterTitle => 'Hier stehen die nächsten Tage';
@override
String get inboxLaterTitle => 'Hier stehen Aufgaben ohne Projekt';
@override
String get browseLaterTitle => 'Hier stehen deine Projekte und Labels';
@override
String get tasksLaterHint =>
'Aufgaben kommen mit einer späteren Version von Halkyon Todo.';
@override
String get settingsTitle => 'Einstellungen';
@ -64,4 +48,278 @@ class AppLocalizationsDe extends AppLocalizations {
String settingsVersion(String version, String build) {
return 'Version $version (Build $build)';
}
@override
String get clear => 'Entfernen';
@override
String get colorLabel => 'Farbe';
@override
String get completedShow => 'Erledigte zeigen';
@override
String get completedHide => 'Erledigte ausblenden';
@override
String get dateTitle => 'Datum';
@override
String get dateNextWeek => 'Nächste Woche';
@override
String get dateWeekend => 'Dieses Wochenende';
@override
String get datePick => 'Tag wählen';
@override
String get dateTime => 'Uhrzeit';
@override
String get dateNone => 'Kein Datum';
@override
String get dateNoneYet => 'Kein Datum';
@override
String get dayToday => 'Heute';
@override
String get dayTomorrow => 'Morgen';
@override
String get dayYesterday => 'Gestern';
@override
String dayHeadingNamed(String date, String name) {
return '$date · $name';
}
@override
String deadlineOn(String day) {
return 'Frist $day';
}
@override
String get deadlineNone => 'Keine Frist';
@override
String get keysMissing =>
'Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.';
@override
String get labelsTitle => 'Labels';
@override
String get labelsNone => 'Noch keine Labels.';
@override
String get labelsNoneSet => 'Keine Labels';
@override
String labelsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Labels',
one: '1 Label',
);
return '$_temp0';
}
@override
String get labelAdd => 'Neues Label';
@override
String get labelEdit => 'Label bearbeiten';
@override
String get labelDelete => 'Label löschen';
@override
String get labelDeleteTitle => 'Label löschen?';
@override
String labelDeleteMessage(String name) {
return '„$name“ wird von jeder Aufgabe entfernt, die es hat.';
}
@override
String get nameLabel => 'Name';
@override
String get save => 'Speichern';
@override
String get overdue => 'Überfällig';
@override
String get reschedule => 'Verschieben';
@override
String get priorityTitle => 'Priorität';
@override
String get priority1 => 'Priorität 1';
@override
String get priority2 => 'Priorität 2';
@override
String get priority3 => 'Priorität 3';
@override
String get priority4 => 'Keine Priorität';
@override
String get projectsTitle => 'Projekte';
@override
String get projectsNone => 'Noch keine eigenen Projekte.';
@override
String get projectAdd => 'Neues Projekt';
@override
String get projectEdit => 'Projekt bearbeiten';
@override
String get projectDelete => 'Projekt löschen';
@override
String get projectDeleteTitle => 'Projekt löschen?';
@override
String projectDeleteMessage(String name) {
return '„$name“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
}
@override
String get projectEmptyTitle => 'Hier ist nichts zu tun';
@override
String get projectEmptyHint => 'Füge mit dem Plus eine Aufgabe hinzu.';
@override
String get projectGone => 'Dieses Projekt wurde gelöscht.';
@override
String get projectMenu => 'Mehr';
@override
String get projectPickTitle => 'Projekt';
@override
String get sectionAdd => 'Neuer Abschnitt';
@override
String get sectionEdit => 'Abschnitt umbenennen';
@override
String get sectionDelete => 'Abschnitt löschen';
@override
String get sectionDeleteTitle => 'Abschnitt löschen?';
@override
String sectionDeleteMessage(String name) {
return '„$name“ wird mit seinen Aufgaben gelöscht.';
}
@override
String get sectionMenu => 'Abschnitt';
@override
String get subTasks => 'Unteraufgaben';
@override
String subTasksDone(int done, int count) {
return 'Unteraufgaben $done/$count';
}
@override
String get subTaskAdd => 'Unteraufgabe hinzufügen';
@override
String get taskTitle => 'Aufgabe';
@override
String get taskAddTitle => 'Aufgabe hinzufügen';
@override
String get taskAdd => 'Hinzufügen';
@override
String taskAddOn(String day) {
return 'Aufgabe für $day hinzufügen';
}
@override
String get taskTitleHint => 'Was ist zu tun?';
@override
String get taskDescription => 'Beschreibung';
@override
String get taskDescriptionHint => 'Beschreibung';
@override
String get taskComplete => 'Als erledigt markieren';
@override
String get taskReopen => 'Als unerledigt markieren';
@override
String get taskCompleted => 'Aufgabe erledigt';
@override
String get taskDelete => 'Aufgabe löschen';
@override
String get taskDeleteTitle => 'Aufgabe löschen?';
@override
String taskDeleteMessage(String title) {
return '„$title“ wird auf all deinen Geräten gelöscht.';
}
@override
String taskDeleteWithSubTasks(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'seinen $count Unteraufgaben',
one: 'seiner Unteraufgabe',
);
return '„$title“ wird mit $_temp0 auf all deinen Geräten gelöscht.';
}
@override
String get taskGone => 'Diese Aufgabe wurde gelöscht.';
@override
String get todayEmptyTitle => 'Heute ist nichts fällig';
@override
String get todayEmptyHint =>
'Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.';
@override
String lockedRows(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Einträge lassen sich auf diesem Gerät nicht öffnen.',
one: '1 Eintrag lässt sich auf diesem Gerät nicht öffnen.',
);
return '$_temp0';
}
@override
String get inboxMissingTitle => 'Noch kein Eingang';
@override
String get inboxMissingHint =>
'Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.';
}

View file

@ -41,22 +41,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get shellTabBrowse => 'Browse';
@override
String get todayLaterTitle => 'Today\'s tasks come here';
@override
String get upcomingLaterTitle => 'The days ahead come here';
@override
String get inboxLaterTitle => 'Tasks without a project come here';
@override
String get browseLaterTitle => 'Your projects and labels come here';
@override
String get tasksLaterHint =>
'Tasks come with a later version of Halkyon Todo.';
@override
String get settingsTitle => 'Settings';
@ -64,6 +48,280 @@ class AppLocalizationsEn extends AppLocalizations {
String settingsVersion(String version, String build) {
return 'Version $version (build $build)';
}
@override
String get clear => 'Clear';
@override
String get colorLabel => 'Colour';
@override
String get completedShow => 'Show done tasks';
@override
String get completedHide => 'Hide done tasks';
@override
String get dateTitle => 'Date';
@override
String get dateNextWeek => 'Next week';
@override
String get dateWeekend => 'This weekend';
@override
String get datePick => 'Pick a day';
@override
String get dateTime => 'Time';
@override
String get dateNone => 'No date';
@override
String get dateNoneYet => 'No date';
@override
String get dayToday => 'Today';
@override
String get dayTomorrow => 'Tomorrow';
@override
String get dayYesterday => 'Yesterday';
@override
String dayHeadingNamed(String date, String name) {
return '$date · $name';
}
@override
String deadlineOn(String day) {
return 'Deadline $day';
}
@override
String get deadlineNone => 'No deadline';
@override
String get keysMissing =>
'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.';
@override
String get labelsTitle => 'Labels';
@override
String get labelsNone => 'No labels yet.';
@override
String get labelsNoneSet => 'No labels';
@override
String labelsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count labels',
one: '1 label',
);
return '$_temp0';
}
@override
String get labelAdd => 'New label';
@override
String get labelEdit => 'Edit label';
@override
String get labelDelete => 'Delete label';
@override
String get labelDeleteTitle => 'Delete the label?';
@override
String labelDeleteMessage(String name) {
return '\"$name\" is taken off every task that has it.';
}
@override
String get nameLabel => 'Name';
@override
String get save => 'Save';
@override
String get overdue => 'Overdue';
@override
String get reschedule => 'Reschedule';
@override
String get priorityTitle => 'Priority';
@override
String get priority1 => 'Priority 1';
@override
String get priority2 => 'Priority 2';
@override
String get priority3 => 'Priority 3';
@override
String get priority4 => 'No priority';
@override
String get projectsTitle => 'Projects';
@override
String get projectsNone => 'No projects of your own yet.';
@override
String get projectAdd => 'New project';
@override
String get projectEdit => 'Edit project';
@override
String get projectDelete => 'Delete project';
@override
String get projectDeleteTitle => 'Delete the project?';
@override
String projectDeleteMessage(String name) {
return '\"$name\" is deleted with its sections and tasks, on all your devices.';
}
@override
String get projectEmptyTitle => 'Nothing to do here';
@override
String get projectEmptyHint => 'Add a task with the + button.';
@override
String get projectGone => 'This project was deleted.';
@override
String get projectMenu => 'More';
@override
String get projectPickTitle => 'Project';
@override
String get sectionAdd => 'New section';
@override
String get sectionEdit => 'Rename section';
@override
String get sectionDelete => 'Delete section';
@override
String get sectionDeleteTitle => 'Delete the section?';
@override
String sectionDeleteMessage(String name) {
return '\"$name\" is deleted with its tasks.';
}
@override
String get sectionMenu => 'Section';
@override
String get subTasks => 'Sub-tasks';
@override
String subTasksDone(int done, int count) {
return 'Sub-tasks $done/$count';
}
@override
String get subTaskAdd => 'Add sub-task';
@override
String get taskTitle => 'Task';
@override
String get taskAddTitle => 'Add task';
@override
String get taskAdd => 'Add';
@override
String taskAddOn(String day) {
return 'Add a task on $day';
}
@override
String get taskTitleHint => 'What\'s to be done?';
@override
String get taskDescription => 'Description';
@override
String get taskDescriptionHint => 'Description';
@override
String get taskComplete => 'Mark as done';
@override
String get taskReopen => 'Mark as not done';
@override
String get taskCompleted => 'Task done';
@override
String get taskDelete => 'Delete task';
@override
String get taskDeleteTitle => 'Delete the task?';
@override
String taskDeleteMessage(String title) {
return '\"$title\" is deleted on all your devices.';
}
@override
String taskDeleteWithSubTasks(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count sub-tasks',
one: 'sub-task',
);
return '\"$title\" is deleted with its $_temp0, on all your devices.';
}
@override
String get taskGone => 'This task was deleted.';
@override
String get todayEmptyTitle => 'Nothing due today';
@override
String get todayEmptyHint =>
'Tasks dated today, and those overdue, show here.';
@override
String lockedRows(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count items don\'t open on this device.',
one: '1 item doesn\'t open on this device.',
);
return '$_temp0';
}
@override
String get inboxMissingTitle => 'No Inbox yet';
@override
String get inboxMissingHint =>
'Your Inbox comes with the next sync, or once this device holds your account\'s keys.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -5,6 +5,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_flutter/src/app/todo_router.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -90,6 +91,12 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
),
// The person's projects, tasks and labels, for every tab and
// sheet.
BlocProvider(
create: (context) =>
TodoCubit(PersonScope.of<TodoPerson>(context))..open(),
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>

View file

@ -13,6 +13,9 @@ abstract final class TodoPaths() {
/// Browse: the projects and labels, and the cog to Settings.
static const browse = '/browse';
/// A project's screen, in Browse.
static String project(String id) => '/browse/projects/$id';
/// Settings, over the tabs.
static const settings = '/settings';
}

View file

@ -1,5 +1,6 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_screen.dart';
import 'package:halkyon_todo_flutter/src/settings/settings_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/inbox_tab.dart';
@ -49,6 +50,13 @@ GoRouter buildTodoRouter() => GoRouter(
GoRoute(
path: TodoPaths.browse,
builder: (context, state) => const BrowseTab(),
routes: [
GoRoute(
path: 'projects/:id',
builder: (context, state) =>
ProjectScreen(projectId: state.pathParameters['id']!),
),
],
),
],
),

View file

@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The schema of a label's payload this app writes and reads.
const labelSchemaVersion = 1;
@ -21,7 +21,7 @@ LabelContent? decodeLabel(ByteData payload) => decodeJson(
name: 'Label',
read: (json) => LabelContent(
name: json['name']! as String,
color: paletteColorNamed(json['color'] as String?),
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);

View file

@ -24,11 +24,7 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
name: 'Project',
read: (json) => ProjectContent(
name: json['name']! as String,
color: paletteColorNamed(json['color'] as String?),
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);
/// The palette's colour named [name]; none for a name it doesn't know.
PaletteColor? paletteColorNamed(String? name) =>
PaletteColor.values.where((color) => color.name == name).firstOrNull;

View file

@ -0,0 +1,136 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a project's name and colour (ADR 0033, D9), [current]'s to
/// change, or a new one's; null where the person changed their mind.
Future<ProjectContent?> editProject(
BuildContext context, {
ProjectContent? current,
}) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current == null ? l10n.projectAdd : l10n.projectEdit,
name: current?.name ?? '',
color: current?.color ?? PaletteColor.cobalt,
);
return switch (named) {
(:final name, :final color) => ProjectContent(
name: name,
color: color,
position: current?.position ?? 0,
),
null => null,
};
}
/// Asks for a label's name and colour, [current]'s to change, or a new
/// one's; null where the person changed their mind.
Future<LabelContent?> editLabel(
BuildContext context, {
LabelContent? current,
}) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current == null ? l10n.labelAdd : l10n.labelEdit,
name: current?.name ?? '',
color: current?.color ?? PaletteColor.carrot,
);
return switch (named) {
(:final name, :final color) => LabelContent(
name: name,
color: color,
position: current?.position ?? 0,
),
null => null,
};
}
/// Asks for a section's name, [current]'s to change; null where the person
/// changed their mind.
Future<SectionContent?> editSection(
BuildContext context,
SectionContent current,
) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current.name.isEmpty ? l10n.sectionAdd : l10n.sectionEdit,
name: current.name,
color: null,
);
return switch (named) {
(:final name, color: _) => current.copyWith(name: name),
null => null,
};
}
Future<({String name, PaletteColor? color})?> _nameAndColor(
BuildContext context, {
required String title,
required String name,
required PaletteColor? color,
}) {
final l10n = AppLocalizations.of(context);
return showDialog<({String name, PaletteColor? color})>(
context: context,
builder: (context) {
final controller = TextEditingController(text: name);
var chosen = color;
void done() {
final text = controller.text.trim();
if (text.isNotEmpty) {
Navigator.of(context).pop((name: text, color: chosen));
}
}
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(title),
// As wide as a dialog's field usually is: the colour picker
// takes the width it is given.
content: SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.nameLabel),
onSubmitted: (_) => done(),
),
if (chosen case final current?) ...[
const SizedBox(height: 16),
Text(
l10n.colorLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
PaletteColorPicker(
selected: current,
onChanged: (picked) => setState(() => chosen = picked),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(onPressed: done, child: Text(l10n.save)),
],
),
);
},
);
}

View file

@ -0,0 +1,140 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.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/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';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
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.
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;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final projectId = widget.projectId;
return TabScreen(
title: widget.title,
actions: [
if (projectId != null)
PopupMenuButton<VoidCallback>(
tooltip: l10n.projectMenu,
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => setState(() => _completed = !_completed),
child: Text(
_completed ? l10n.completedHide : l10n.completedShow,
),
),
PopupMenuItem(
value: () => addSection(context, projectId),
child: Text(l10n.sectionAdd),
),
...?widget.menu?.call(context),
],
),
],
floatingActionButton: projectId == null
? null
: AddTaskButton(onPressed: () => showQuickAddIn(context, projectId)),
body: Column(
children: [
const KeysResetBanner(),
Expanded(
child: switch (projectId) {
final id? => ProjectTasks(projectId: id, completed: _completed),
// No Inbox yet: made by the first device holding the
// account's keys, or still on its way here.
null
when context.select<TodoCubit, bool>(
(cubit) => cubit.state.loaded,
) =>
Center(
child: EmptyListMessage(
icon: LucideIcons.inbox,
title: l10n.inboxMissingTitle,
hint: l10n.inboxMissingHint,
),
),
null => const Center(child: AppLoadingIndicator()),
},
),
],
),
);
}
}
/// A project of the person's own, from Browse (ADR 0033, D2): its tasks as
/// the Inbox's, and in its menu editing its name and colour and deleting it
/// with its sections and tasks, which asks first. A project gone (deleted
/// on another device) goes back.
class const ProjectScreen({required final String projectId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final project = state.projectOf(projectId);
if (project == null) {
return TabScreen(
title: '',
body: Center(child: Text(state.loaded ? l10n.projectGone : '')),
);
}
final content = project.content!;
return ProjectScreenBody(
title: project.inbox ? l10n.shellTabInbox : content.name,
projectId: projectId,
menu: project.inbox
? null
: (context) => [
PopupMenuItem(
value: () async {
final todo = PersonScope.of<TodoPerson>(context);
if (await editProject(context, current: content)
case final edited?) {
await todo.projects.update(projectId, edited);
}
},
child: Text(l10n.projectEdit),
),
PopupMenuItem(
value: () async {
final todo = PersonScope.of<TodoPerson>(context);
final router = GoRouter.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.projectDeleteTitle,
message: l10n.projectDeleteMessage(content.name),
confirmLabel: l10n.projectDelete,
)) {
await todo.deleteProject(projectId);
router.pop();
}
},
child: Text(l10n.projectDelete),
),
],
);
}
}

View file

@ -0,0 +1,169 @@
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/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_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_tile.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
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.
class const ProjectTasks({
required final String projectId,
final bool completed = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final loose = tasksIn(state.tasks, projectId, completed: completed);
final sections = state.sectionsOf(projectId);
if (loose.isEmpty &&
sections.isEmpty &&
!state.tasks.any((t) => t.content.projectId == projectId)) {
return Center(
child: EmptyListMessage(
icon: LucideIcons.listTodo,
title: l10n.projectEmptyTitle,
hint: l10n.projectEmptyHint,
),
);
}
return ListView(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
children: [
for (final task in loose) TaskTile(task: task),
for (final section in sections) ..._section(context, state, section),
],
);
}
List<Widget> _section(
BuildContext context,
TodoState state,
StoredSection section,
) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = section.content!;
final tasks = tasksIn(
state.tasks,
projectId,
sectionId: section.id,
completed: completed,
);
return [
const Divider(height: 24),
ListTile(
title: Text(
'${content.name} ${tasks.where((t) => !t.content.completed).length}',
style: theme.textTheme.titleMedium,
),
subtitle: content.description.isEmpty
? null
: Text(
content.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: PopupMenuButton<_SectionAction>(
tooltip: l10n.sectionMenu,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) async {
final todo = PersonScope.of<TodoPerson>(context);
switch (action) {
case _SectionAction.add:
await showQuickAdd(
context,
projectId: projectId,
sectionId: section.id,
);
case _SectionAction.edit:
if (await editSection(context, content) case final edited?) {
await todo.sections.update(section.id, edited);
}
case _SectionAction.delete:
if (await confirmDestructiveAction(
context,
title: l10n.sectionDeleteTitle,
message: l10n.sectionDeleteMessage(content.name),
confirmLabel: l10n.sectionDelete,
)) {
await todo.deleteSection(projectId, section.id);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _SectionAction.add,
child: Text(l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: Text(l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: Text(l10n.sectionDelete),
),
],
),
),
for (final task in tasks) TaskTile(task: task),
];
}
}
enum _SectionAction() {
add,
edit,
delete,
}
/// Asks for a new section's name and adds it last in [projectId].
Future<void> addSection(BuildContext context, String projectId) async {
final todo = PersonScope.of<TodoPerson>(context);
final sections = context.read<TodoCubit>().state.sectionsOf(projectId);
final added = await editSection(
context,
SectionContent(
name: '',
projectId: projectId,
position: sections.isEmpty ? 0 : sections.last.content!.position + 1,
),
);
if (added != null) {
await todo.sections.add(added);
}
}
/// Quick add into [projectId], above its sections.
Future<void> showQuickAddIn(BuildContext context, String projectId) =>
showQuickAdd(context, projectId: projectId);
/// The button adding a task, above the navigation bar (ADR 0033, D2).
class const AddTaskButton({required final VoidCallback onPressed, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => FloatingActionButton(
// Every tab keeps its own, alive beside the others: no hero flies.
heroTag: null,
tooltip: AppLocalizations.of(context).taskAddTitle,
onPressed: onPressed,
child: const Icon(LucideIcons.plus),
);
}

View file

@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// Everything of the person's that Todo's screens show, as it stands on
/// this device (ADR 0033, D8): their projects (the Inbox first), sections,
/// tasks and labels, those that open; and how many rows of each kind don't.
final class const TodoState({
final bool loaded = false,
final List<StoredProject> projects = const [],
final List<StoredSection> sections = const [],
final List<OpenTask> tasks = const [],
final List<StoredLabel> labels = const [],
/// How many rows don't open on this device: another device of the
/// person's wrote them before this one held the keys, or the instance
/// made them up.
final int locked = 0,
}) with Equatable {
/// The Inbox's ID; null before the Inbox is there.
String? get inboxId => projects
.where((project) => project.inbox)
.map((project) => project.id)
.firstOrNull;
/// The project [id], opened; null for one gone or locked.
StoredProject? projectOf(String id) => projects
.where((project) => project.id == id && project.content != null)
.firstOrNull;
/// The sections of the project [projectId] that open, in their order.
List<StoredSection> sectionsOf(String projectId) => [
for (final section in sections)
if (section.content?.projectId == projectId) section,
]..sort((a, b) => a.content!.position.compareTo(b.content!.position));
/// The task [id]; null for one gone or locked.
OpenTask? taskOf(String id) =>
tasks.where((task) => task.id == id).firstOrNull;
@override
List<Object?> get props => [
loaded,
projects,
sections,
tasks,
labels,
locked,
];
}
/// Reads the person's data into [TodoState], and again whenever it changes
/// here or a sync brings changes from their other devices.
class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
this : super(const TodoState());
final _subscriptions = <StreamSubscription<void>>[];
late final _reads = CoalescingRunner(
_read,
onError: (error, stackTrace) =>
log("The person's tasks not read: $error", name: 'Todo'),
);
/// Reads it all, and follows every change.
void open() {
for (final changes in [
_todo.projects.changes,
_todo.sections.changes,
_todo.tasks.changes,
_todo.labels.changes,
]) {
_subscriptions.add(changes.listen((_) => unawaited(_reads.run())));
}
unawaited(_reads.run());
}
Future<void> _read() async {
final projects = await _todo.projects.all();
final sections = await _todo.sections.all();
final tasks = await _todo.tasks.all();
final labels = await _todo.labels.all();
if (isClosed) {
return;
}
emit(
TodoState(
loaded: true,
projects: projects,
sections: [
for (final section in sections)
if (section.content != null) section,
],
tasks: openTasks(tasks),
labels: [
for (final label in labels)
if (label.content != null) label,
],
locked: [
...projects.map((row) => row.content),
...sections.map((row) => row.content),
...tasks.map((row) => row.content),
...labels.map((row) => row.content),
].where((content) => content == null).length,
),
);
}
@override
Future<void> close() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await super.close();
}
}

View file

@ -0,0 +1,120 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A task that opens here: its ID and what it is.
typedef OpenTask = ({String id, TaskContent content});
/// The tasks of [all] that open here, locked ones left out.
List<OpenTask> openTasks(Iterable<StoredTask> all) => [
for (final task in all)
if (task.content case final content?) (id: task.id, content: content),
];
/// Orders tasks as a list shows them: by their position, ties by when they
/// were made.
int byPosition(OpenTask a, OpenTask b) {
final position = a.content.position.compareTo(b.content.position);
return position != 0 ? position : a.id.compareTo(b.id);
}
/// Orders tasks as a day shows them (ADR 0033, D8): those with a time first,
/// by it, then by priority, then by their position.
int byTime(OpenTask a, OpenTask b) {
final (at, bt) = (a.content.date?.time, b.content.date?.time);
if (at != bt) {
if (at == null) {
return 1;
}
if (bt == null) {
return -1;
}
return at.compareTo(bt);
}
final priority = a.content.priority.index.compareTo(b.content.priority.index);
return priority != 0 ? priority : byPosition(a, b);
}
/// The tasks of the project [projectId] directly in it (no sub-tasks), in
/// the section [sectionId] (none: above the sections), done ones only where
/// [completed], in their order.
List<OpenTask> tasksIn(
List<OpenTask> tasks,
String projectId, {
String? sectionId,
bool completed = false,
}) => [
for (final task in tasks)
if (task.content.projectId == projectId &&
task.content.parentId == null &&
task.content.sectionId == sectionId &&
(completed || !task.content.completed))
task,
]..sort(byPosition);
/// The sub-tasks of [parentId], in their order, done ones too.
List<OpenTask> subTasksOf(List<OpenTask> tasks, String parentId) => [
for (final task in tasks)
if (task.content.parentId == parentId) task,
]..sort(byPosition);
/// What Today shows on [today] (ADR 0033, D8): the `overdue` tasks not done,
/// dated before it, oldest first; and `due` ones, dated on it, done ones too
/// where [completed].
({List<OpenTask> overdue, List<OpenTask> due}) todayOf(
List<OpenTask> tasks,
Day today, {
bool completed = false,
}) {
final overdue = <OpenTask>[];
final due = <OpenTask>[];
for (final task in tasks) {
final day = task.content.date?.day;
if (day == null) {
continue;
}
if (day < today && !task.content.completed) {
overdue.add(task);
} else if (day == today && (completed || !task.content.completed)) {
due.add(task);
}
}
overdue.sort((a, b) {
final days = a.content.date!.day.compareTo(b.content.date!.day);
return days != 0 ? days : byTime(a, b);
});
return (overdue: overdue, due: due..sort(byTime));
}
/// What Upcoming shows from [today] on (ADR 0033, D8): the overdue tasks,
/// then each of the [days] days with its tasks (none for a day without),
/// done ones too where [completed].
({List<OpenTask> overdue, List<(Day, List<OpenTask>)> days}) upcomingOf(
List<OpenTask> tasks,
Day today, {
int days = 14,
bool completed = false,
}) {
final byDay = <Day, List<OpenTask>>{
for (var i = 0; i < days; i++) today.plus(i): [],
};
for (final task in tasks) {
if (task.content.date?.day case final day?
when byDay.containsKey(day) && (completed || !task.content.completed)) {
byDay[day]!.add(task);
}
}
return (
overdue: todayOf(tasks, today).overdue,
days: [
for (final MapEntry(:key, :value) in byDay.entries)
(key, value..sort(byTime)),
],
);
}
/// How many of [tasks] in the project [projectId] are still to be done,
/// sub-tasks counted.
int openCountIn(List<OpenTask> tasks, String projectId) => tasks
.where(
(task) => task.content.projectId == projectId && !task.content.completed,
)
.length;

View file

@ -0,0 +1,133 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A date chosen ([TaskDate] or none, to clear it); null from [pickTaskDate]
/// where the person changed their mind.
typedef DateChoice = ({TaskDate? date});
/// Asks for a task's date (ADR 0033, D8): today, tomorrow, the coming
/// weekend, next week (the day the language starts it on), a day from the
/// calendar, with a time where the person adds one; or none, where
/// [current] has one.
Future<DateChoice?> pickTaskDate(
BuildContext context, {
TaskDate? current,
}) => showAdaptiveSheet<DateChoice>(
context,
builder: (context) => _DateSheet(current: current),
);
/// Asks for a deadline, a day alone: none to clear it, null where the
/// person changed their mind.
Future<({Day? day})?> pickDeadline(BuildContext context, {Day? current}) async {
final today = Day.of(clock.now());
final picked = await showDatePicker(
context: context,
initialDate: (current ?? today).toDate(),
firstDate: DateTime(today.year - 5),
lastDate: DateTime(today.year + 20),
);
return picked == null ? null : (day: Day.of(picked));
}
class const _DateSheet({required final TaskDate? current})
extends StatefulWidget {
@override
State<_DateSheet> createState() => _DateSheetState();
}
class _DateSheetState() extends State<_DateSheet> {
late int? _time = widget.current?.time;
late final Duration? _duration = widget.current?.duration;
void _choose(Day day) => Navigator.of(
context,
).pop<DateChoice>((date: TaskDate(day, time: _time, duration: _duration)));
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final today = Day.of(clock.now());
final firstDay = MaterialLocalizations.of(context).firstDayOfWeekIndex;
// DateTime's weekdays run 1 (Monday) to 7, Material's 0 (Sunday) to 6.
int until(int weekday) => (weekday - today.toDate().weekday + 7) % 7;
final nextWeek = today.plus(
switch (until(firstDay == 0 ? 7 : firstDay)) {
0 => 7,
final days => days,
},
);
final weekend = today.plus(until(DateTime.saturday));
Widget option(IconData icon, String label, Day day) => ListTile(
leading: Icon(icon),
title: Text(label),
trailing: Text(dayText(context, day, today)),
onTap: () => _choose(day),
);
return SheetScaffold(
title: l10n.dateTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
option(LucideIcons.calendarCheck, l10n.dayToday, today),
option(LucideIcons.sun, l10n.dayTomorrow, today.plus(1)),
if (weekend != today && weekend != today.plus(1))
option(LucideIcons.sofa, l10n.dateWeekend, weekend),
option(LucideIcons.calendarArrowUp, l10n.dateNextWeek, nextWeek),
ListTile(
leading: const Icon(LucideIcons.calendarDays),
title: Text(l10n.datePick),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: (widget.current?.day ?? today).toDate(),
firstDate: DateTime(today.year - 5),
lastDate: DateTime(today.year + 20),
);
if (picked != null && context.mounted) {
_choose(Day.of(picked));
}
},
),
ListTile(
leading: const Icon(LucideIcons.clock),
title: Text(l10n.dateTime),
trailing: switch (_time) {
final time? => InputChip(
label: Text(timeText(context, time)),
onDeleted: () => setState(() => _time = null),
),
null => null,
},
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: switch (_time) {
final time? => TimeOfDay(
hour: time ~/ 60,
minute: time % 60,
),
null => const TimeOfDay(hour: 9, minute: 0),
},
);
if (picked != null) {
setState(() => _time = picked.hour * 60 + picked.minute);
}
},
),
if (widget.current != null)
ListTile(
leading: const Icon(LucideIcons.calendarX),
title: Text(l10n.dateNone),
onTap: () => Navigator.of(context).pop<DateChoice>((date: null)),
),
],
),
);
}
}

View file

@ -0,0 +1,253 @@
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/privacy/todo_keys.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/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Quick add (ADR 0033, D8): a task's title, and chips for its date,
/// priority, project and labels, and a description where asked for. It
/// goes into [projectId] (the Inbox where none is given), [sectionId] and
/// under [parentId] where given, dated [day] where given, last among its
/// siblings. The sheet stays for the next task; going back closes it.
Future<void> showQuickAdd(
BuildContext context, {
String? projectId,
String? sectionId,
String? parentId,
Day? day,
}) => showAdaptiveSheet<void>(
context,
builder: (context) => _QuickAdd(
projectId: projectId ?? context.read<TodoCubit>().state.inboxId,
sectionId: sectionId,
parentId: parentId,
day: day,
),
);
class const _QuickAdd({
required final String? projectId,
required final String? sectionId,
required final String? parentId,
required final Day? day,
}) extends StatefulWidget {
@override
State<_QuickAdd> createState() => _QuickAddState();
}
class _QuickAddState() extends State<_QuickAdd> {
final _title = TextEditingController();
final _description = TextEditingController();
final _focus = FocusNode();
late TaskDate? _date = switch (widget.day) {
final day? => TaskDate(day),
null => null,
};
var _priority = Priority.none;
late String? _projectId = widget.projectId;
late String? _sectionId = widget.sectionId;
var _labelIds = <String>[];
var _describing = false;
@override
void dispose() {
_title.dispose();
_description.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _add() async {
final l10n = AppLocalizations.of(context);
final title = _title.text.trim();
final projectId = _projectId;
if (title.isEmpty || projectId == null) {
return;
}
final state = context.read<TodoCubit>().state;
final siblings = state.tasks.where(
(task) =>
task.content.projectId == projectId &&
task.content.sectionId == _sectionId &&
task.content.parentId == widget.parentId,
);
final last = siblings.fold<double>(
0,
(position, task) =>
task.content.position > position ? task.content.position : position,
);
try {
await PersonScope.of<TodoPerson>(context).tasks.add(
TaskContent(
title: title,
projectId: projectId,
sectionId: _sectionId,
parentId: widget.parentId,
description: _description.text.trim(),
position: siblings.isEmpty ? 0 : last + 1,
date: _date,
priority: _priority,
labelIds: _labelIds,
),
);
} on KeysMissing {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
return;
}
if (!mounted) {
return;
}
// Ready for the next one, in the same place and on the same day.
_title.clear();
_description.clear();
setState(() {
_priority = Priority.none;
_labelIds = [];
});
_focus.requestFocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final today = Day.of(clock.now());
final project = switch (_projectId) {
final id? => state.projectOf(id),
null => null,
};
return SheetScaffold(
title: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd,
hasSnackBars: true,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _title,
focusNode: _focus,
autofocus: true,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: l10n.taskTitleHint),
onSubmitted: (_) => _add(),
),
if (_describing)
TextField(
controller: _description,
minLines: 1,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: l10n.taskDescriptionHint),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
ActionChip(
avatar: Icon(
LucideIcons.calendar,
color: switch (_date) {
final date? => dateColor(context, date.day, today),
null => null,
},
),
label: Text(switch (_date) {
final date? => dateText(context, date, today),
null => l10n.dateTitle,
}),
onPressed: () async {
if (await pickTaskDate(context, current: _date) case (
:final date,
)) {
setState(() => _date = date);
}
},
),
ActionChip(
avatar: Icon(
LucideIcons.flag,
color: priorityColor(context, _priority),
),
label: Text(
_priority == Priority.none
? l10n.priorityTitle
: priorityText(l10n, _priority),
),
onPressed: () async {
if (await pickPriority(context, _priority)
case final priority?) {
setState(() => _priority = priority);
}
},
),
if (widget.parentId == null)
ActionChip(
avatar: Icon(
project?.inbox ?? true
? LucideIcons.inbox
: LucideIcons.hash,
),
label: Text(switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
}),
onPressed: () async {
if (await pickProject(context, _projectId) case final id?) {
setState(() {
if (id != _projectId) {
_sectionId = null;
}
_projectId = id;
});
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
_labelIds.isEmpty
? l10n.labelsTitle
: l10n.labelsCount(_labelIds.length),
),
onPressed: () async {
if (await pickLabels(context, _labelIds) case final labels?) {
setState(() => _labelIds = labels);
}
},
),
if (!_describing)
ActionChip(
avatar: const Icon(LucideIcons.text),
label: Text(l10n.taskDescription),
onPressed: () => setState(() => _describing = true),
),
],
),
],
),
footer: ListenableBuilder(
listenable: _title,
builder: (context, _) => FilledButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.taskAdd),
onPressed: _title.text.trim().isEmpty ? null : _add,
),
),
);
}
}

View file

@ -0,0 +1,114 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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_texts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a priority, [current] ticked; null where the person changed
/// their mind.
Future<Priority?> pickPriority(BuildContext context, Priority current) {
final l10n = AppLocalizations.of(context);
return showDialog<Priority>(
context: context,
builder: (context) => SimpleDialog(
title: Text(l10n.priorityTitle),
children: [
for (final priority in Priority.values)
ListTile(
leading: Icon(
LucideIcons.flag,
color: priorityColor(context, priority),
),
title: Text(priorityText(l10n, priority)),
trailing: priority == current
? const Icon(LucideIcons.check)
: null,
onTap: () => Navigator.of(context).pop(priority),
),
],
),
);
}
/// Asks for a project, the Inbox first, [current] ticked; its ID, or null
/// where the person changed their mind.
Future<String?> pickProject(BuildContext context, String? current) {
final l10n = AppLocalizations.of(context);
final state = context.read<TodoCubit>().state;
return showDialog<String>(
context: context,
builder: (context) => SimpleDialog(
title: Text(l10n.projectPickTitle),
children: [
for (final project in state.projects)
if (project.content case final content?)
ListTile(
leading: Icon(
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
color: switch (content.color) {
final color? => paletteText(context, color),
null => null,
},
),
title: Text(project.inbox ? l10n.shellTabInbox : content.name),
trailing: project.id == current
? const Icon(LucideIcons.check)
: null,
onTap: () => Navigator.of(context).pop(project.id),
),
],
),
);
}
/// Asks which labels a task has, [current] ticked; null where the person
/// changed their mind.
Future<List<String>?> pickLabels(BuildContext context, List<String> current) {
final l10n = AppLocalizations.of(context);
final labels = context.read<TodoCubit>().state.labels;
return showDialog<List<String>>(
context: context,
builder: (context) {
final chosen = {...current};
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(l10n.labelsTitle),
content: labels.isEmpty
? Text(l10n.labelsNone)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final label in labels)
CheckboxListTile(
value: chosen.contains(label.id),
title: Text(label.content!.name),
onChanged: (on) => setState(
() => on ?? false
? chosen.add(label.id)
: chosen.remove(label.id),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop([
for (final label in labels)
if (chosen.contains(label.id)) label.id,
]),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
},
);
}

View file

@ -0,0 +1,321 @@
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/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.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/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the task [id]'s sheet (ADR 0033, D8): its title and description to
/// edit, a row for each of its date, deadline, priority, project and labels,
/// its sub-tasks with one more to add, and deleting it, which asks first.
Future<void> openTaskSheet(BuildContext context, String id) =>
showAdaptiveSheet<void>(context, builder: (context) => _TaskSheet(id: id));
class const _TaskSheet({required final String id}) extends StatefulWidget {
@override
State<_TaskSheet> createState() => _TaskSheetState();
}
class _TaskSheetState() extends State<_TaskSheet> {
late final TextEditingController _title;
late final TextEditingController _description;
late final TaskRepository _tasks;
/// Both read in [initState], as [dispose] can't look them up any more.
late final TodoCubit _todo;
@override
void initState() {
super.initState();
_todo = context.read<TodoCubit>();
_tasks = PersonScope.of<TodoPerson>(context).tasks;
final content = _todo.state.taskOf(widget.id)?.content;
_title = TextEditingController(text: content?.title);
_description = TextEditingController(text: content?.description);
}
@override
void dispose() {
// What was typed is kept as the sheet closes.
unawaited(_saveText());
_title.dispose();
_description.dispose();
super.dispose();
}
TaskContent? get _content => _todo.state.taskOf(widget.id)?.content;
Future<void> _change(TaskContent Function(TaskContent content) change) async {
final content = _content;
if (content == null) {
return;
}
try {
await _tasks.update(widget.id, change(content));
} on KeysMissing {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).keysMissing)),
);
}
}
}
Future<void> _saveText() async {
final content = _content;
final title = _title.text.trim();
final description = _description.text.trim();
if (content == null ||
title.isEmpty ||
(title == content.title && description == content.description)) {
return;
}
try {
await _tasks.update(
widget.id,
content.copyWith(title: title, description: description),
);
} on KeysMissing {
// Nothing is written on a device without the keys.
}
}
/// Moves the task, with its sub-tasks, to the project [projectId].
Future<void> _move(String projectId) async {
final state = context.read<TodoCubit>().state;
await _change(
(content) =>
content.copyWith(projectId: projectId, sectionId: () => null),
);
final below = <OpenTask>[];
void collect(String id) {
for (final task in subTasksOf(state.tasks, id)) {
below.add(task);
collect(task.id);
}
}
collect(widget.id);
for (final task in below) {
await _tasks.update(
task.id,
task.content.copyWith(projectId: projectId, sectionId: () => null),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final task = state.taskOf(widget.id);
if (task == null) {
// Deleted here or on another device.
return SheetScaffold(
title: l10n.taskTitle,
body: Text(l10n.taskGone),
);
}
final content = task.content;
final today = Day.of(clock.now());
final project = state.projectOf(content.projectId);
final subTasks = subTasksOf(state.tasks, task.id);
final labels = {
for (final label in state.labels) label.id: label.content!.name,
};
final muted = theme.colorScheme.onSurfaceVariant;
Widget clear(VoidCallback onPressed) => IconButton(
tooltip: l10n.clear,
icon: const Icon(LucideIcons.x),
onPressed: onPressed,
);
return SheetScaffold(
title: switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
},
hasSnackBars: true,
action: IconButton(
tooltip: l10n.taskDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () async {
final sheet = Navigator.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.taskDeleteTitle,
message: subTasks.isEmpty
? l10n.taskDeleteMessage(content.title)
: l10n.taskDeleteWithSubTasks(content.title, subTasks.length),
confirmLabel: l10n.taskDelete,
)) {
await _tasks.delete(widget.id);
sheet.pop();
}
},
),
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
TaskCheck(task: task),
Expanded(
child: TextField(
controller: _title,
style: theme.textTheme.titleLarge,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
hintText: l10n.taskTitleHint,
),
onSubmitted: (_) => _saveText(),
onTapOutside: (_) => unawaited(_saveText()),
),
),
],
),
TextField(
controller: _description,
minLines: 1,
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
prefixIcon: const Icon(LucideIcons.text),
hintText: l10n.taskDescriptionHint,
),
onTapOutside: (_) => unawaited(_saveText()),
),
ListTile(
leading: Icon(
LucideIcons.calendar,
color: switch (content.date) {
final date? => dateColor(context, date.day, today),
null => null,
},
),
title: Text(switch (content.date) {
final date? => dateText(context, date, today),
null => l10n.dateNoneYet,
}),
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await _change((c) => c.copyWith(date: () => date));
}
},
),
ListTile(
leading: const Icon(LucideIcons.target),
title: Text(switch (content.deadline) {
final day? => l10n.deadlineOn(dayText(context, day, today)),
null => l10n.deadlineNone,
}),
trailing: content.deadline == null
? null
: clear(() => _change((c) => c.copyWith(deadline: () => null))),
onTap: () async {
if (await pickDeadline(context, current: content.deadline) case (
:final day,
)) {
await _change((c) => c.copyWith(deadline: () => day));
}
},
),
ListTile(
leading: Icon(
LucideIcons.flag,
color: priorityColor(context, content.priority),
),
title: Text(priorityText(l10n, content.priority)),
onTap: () async {
if (await pickPriority(context, content.priority)
case final priority?) {
await _change((c) => c.copyWith(priority: priority));
}
},
),
if (content.parentId == null)
ListTile(
leading: Icon(
project?.inbox ?? true ? LucideIcons.inbox : LucideIcons.hash,
),
title: Text(switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
}),
onTap: () async {
if (await pickProject(context, content.projectId) case final id?
when id != content.projectId) {
await _move(id);
}
},
),
ListTile(
leading: const Icon(LucideIcons.tag),
title: Text(
content.labelIds.isEmpty
? l10n.labelsNoneSet
: [
for (final id in content.labelIds) ?labels[id],
].join(', '),
),
onTap: () async {
if (await pickLabels(context, content.labelIds)
case final chosen?) {
await _change((c) => c.copyWith(labelIds: chosen));
}
},
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
subTasks.isEmpty
? l10n.subTasks
: l10n.subTasksDone(
subTasks.where((t) => t.content.completed).length,
subTasks.length,
),
style: theme.textTheme.titleSmall?.copyWith(color: muted),
),
),
for (final sub in subTasks) TaskTile(task: sub),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.subTaskAdd),
onPressed: () => showQuickAdd(
context,
projectId: content.projectId,
sectionId: content.sectionId,
parentId: task.id,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,88 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// [minutes] after midnight as the language writes a time of day.
String timeText(BuildContext context, int minutes) => DateFormat.Hm(
Localizations.localeOf(context).toString(),
).format(DateTime(2000, 1, 1, minutes ~/ 60, minutes % 60));
/// [day] as a task's row names it, seen on [today]: Today, Tomorrow,
/// Yesterday, the weekday within the coming week, else the day and month,
/// and the year where it isn't this one.
String dayText(BuildContext context, Day day, Day today) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final days = today.daysUntil(day);
return switch (days) {
0 => l10n.dayToday,
1 => l10n.dayTomorrow,
-1 => l10n.dayYesterday,
> 1 && < 7 => DateFormat.EEEE(locale).format(day.toDate()),
_ when day.year == today.year => DateFormat.MMMd(
locale,
).format(day.toDate()),
_ => DateFormat.yMMMd(locale).format(day.toDate()),
};
}
/// [date] as a task's row names it: its day, and its time or its time span.
String dateText(BuildContext context, TaskDate date, Day today) {
final day = dayText(context, date.day, today);
final time = date.time;
if (time == null) {
return day;
}
final start = timeText(context, time);
return switch (date.duration) {
final duration? =>
'$day $start–${timeText(context, (time + duration.inMinutes) % (24 * 60))}',
null => '$day $start',
};
}
/// [color] for text and icons on the screen's surface: its darker outline
/// in light mode, its fill in dark mode, so it reads on both.
Color paletteText(BuildContext context, PaletteColor color) =>
Theme.of(context).brightness == Brightness.light
? color.outline
: color.background;
/// The colour a task's date shows in (ADR 0033, D9): cerise overdue, fern
/// today, carrot tomorrow, purple in the coming week, the muted text colour
/// later, and muted for a task done.
Color dateColor(BuildContext context, Day day, Day today, {bool done = false}) {
final days = today.daysUntil(day);
final color = switch (days) {
_ when done => null,
< 0 => PaletteColor.cerise,
0 => PaletteColor.fern,
1 => PaletteColor.carrot,
< 7 => PaletteColor.purple,
_ => null,
};
return color == null
? Theme.of(context).colorScheme.onSurfaceVariant
: paletteText(context, color);
}
/// The colour of a task's priority (ADR 0033, D9): cerise, carrot, cobalt;
/// none for one not marked.
Color? priorityColor(BuildContext context, Priority priority) =>
switch (priority) {
Priority.p1 => paletteText(context, PaletteColor.cerise),
Priority.p2 => paletteText(context, PaletteColor.carrot),
Priority.p3 => paletteText(context, PaletteColor.slateblue),
Priority.none => null,
};
/// The name of [priority], as its menu lists it.
String priorityText(AppLocalizations l10n, Priority priority) =>
switch (priority) {
Priority.p1 => l10n.priority1,
Priority.p2 => l10n.priority2,
Priority.p3 => l10n.priority3,
Priority.none => l10n.priority4,
};

Some files were not shown because too many files have changed in this diff Show more