feat(todo): tasks chosen together, quick add reading what is typed (ADR 0033, D14, D15)

- A section's heading stays atop a project's list while its tasks
  scroll beneath it (slivers, PinnedHeaderSliver).
- Upcoming's month unfolds in place beneath its strip, from its name
  or its handle; a button beside the month goes back to today.
- Tasks are chosen together: a row swiped right or held, or Select
  tasks in Today's, Upcoming's and a project's menu; a bar of date,
  move, labels, priority and more (complete, duplicate, deadline,
  delete) takes the navigation bar's place. A row swiped left asks
  for its date.
- TaskRepository.move moves tasks with their sub-tasks.
- parseQuickAdd reads #project, @label (an unknown one made),
  {deadline}, !!1 and p1 to p4, !30m, dates, times and recurrences
  (tomorrow 9am, Mon 18:00, ev Tue 7pm, every 2 days, every weekday),
  from a table of words per language: English, and German beside it.
- Each token is highlighted and shown on its chip; a tap keeps it as
  text, and a chip picked takes it out of the title.
- Quick add shows its title alone at first, its chips once typing
  starts, and the description and deadline behind its + (the
  description also from the down arrow).

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 16:53:59 +01:00
commit 4960234a63
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 3619 additions and 383 deletions

View file

@ -1,23 +1,17 @@
{
"@@locale": "de",
"appTitle": "Halkyon Todo",
"launchProduct": "Todo",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Aufgaben liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über Halkyon Todo",
"shellTabToday": "Heute",
"shellTabUpcoming": "Demnächst",
"shellTabInbox": "Eingang",
"shellTabBrowse": "Übersicht",
"settingsTitle": "Einstellungen",
"settingsVersion": "Version {version} (Build {build})",
"clear": "Entfernen",
"colorLabel": "Farbe",
"completedShow": "Erledigte zeigen",
@ -145,5 +139,31 @@
"subTasksHideDone": "Erledigte Unteraufgaben ausblenden",
"subTasksShowDone": "Erledigte Unteraufgaben zeigen",
"taskClose": "Schließen",
"taskPath": "Wo die Aufgabe liegt"
"taskPath": "Wo die Aufgabe liegt",
"weekToday": "Zurück zu heute",
"monthPrevious": "Voriger Monat",
"monthNext": "Folgender Monat",
"monthShow": "Ganzen Monat zeigen",
"monthHide": "Eine Woche zeigen",
"selectTasks": "Aufgaben auswählen",
"selectionCount": "{count, plural, =0{Aufgaben auswählen} =1{1 Aufgabe ausgewählt} other{{count} Aufgaben ausgewählt}}",
"selectionEnd": "Auswahl beenden",
"selectionDate": "Datum",
"selectionMove": "Verschieben nach",
"selectionLabels": "Labels",
"selectionPriority": "Priorität",
"selectionMore": "Mehr",
"selectionDelete": "{count, plural, =1{1 Aufgabe löschen} other{{count} Aufgaben löschen}}",
"selectionDeleteTitle": "Die Aufgaben löschen?",
"selectionDeleteMessage": "{count, plural, =1{1 Aufgabe wird mit ihren Unteraufgaben auf all deinen Geräten gelöscht.} other{{count} Aufgaben werden mit ihren Unteraufgaben auf all deinen Geräten gelöscht.}}",
"selectionDuplicate": "{count, plural, =1{1 Aufgabe duplizieren} other{{count} Aufgaben duplizieren}}",
"selectionComplete": "{count, plural, =1{1 Aufgabe erledigen} other{{count} Aufgaben erledigen}}",
"selectionDeadline": "Frist",
"tasksCompleted": "{count, plural, =1{Aufgabe erledigt} other{{count} Aufgaben erledigt}}",
"swipeSelect": "Auswählen",
"swipeDate": "Datum",
"tasksMenu": "Mehr",
"quickAddMore": "Mehr",
"quickAddTokenHint": "Tippe ein hervorgehobenes Wort an, um es als Text zu behalten.",
"deadlineTitle": "Frist"
}

View file

@ -1,26 +1,33 @@
{
"@@locale": "en",
"appTitle": "Halkyon Todo",
"@appTitle": {"description": "The app's name: the window's title, and where halkyon_app's texts name the app."},
"@appTitle": {
"description": "The app's name: the window's title, and where halkyon_app's texts name the app."
},
"launchProduct": "Todo",
"@launchProduct": {"description": "The app's word in its wordmark, under \"Halkyon\"."},
"@launchProduct": {
"description": "The app's word in its wordmark, under \"Halkyon\"."
},
"instanceFirstStartSubtitle": "Choose the instance to keep your tasks on. Someone runs each one; you can add others later.",
"accountTitle": "Account",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About Halkyon Todo",
"shellTabToday": "Today",
"shellTabUpcoming": "Upcoming",
"shellTabInbox": "Inbox",
"shellTabBrowse": "Browse",
"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",
@ -36,22 +43,49 @@
"dayTomorrow": "Tomorrow",
"dayYesterday": "Yesterday",
"dayHeadingNamed": "{date} · {name}",
"@dayHeadingNamed": {"placeholders": {"date": {"type": "String"}, "name": {"type": "String"}}},
"@dayHeadingNamed": {
"placeholders": {
"date": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"deadlineOn": "Deadline {day}",
"@deadlineOn": {"placeholders": {"day": {"type": "String"}}},
"@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"}}},
"@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"}}},
"@labelDeleteMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"nameLabel": "Name",
"save": "Save",
"overdue": "Overdue",
@ -68,7 +102,13 @@
"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"}}},
"@projectDeleteMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"projectEmptyTitle": "Nothing to do here",
"projectEmptyHint": "Add a task with the + button.",
"projectGone": "This project was deleted.",
@ -79,17 +119,38 @@
"sectionDelete": "Delete section",
"sectionDeleteTitle": "Delete the section?",
"sectionDeleteMessage": "\"{name}\" is deleted with its tasks.",
"@sectionDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"@sectionDeleteMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"sectionMenu": "Section",
"subTasks": "Sub-tasks",
"subTasksDone": "Sub-tasks {done}/{count}",
"@subTasksDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
"@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"}}},
"@taskAddOn": {
"placeholders": {
"day": {
"type": "String"
}
}
},
"taskTitleHint": "What's to be done?",
"taskDescription": "Description",
"taskDescriptionHint": "Description",
@ -99,40 +160,133 @@
"taskDelete": "Delete task",
"taskDeleteTitle": "Delete the task?",
"taskDeleteMessage": "\"{title}\" is deleted on all your devices.",
"@taskDeleteMessage": {"placeholders": {"title": {"type": "String"}}},
"@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"}}},
"@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"}}},
"@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.",
"repeatTitle": "Repeat",
"repeatNone": "Doesn't repeat",
"repeatCustom": "Custom",
"repeatEveryDay": "{count, plural, =1{Every day} other{Every {count} days}}",
"@repeatEveryDay": {"placeholders": {"count": {"type": "int"}}},
"@repeatEveryDay": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"repeatEveryWeek": "{count, plural, =1{Every week} other{Every {count} weeks}}",
"@repeatEveryWeek": {"placeholders": {"count": {"type": "int"}}},
"@repeatEveryWeek": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"repeatEveryMonth": "{count, plural, =1{Every month} other{Every {count} months}}",
"@repeatEveryMonth": {"placeholders": {"count": {"type": "int"}}},
"@repeatEveryMonth": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"repeatEveryYear": "{count, plural, =1{Every year} other{Every {count} years}}",
"@repeatEveryYear": {"placeholders": {"count": {"type": "int"}}},
"@repeatEveryYear": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"repeatWeekdays": "Every weekday",
"repeatOn": "{rule} on {on}",
"@repeatOn": {"placeholders": {"rule": {"type": "String"}, "on": {"type": "String"}}},
"@repeatOn": {
"placeholders": {
"rule": {
"type": "String"
},
"on": {
"type": "String"
}
}
},
"repeatMonthDay": "day {day}",
"@repeatMonthDay": {"placeholders": {"day": {"type": "int"}}},
"@repeatMonthDay": {
"placeholders": {
"day": {
"type": "int"
}
}
},
"repeatMonthWeek": "the {nth, select, 1{first} 2{second} 3{third} 4{fourth} other{last}} {weekday}",
"@repeatMonthWeek": {"placeholders": {"nth": {"type": "String"}, "weekday": {"type": "String"}}},
"@repeatMonthWeek": {
"placeholders": {
"nth": {
"type": "String"
},
"weekday": {
"type": "String"
}
}
},
"repeatFromCompletion": "{rule}, from when it's done",
"@repeatFromCompletion": {"placeholders": {"rule": {"type": "String"}}},
"@repeatFromCompletion": {
"placeholders": {
"rule": {
"type": "String"
}
}
},
"repeatUntil": "{rule}, until {day}",
"@repeatUntil": {"placeholders": {"rule": {"type": "String"}, "day": {"type": "String"}}},
"@repeatUntil": {
"placeholders": {
"rule": {
"type": "String"
},
"day": {
"type": "String"
}
}
},
"repeatLeft": "{rule}, {count, plural, =0{this time only} =1{once more} other{{count} more times}}",
"@repeatLeft": {"placeholders": {"rule": {"type": "String"}, "count": {"type": "int"}}},
"@repeatLeft": {
"placeholders": {
"rule": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"repeatEvery": "Every",
"repeatUnitDays": "Days",
"repeatUnitWeeks": "Weeks",
@ -144,18 +298,48 @@
"repeatEndsNever": "Never",
"repeatEndsOn": "On a day",
"repeatEndsAfter": "{count, plural, =1{After once} other{After {count} times}}",
"@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}},
"@repeatEndsAfter": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"remindersTitle": "Reminders",
"remindersNone": "No reminders",
"reminderAtTime": "At its time",
"reminderMinutesBefore": "{count, plural, =1{1 minute before} other{{count} minutes before}}",
"@reminderMinutesBefore": {"placeholders": {"count": {"type": "int"}}},
"@reminderMinutesBefore": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reminderHoursBefore": "{count, plural, =1{1 hour before} other{{count} hours before}}",
"@reminderHoursBefore": {"placeholders": {"count": {"type": "int"}}},
"@reminderHoursBefore": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reminderDaysBefore": "{count, plural, =1{1 day before} other{{count} days before}}",
"@reminderDaysBefore": {"placeholders": {"count": {"type": "int"}}},
"@reminderDaysBefore": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reminderDue": "Due at {time}",
"@reminderDue": {"placeholders": {"time": {"type": "String"}}},
"@reminderDue": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"remindersChannel": "Task reminders",
"remindersNotAllowed": "Notifications are off for Halkyon Todo: reminders show once you allow them in the system's settings.",
"remindersOnThisDevice": "Reminders come on each of your devices that opens your tasks.",
@ -165,16 +349,105 @@
"searchHint": "Search tasks and projects",
"searchEmpty": "Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can't read your tasks.",
"searchNone": "Nothing found for “{query}”.",
"@searchNone": {"placeholders": {"query": {"type": "String"}}},
"@searchNone": {
"placeholders": {
"query": {
"type": "String"
}
}
},
"searchProjects": "Projects",
"searchTasks": "Tasks",
"taskMore": "Task options",
"taskAddedOn": "Added on {when}",
"@taskAddedOn": {"placeholders": {"when": {"type": "String"}}},
"@taskAddedOn": {
"placeholders": {
"when": {
"type": "String"
}
}
},
"taskDuplicate": "Duplicate",
"taskDuplicated": "Task duplicated",
"subTasksHideDone": "Hide done sub-tasks",
"subTasksShowDone": "Show done sub-tasks",
"taskClose": "Close",
"taskPath": "Where the task is"
"taskPath": "Where the task is",
"weekToday": "Back to today",
"monthPrevious": "Previous month",
"monthNext": "Following month",
"monthShow": "Show the whole month",
"monthHide": "Show one week",
"selectTasks": "Select tasks",
"selectionCount": "{count, plural, =0{Select tasks} =1{1 task selected} other{{count} tasks selected}}",
"@selectionCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionEnd": "Stop selecting",
"selectionDate": "Date",
"selectionMove": "Move to",
"selectionLabels": "Labels",
"selectionPriority": "Priority",
"selectionMore": "More",
"selectionDelete": "{count, plural, =1{Delete 1 task} other{Delete {count} tasks}}",
"@selectionDelete": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionDeleteTitle": "Delete the tasks?",
"selectionDeleteMessage": "{count, plural, =1{1 task is deleted with its sub-tasks, on all your devices.} other{{count} tasks are deleted with their sub-tasks, on all your devices.}}",
"@selectionDeleteMessage": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionDuplicate": "{count, plural, =1{Duplicate 1 task} other{Duplicate {count} tasks}}",
"@selectionDuplicate": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionComplete": "{count, plural, =1{Complete 1 task} other{Complete {count} tasks}}",
"@selectionComplete": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionDeadline": "Deadline",
"tasksCompleted": "{count, plural, =1{Task done} other{{count} tasks done}}",
"@tasksCompleted": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"swipeSelect": "Select",
"swipeDate": "Date",
"tasksMenu": "More",
"quickAddMore": "More",
"@quickAddMore": {
"description": "Quick add's + menu, holding Description and Deadline (ADR 0033, D15)."
},
"quickAddTokenHint": "Tap a highlighted word to keep it as text.",
"@quickAddTokenHint": {
"description": "Below quick add's title while it has tokens recognised (D15)."
},
"deadlineTitle": "Deadline",
"@deadlineTitle": {
"description": "Quick add's deadline chip and menu entry (D15)."
}
}

View file

@ -939,6 +939,162 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Where the task is'**
String get taskPath;
/// No description provided for @weekToday.
///
/// In en, this message translates to:
/// **'Back to today'**
String get weekToday;
/// No description provided for @monthPrevious.
///
/// In en, this message translates to:
/// **'Previous month'**
String get monthPrevious;
/// No description provided for @monthNext.
///
/// In en, this message translates to:
/// **'Following month'**
String get monthNext;
/// No description provided for @monthShow.
///
/// In en, this message translates to:
/// **'Show the whole month'**
String get monthShow;
/// No description provided for @monthHide.
///
/// In en, this message translates to:
/// **'Show one week'**
String get monthHide;
/// No description provided for @selectTasks.
///
/// In en, this message translates to:
/// **'Select tasks'**
String get selectTasks;
/// No description provided for @selectionCount.
///
/// In en, this message translates to:
/// **'{count, plural, =0{Select tasks} =1{1 task selected} other{{count} tasks selected}}'**
String selectionCount(int count);
/// No description provided for @selectionEnd.
///
/// In en, this message translates to:
/// **'Stop selecting'**
String get selectionEnd;
/// No description provided for @selectionDate.
///
/// In en, this message translates to:
/// **'Date'**
String get selectionDate;
/// No description provided for @selectionMove.
///
/// In en, this message translates to:
/// **'Move to'**
String get selectionMove;
/// No description provided for @selectionLabels.
///
/// In en, this message translates to:
/// **'Labels'**
String get selectionLabels;
/// No description provided for @selectionPriority.
///
/// In en, this message translates to:
/// **'Priority'**
String get selectionPriority;
/// No description provided for @selectionMore.
///
/// In en, this message translates to:
/// **'More'**
String get selectionMore;
/// No description provided for @selectionDelete.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Delete 1 task} other{Delete {count} tasks}}'**
String selectionDelete(int count);
/// No description provided for @selectionDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the tasks?'**
String get selectionDeleteTitle;
/// No description provided for @selectionDeleteMessage.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 task is deleted with its sub-tasks, on all your devices.} other{{count} tasks are deleted with their sub-tasks, on all your devices.}}'**
String selectionDeleteMessage(int count);
/// No description provided for @selectionDuplicate.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Duplicate 1 task} other{Duplicate {count} tasks}}'**
String selectionDuplicate(int count);
/// No description provided for @selectionComplete.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Complete 1 task} other{Complete {count} tasks}}'**
String selectionComplete(int count);
/// No description provided for @selectionDeadline.
///
/// In en, this message translates to:
/// **'Deadline'**
String get selectionDeadline;
/// No description provided for @tasksCompleted.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Task done} other{{count} tasks done}}'**
String tasksCompleted(int count);
/// No description provided for @swipeSelect.
///
/// In en, this message translates to:
/// **'Select'**
String get swipeSelect;
/// No description provided for @swipeDate.
///
/// In en, this message translates to:
/// **'Date'**
String get swipeDate;
/// No description provided for @tasksMenu.
///
/// In en, this message translates to:
/// **'More'**
String get tasksMenu;
/// Quick add's + menu, holding Description and Deadline (ADR 0033, D15).
///
/// In en, this message translates to:
/// **'More'**
String get quickAddMore;
/// Below quick add's title while it has tokens recognised (D15).
///
/// In en, this message translates to:
/// **'Tap a highlighted word to keep it as text.'**
String get quickAddTokenHint;
/// Quick add's deadline chip and menu entry (D15).
///
/// In en, this message translates to:
/// **'Deadline'**
String get deadlineTitle;
}
class _AppLocalizationsDelegate

View file

@ -578,4 +578,133 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get taskPath => 'Wo die Aufgabe liegt';
@override
String get weekToday => 'Zurück zu heute';
@override
String get monthPrevious => 'Voriger Monat';
@override
String get monthNext => 'Folgender Monat';
@override
String get monthShow => 'Ganzen Monat zeigen';
@override
String get monthHide => 'Eine Woche zeigen';
@override
String get selectTasks => 'Aufgaben auswählen';
@override
String selectionCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Aufgaben ausgewählt',
one: '1 Aufgabe ausgewählt',
zero: 'Aufgaben auswählen',
);
return '$_temp0';
}
@override
String get selectionEnd => 'Auswahl beenden';
@override
String get selectionDate => 'Datum';
@override
String get selectionMove => 'Verschieben nach';
@override
String get selectionLabels => 'Labels';
@override
String get selectionPriority => 'Priorität';
@override
String get selectionMore => 'Mehr';
@override
String selectionDelete(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Aufgaben löschen',
one: '1 Aufgabe löschen',
);
return '$_temp0';
}
@override
String get selectionDeleteTitle => 'Die Aufgaben löschen?';
@override
String selectionDeleteMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count Aufgaben werden mit ihren Unteraufgaben auf all deinen Geräten gelöscht.',
one: '1 Aufgabe wird mit ihren Unteraufgaben auf all deinen Geräten gelöscht.',
);
return '$_temp0';
}
@override
String selectionDuplicate(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Aufgaben duplizieren',
one: '1 Aufgabe duplizieren',
);
return '$_temp0';
}
@override
String selectionComplete(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Aufgaben erledigen',
one: '1 Aufgabe erledigen',
);
return '$_temp0';
}
@override
String get selectionDeadline => 'Frist';
@override
String tasksCompleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Aufgaben erledigt',
one: 'Aufgabe erledigt',
);
return '$_temp0';
}
@override
String get swipeSelect => 'Auswählen';
@override
String get swipeDate => 'Datum';
@override
String get tasksMenu => 'Mehr';
@override
String get quickAddMore => 'Mehr';
@override
String get quickAddTokenHint =>
'Tippe ein hervorgehobenes Wort an, um es als Text zu behalten.';
@override
String get deadlineTitle => 'Frist';
}

View file

@ -578,6 +578,134 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get taskPath => 'Where the task is';
@override
String get weekToday => 'Back to today';
@override
String get monthPrevious => 'Previous month';
@override
String get monthNext => 'Following month';
@override
String get monthShow => 'Show the whole month';
@override
String get monthHide => 'Show one week';
@override
String get selectTasks => 'Select tasks';
@override
String selectionCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tasks selected',
one: '1 task selected',
zero: 'Select tasks',
);
return '$_temp0';
}
@override
String get selectionEnd => 'Stop selecting';
@override
String get selectionDate => 'Date';
@override
String get selectionMove => 'Move to';
@override
String get selectionLabels => 'Labels';
@override
String get selectionPriority => 'Priority';
@override
String get selectionMore => 'More';
@override
String selectionDelete(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Delete $count tasks',
one: 'Delete 1 task',
);
return '$_temp0';
}
@override
String get selectionDeleteTitle => 'Delete the tasks?';
@override
String selectionDeleteMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count tasks are deleted with their sub-tasks, on all your devices.',
one: '1 task is deleted with its sub-tasks, on all your devices.',
);
return '$_temp0';
}
@override
String selectionDuplicate(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Duplicate $count tasks',
one: 'Duplicate 1 task',
);
return '$_temp0';
}
@override
String selectionComplete(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Complete $count tasks',
one: 'Complete 1 task',
);
return '$_temp0';
}
@override
String get selectionDeadline => 'Deadline';
@override
String tasksCompleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tasks done',
one: 'Task done',
);
return '$_temp0';
}
@override
String get swipeSelect => 'Select';
@override
String get swipeDate => 'Date';
@override
String get tasksMenu => 'More';
@override
String get quickAddMore => 'More';
@override
String get quickAddTokenHint => 'Tap a highlighted word to keep it as text.';
@override
String get deadlineTitle => 'Deadline';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -51,6 +51,7 @@ class _ProjectScreenBodyState() extends State<ProjectScreenBody> {
value: () => addSection(context, projectId),
child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd),
),
selectTasksEntry(context),
...?widget.menu?.call(context),
],
),

View file

@ -17,6 +17,9 @@ import 'package:material_ui/material_ui.dart';
/// 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.
/// 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.
class const ProjectTasks({
required final String projectId,
final bool completed = false,
@ -42,18 +45,22 @@ class const ProjectTasks({
),
);
}
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),
return CustomScrollView(
slivers: [
SliverList.list(
children: [for (final task in loose) TaskTile(task: task)],
),
for (final section in sections) _section(context, state, section),
SliverPadding(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
),
],
);
}
List<Widget> _section(
Widget _section(
BuildContext context,
TodoState state,
StoredSection section,
@ -67,69 +74,78 @@ class const ProjectTasks({
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: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.sectionDelete,
destructive: true,
),
),
],
),
final heading = ListTile(
title: Text(
'${content.name} ${tasks.where((t) => !t.content.completed).length}',
style: theme.textTheme.titleMedium,
),
for (final task in tasks) TaskTile(task: task),
];
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: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.sectionDelete,
destructive: true,
),
),
],
),
);
return SliverMainAxisGroup(
slivers: [
const SliverToBoxAdapter(child: Divider(height: 24)),
PinnedHeaderSliver(
// Opaque, so the rows scrolling beneath don't show through.
child: ColoredBox(color: theme.colorScheme.surface, child: heading),
),
SliverList.builder(
itemCount: tasks.length,
itemBuilder: (context, index) => TaskTile(task: tasks[index]),
),
],
);
}
}

View file

@ -93,6 +93,27 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
}
}
/// Moves the tasks [ids] to the project [projectId] with their sub-tasks,
/// theirs too, out of any section; one whose parent stays behind becomes
/// a task of its own there.
Future<void> move(Iterable<String> ids, String projectId) async {
final tasks = await all();
final moving = _withDescendants({...ids}, tasks);
for (final task in tasks) {
if (task.content case final content? when moving.contains(task.id)) {
await update(
task.id,
content.copyWith(
projectId: projectId,
sectionId: () => null,
parentId: () =>
moving.contains(content.parentId) ? content.parentId : null,
),
);
}
}
}
/// Copies the task [id] with its sub-tasks, theirs too, each as it is,
/// the copy right after it among its siblings, and returns the copy's ID
/// (the developer's, 1 Nov: the task's menu's Duplicate, as Todoist's).

View file

@ -0,0 +1,592 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// What a token in quick add's text sets (ADR 0033, D15).
enum QuickAddKind() {
project,
label,
deadline,
priority,
reminder,
date,
time,
repeat,
}
/// A token recognised in quick add's text, from [start] to [end].
final class const QuickAddToken(
final QuickAddKind kind,
final int start,
final int end,
) with Equatable {
@override
List<Object?> get props => [kind, start, end];
}
/// What quick add's text says (ADR 0033, D15): the [title] left once its
/// [tokens] are taken out, and what they set; null where none set it.
final class const ParsedTask({
required final String title,
required final List<QuickAddToken> tokens,
final String? projectId,
final List<String> labelIds = const [],
/// Labels named after `@` that don't exist yet, to be made.
final List<String> newLabels = const [],
final Priority? priority,
final Day? deadline,
final TaskDate? date,
/// Reminders, minutes before the task's time, as [TaskContent.reminders].
final List<int> reminders = const [],
});
/// A project's or a label's ID and the name it is typed by.
typedef NamedId = ({String id, String name});
/// Reads quick add's [text] at [now] (ADR 0033, D15): `#project` and
/// `@label` by their names (the longest matching, the case aside; an
/// unknown label to be made, an unknown project left as text), `{deadline}`,
/// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that
/// long before its time), and dates, times and recurrences in [words]. Only
/// the first of each kind counts. A token whose text, in lower case, is
/// among [ignored] (tapped off by the person) stays text.
ParsedTask parseQuickAdd(
String text, {
required DateTime now,
required List<QuickAddWords> words,
List<NamedId> projects = const [],
List<NamedId> labels = const [],
Set<String> ignored = const {},
}) => _Parser(text, now, words, ignored).parse(projects, labels);
class _Parser(
final String _text,
final DateTime _now,
final List<QuickAddWords> _words,
final Set<String> _ignored,
) {
late final _today = Day.of(_now);
final _tokens = <QuickAddToken>[];
/// The text with each token found so far blanked out, the offsets kept.
late var _rest = _text;
String? _projectId;
final _labelIds = <String>[];
final _newLabels = <String>[];
Priority? _priority;
Day? _deadline;
Day? _day;
int? _time;
Repeat? _repeat;
int? _reminder;
QuickAddToken? _reminderToken;
bool _has(QuickAddKind kind) => _tokens.any((token) => token.kind == kind);
bool _isIgnored(int start, int end) =>
_ignored.contains(_text.substring(start, end).toLowerCase());
void _take(QuickAddKind kind, int start, int end) {
final token = QuickAddToken(kind, start, end);
_tokens.add(token);
if (kind == QuickAddKind.reminder) {
_reminderToken = token;
}
_rest = _rest.replaceRange(start, end, ' ' * (end - start));
}
static bool _boundary(String text, int at) =>
at < 0 || at >= text.length || text[at].trim().isEmpty;
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
_symbols(projects, labels);
_phrases();
return _result();
}
// The symbols, in every language.
void _symbols(List<NamedId> projects, List<NamedId> labels) {
final lowered = _text.toLowerCase();
final byLength = [
...projects,
]..sort((a, b) => b.name.length.compareTo(a.name.length));
final labelsByLength = [
...labels,
]..sort((a, b) => b.name.length.compareTo(a.name.length));
for (var at = 0; at < _text.length; at++) {
final char = _text[at];
if ((char != '#' && char != '@') || !_boundary(_text, at - 1)) {
continue;
}
final named = char == '#' ? byLength : labelsByLength;
NamedId? found;
for (final candidate in named) {
final name = candidate.name.toLowerCase();
final end = at + 1 + name.length;
if (name.isNotEmpty &&
lowered.startsWith(name, at + 1) &&
_boundary(_text, end) &&
!_isIgnored(at, end)) {
found = candidate;
break;
}
}
if (char == '#') {
if (found != null && _projectId == null) {
_projectId = found.id;
_take(QuickAddKind.project, at, at + 1 + found.name.length);
}
} else if (found != null) {
if (!_labelIds.contains(found.id)) {
_labelIds.add(found.id);
}
_take(QuickAddKind.label, at, at + 1 + found.name.length);
} else if (RegExp(r'@([^\s@#{}]+)').matchAsPrefix(_text, at)
case final match? when !_isIgnored(at, match.end)) {
final name = match.group(1)!;
if (!_newLabels.any(
(label) => label.toLowerCase() == name.toLowerCase(),
)) {
_newLabels.add(name);
}
_take(QuickAddKind.label, at, match.end);
}
}
for (final match in RegExp(r'\{([^{}]*)\}').allMatches(_text)) {
if (_deadline != null || _isIgnored(match.start, match.end)) {
continue;
}
final inner = _wordsIn(match.group(1)!, match.start + 1);
if (inner.isNotEmpty) {
if (_dateAt(inner, 0) case (final day, final length)
when length == inner.length) {
_deadline = day;
_take(QuickAddKind.deadline, match.start, match.end);
}
}
}
for (final match in RegExp(
r'(?<=^|\s)(?:!!([1-4])|[pP]([1-4]))(?=\s|$)',
).allMatches(_text)) {
if (_priority == null && !_isIgnored(match.start, match.end)) {
_priority = Priority.ofNumber(
int.parse(match.group(1) ?? match.group(2)!),
);
_take(QuickAddKind.priority, match.start, match.end);
}
}
for (final match in RegExp(
r'(?<=^|\s)!(\d{1,4})(m|min|h)(?=\s|$)',
).allMatches(_text)) {
if (_reminder == null && !_isIgnored(match.start, match.end)) {
final amount = int.parse(match.group(1)!);
_reminder = match.group(2) == 'h' ? amount * 60 : amount;
_take(QuickAddKind.reminder, match.start, match.end);
}
}
}
// Words.
static final _word = RegExp(r'[^\s,]+');
/// The words of [text] in lower case, each with where it is, [offset]
/// added.
static List<_Word> _wordsIn(String text, [int offset = 0]) => [
for (final match in _word.allMatches(text))
_Word(
match.group(0)!.toLowerCase(),
offset + match.start,
offset + match.end,
),
];
bool _in(Set<String> Function(QuickAddWords words) table, String word) =>
_words.any((words) => table(words).contains(word));
T? _lookUp<T>(Map<String, T> Function(QuickAddWords words) table, String w) {
for (final words in _words) {
if (table(words)[w] case final value?) {
return value;
}
}
return null;
}
int? _weekday(String word) => _lookUp((words) => words.weekdays, word);
int? _month(String word) => _lookUp((words) => words.months, word);
RepeatUnit? _unit(String word) => _lookUp((words) => words.units, word);
/// A number: its digits, or an ordinal word standing for one.
int? _number(String word) =>
int.tryParse(word) ?? _lookUp((words) => words.ordinals, word);
/// A day's number, maybe followed by a suffix (`7th`, `7.`).
int? _dayNumber(String word) {
if (RegExp(r'^(\d{1,2})(\D*)$').firstMatch(word) case final match?) {
final suffix = match.group(2)!;
if (suffix.isEmpty || _in((words) => words.daySuffixes, suffix)) {
return int.parse(match.group(1)!);
}
}
return null;
}
void _phrases() {
final words = _wordsIn(_rest);
var i = 0;
while (i < words.length) {
final found = _longestAt(words, i);
if (found == null) {
i++;
continue;
}
final (kind, length, apply) = found;
final start = words[i].start;
final end = words[i + length - 1].end;
apply();
_take(kind, start, end);
i += length;
}
}
/// The longest phrase starting at [words]' [i]th word whose kind isn't
/// set yet: its kind, how many words it takes, and what setting it does.
(QuickAddKind, int, void Function())? _longestAt(List<_Word> words, int i) {
final candidates = <(QuickAddKind, int, void Function())>[
if (!_has(QuickAddKind.repeat))
if (_repeatAt(words, i) case (final repeat, final length))
(QuickAddKind.repeat, length, () => _repeat = repeat),
if (!_has(QuickAddKind.date))
if (_dateAt(words, i) case (final day, final length))
(QuickAddKind.date, length, () => _day = day),
if (!_has(QuickAddKind.time))
if (_timeAt(words, i) case (final time, final length))
(QuickAddKind.time, length, () => _time = time),
];
(QuickAddKind, int, void Function())? best;
for (final candidate in candidates) {
final end = words[i + candidate.$2 - 1].end;
if (!_isIgnored(words[i].start, end) &&
(best == null || candidate.$2 > best.$2)) {
best = candidate;
}
}
return best;
}
/// A recurrence at [i]: `daily`, or `every` (`every!` from when it was
/// done) and a count and unit, weekdays, workdays or the weekend.
(Repeat, int)? _repeatAt(List<_Word> words, int i) {
final first = words[i].text;
if (_lookUp((words) => words.adverbs, first) case final unit?) {
return (Repeat(unit), 1);
}
final fromCompletion = first.endsWith('!');
final every = fromCompletion ? first.substring(0, first.length - 1) : first;
if (!_in((words) => words.every, every) || i + 1 >= words.length) {
return null;
}
var at = i + 1;
final next = words[at].text;
if (_in((words) => words.workdays, next)) {
return (
Repeat(
RepeatUnit.week,
weekdays: const {1, 2, 3, 4, 5},
fromCompletion: fromCompletion,
),
2,
);
}
if (_in((words) => words.weekend, next)) {
return (
Repeat(
RepeatUnit.week,
weekdays: const {DateTime.saturday, DateTime.sunday},
fromCompletion: fromCompletion,
),
2,
);
}
if (_weekday(next) != null) {
final weekdays = <int>{};
var end = at;
while (at < words.length) {
if (_weekday(words[at].text) case final weekday?) {
weekdays.add(weekday);
end = at;
at++;
} else if (_in((words) => words.and, words[at].text) &&
at + 1 < words.length &&
_weekday(words[at + 1].text) != null) {
at++;
} else {
break;
}
}
return (
Repeat(
RepeatUnit.week,
weekdays: weekdays,
fromCompletion: fromCompletion,
),
end - i + 1,
);
}
var count = 1;
if (_number(next) case final number? when number > 0) {
count = number;
at++;
}
if (at < words.length) {
if (_unit(words[at].text) case final unit?) {
return (
Repeat(unit, every: count, fromCompletion: fromCompletion),
at - i + 1,
);
}
}
return null;
}
/// The coming [weekday], today too; after today where [strictly].
Day _coming(int weekday, {bool strictly = false}) {
var day = strictly ? _today.plus(1) : _today;
while (day.weekday != weekday) {
day = day.plus(1);
}
return day;
}
/// [year], [month] and [day] where they make a day.
static Day? _valid(int year, int month, int day) =>
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= Day(year, month, 1).daysInMonth
? Day(year, month, day)
: null;
/// A day of a month without its year: this year's, or next year's where
/// it has passed.
Day? _yearless(int month, int day) {
final here = _valid(_today.year, month, day);
if (here == null) {
return null;
}
return here < _today ? _valid(_today.year + 1, month, day) : here;
}
/// A day at [i]: today, tomorrow, a weekday, `next` one, the weekend, `in`
/// a span, a month's day (either way round, with or without its year),
/// `2026-12-24` or `24.12.` (`24.12.2026`).
(Day, int)? _dateAt(List<_Word> words, int i) {
final word = words[i].text;
String? after(int n) => i + n < words.length ? words[i + n].text : null;
if (_in((words) => words.today, word)) {
return (_today, 1);
}
if (_in((words) => words.tomorrow, word)) {
return (_today.plus(1), 1);
}
if (_in((words) => words.dayAfterTomorrow, word)) {
return (_today.plus(2), 1);
}
if (_weekday(word) case final weekday?) {
return (_coming(weekday), 1);
}
if (_in((words) => words.weekend, word)) {
return (_coming(DateTime.saturday), 1);
}
if (_in((words) => words.next, word) && after(1) != null) {
final next = after(1)!;
if (_weekday(next) case final weekday?) {
return (_coming(weekday, strictly: true), 2);
}
switch (_unit(next)) {
case RepeatUnit.day:
return (_today.plus(1), 2);
case RepeatUnit.week:
return (_today.weekStart.plus(7), 2);
case RepeatUnit.month:
return (Day.of(DateTime(_today.year, _today.month + 1)), 2);
case RepeatUnit.year:
return (Day(_today.year + 1, 1, 1), 2);
case null:
break;
}
if (_in((words) => words.weekend, next)) {
return (_coming(DateTime.saturday, strictly: true), 2);
}
}
if (_in((words) => words.inWords, word) && after(2) != null) {
if ((int.tryParse(after(1)!), _unit(after(2)!)) case (
final count?,
final unit?,
)) {
final day = switch (unit) {
RepeatUnit.day => _today.plus(count),
RepeatUnit.week => _today.plus(7 * count),
RepeatUnit.month => Day.of(
DateTime(_today.year, _today.month + count, _today.day),
),
RepeatUnit.year => Day.of(
DateTime(_today.year + count, _today.month, _today.day),
),
};
return (day, 3);
}
}
// A month's day, either way round, maybe with its year.
int? year(String? word) => switch (word) {
final word? when RegExp(r'^\d{4}$').hasMatch(word) => int.parse(word),
_ => null,
};
(Day, int)? dated(int month, int day, String? yearWord, int length) {
if (year(yearWord) case final year?) {
if (_valid(year, month, day) case final valid?) {
return (valid, length + 1);
}
return null;
}
return switch (_yearless(month, day)) {
final valid? => (valid, length),
null => null,
};
}
if ((_month(word), after(1)) case (final month?, final next?)) {
if (_dayNumber(next) case final day?) {
return dated(month, day, after(2), 2);
}
}
if ((_dayNumber(word), after(1)) case (final day?, final next?)) {
if (_month(next) case final month?) {
return dated(month, day, after(2), 2);
}
}
if (RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(word)
case final match?) {
if (_valid(
int.parse(match.group(1)!),
int.parse(match.group(2)!),
int.parse(match.group(3)!),
)
case final day?) {
return (day, 1);
}
}
if (RegExp(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})?$').firstMatch(word)
case final match?) {
final day = int.parse(match.group(1)!);
final month = int.parse(match.group(2)!);
final found = switch (match.group(3)) {
final year? => _valid(int.parse(year), month, day),
null => _yearless(month, day),
};
if (found != null) {
return (found, 1);
}
}
return null;
}
/// A time at [i], minutes after midnight: `9am`, `9:30 pm`, `18:00`,
/// `18 Uhr`, maybe after `at`.
(int, int)? _timeAt(List<_Word> words, int i) {
var at = i;
final lead = _in((words) => words.at, words[i].text) ? 1 : 0;
at += lead;
if (at >= words.length) {
return null;
}
final word = words[at].text;
final next = at + 1 < words.length ? words[at + 1].text : null;
int minutes(String? text) => text == null ? 0 : int.parse(text);
(int, int)? twelve(int hour, int minute, int half, int length) =>
hour >= 1 && hour <= 12 && minute < 60
? ((hour % 12 + half) * 60 + minute, lead + length)
: null;
(int, int)? full(int hour, int minute, int length) =>
hour < 24 && minute < 60 ? (hour * 60 + minute, lead + length) : null;
final clock = RegExp(r'^(\d{1,2})(?::(\d{2}))?([a-z]*)$').firstMatch(word);
if (clock == null) {
return null;
}
final hour = int.parse(clock.group(1)!);
final minute = minutes(clock.group(2));
final suffix = clock.group(3)!;
if (suffix.isNotEmpty) {
return switch (_lookUp((words) => words.meridiem, suffix)) {
final half? => twelve(hour, minute, half, 1),
null => null,
};
}
if (next != null) {
if (_lookUp((words) => words.meridiem, next) case final half?) {
return twelve(hour, minute, half, 2);
}
if (_in((words) => words.oClock, next)) {
return full(hour, minute, 2);
}
}
// A bare number is no time; a colon makes one.
return clock.group(2) != null ? full(hour, minute, 1) : null;
}
ParsedTask _result() {
var day = _day;
var time = _time;
var reminders = const <int>[];
if (_reminder case final reminder?) {
if (time != null) {
reminders = [reminder];
} else if (day == null) {
final due = _now.add(Duration(minutes: reminder));
day = Day.of(due);
time = due.hour * 60 + due.minute;
reminders = [0];
} else {
// A day without a time has nothing to be reminded before.
_tokens.remove(_reminderToken);
}
}
if (day == null && time != null) {
final nowMinutes = _now.hour * 60 + _now.minute;
day = time > nowMinutes ? _today : _today.plus(1);
}
if (_repeat case final repeat?) {
day = repeat.firstFrom(day ?? _today);
}
_tokens.sort((a, b) => a.start.compareTo(b.start));
final title = StringBuffer();
var from = 0;
for (final token in _tokens) {
title.write(_text.substring(from, token.start));
from = token.end;
}
title.write(_text.substring(from));
return ParsedTask(
title: title.toString().replaceAll(RegExp(r'\s+'), ' ').trim(),
tokens: List.unmodifiable(_tokens),
projectId: _projectId,
labelIds: List.unmodifiable(_labelIds),
newLabels: List.unmodifiable(_newLabels),
priority: _priority,
deadline: _deadline,
date: day == null ? null : TaskDate(day, time: time, repeat: _repeat),
reminders: reminders,
);
}
}
/// A word of the text, in lower case, and where it is.
class const _Word(final String text, final int start, final int end);

View file

@ -0,0 +1,242 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
/// The words quick add reads dates, times and recurrences in, one table a
/// language (ADR 0033, D15): a new app language adds a table here, not a
/// parser. Quick add reads the app's language and English together; the
/// symbols (`#`, `@`, `{}`, `!!`, `p1`, `!30m`) hold in every one. Every
/// word is lower case, as the text is compared folded to it.
final class const QuickAddWords({
/// Today, and the words for it.
required final Set<String> today,
/// Tomorrow.
required final Set<String> tomorrow,
/// The day after tomorrow, where the language has a word for it.
final Set<String> dayAfterTomorrow = const {},
/// The weekdays' names and short forms, to `DateTime.monday` to `sunday`.
required final Map<String, int> weekdays,
/// The months' names and short forms, to 1 to 12.
required final Map<String, int> months,
/// The units counted, singular and plural: `in 3 days`, `every week`.
required final Map<String, RepeatUnit> units,
/// Recurrences in a word: `daily`.
required final Map<String, RepeatUnit> adverbs,
/// What starts a recurrence: `every`, `ev`. Followed by `!`, it counts
/// from when the task was done.
required final Set<String> every,
/// Counted words standing for a number: `other` and `second` for 2.
required final Map<String, int> ordinals,
/// Monday to Friday: `every weekday`.
required final Set<String> workdays,
/// Saturday and Sunday: `weekend`, `every weekend`.
required final Set<String> weekend,
/// What starts the next one: `next monday`, `next week`.
required final Set<String> next,
/// What starts a span from now: `in 3 days`.
required final Set<String> inWords,
/// What may stand before a time: `at 7pm`.
required final Set<String> at,
/// What joins weekdays: `mon and fri`.
required final Set<String> and,
/// What may follow a 24-hour time: `18 Uhr`.
final Set<String> oClock = const {},
/// Twelve-hour times' halves: `am` to 0, `pm` to 12.
final Map<String, int> meridiem = const {},
/// What may follow a day's number: `7th`, `7.`.
final Set<String> daySuffixes = const {},
}) {
/// The table of [language] (its code, `de`), where there is one.
static QuickAddWords? of(String language) => switch (language) {
'en' => en,
'de' => de,
_ => null,
};
/// The tables quick add reads where the app speaks [language]: its own,
/// then English.
static List<QuickAddWords> forLanguage(String language) => [
if (of(language) case final words? when words != en) words,
en,
];
/// English.
static const en = QuickAddWords(
today: {'today', 'tod'},
tomorrow: {'tomorrow', 'tom', 'tmr'},
weekdays: {
'monday': DateTime.monday,
'mon': DateTime.monday,
'tuesday': DateTime.tuesday,
'tue': DateTime.tuesday,
'tues': DateTime.tuesday,
'wednesday': DateTime.wednesday,
'wed': DateTime.wednesday,
'thursday': DateTime.thursday,
'thu': DateTime.thursday,
'thurs': DateTime.thursday,
'friday': DateTime.friday,
'fri': DateTime.friday,
'saturday': DateTime.saturday,
'sat': DateTime.saturday,
'sunday': DateTime.sunday,
'sun': DateTime.sunday,
},
months: {
'january': 1,
'jan': 1,
'february': 2,
'feb': 2,
'march': 3,
'mar': 3,
'april': 4,
'apr': 4,
'may': 5,
'june': 6,
'jun': 6,
'july': 7,
'jul': 7,
'august': 8,
'aug': 8,
'september': 9,
'sep': 9,
'sept': 9,
'october': 10,
'oct': 10,
'november': 11,
'nov': 11,
'december': 12,
'dec': 12,
},
units: {
'day': RepeatUnit.day,
'days': RepeatUnit.day,
'week': RepeatUnit.week,
'weeks': RepeatUnit.week,
'month': RepeatUnit.month,
'months': RepeatUnit.month,
'year': RepeatUnit.year,
'years': RepeatUnit.year,
},
adverbs: {
'daily': RepeatUnit.day,
'weekly': RepeatUnit.week,
'monthly': RepeatUnit.month,
'yearly': RepeatUnit.year,
'annually': RepeatUnit.year,
},
every: {'every', 'ev', 'each'},
ordinals: {
'other': 2,
'second': 2,
'third': 3,
'fourth': 4,
'fifth': 5,
'sixth': 6,
},
workdays: {'weekday', 'workday'},
weekend: {'weekend'},
next: {'next'},
inWords: {'in'},
at: {'at'},
and: {'and', '&'},
meridiem: {'am': 0, 'pm': 12},
daySuffixes: {'st', 'nd', 'rd', 'th'},
);
/// German. Weekdays by their whole names alone: their short forms (`so`,
/// `do`, `die`, `mit`) are everyday words.
static const de = QuickAddWords(
today: {'heute'},
tomorrow: {'morgen'},
dayAfterTomorrow: {'übermorgen'},
weekdays: {
'montag': DateTime.monday,
'dienstag': DateTime.tuesday,
'mittwoch': DateTime.wednesday,
'donnerstag': DateTime.thursday,
'freitag': DateTime.friday,
'samstag': DateTime.saturday,
'sonnabend': DateTime.saturday,
'sonntag': DateTime.sunday,
},
months: {
'januar': 1,
'jan': 1,
'jänner': 1,
'februar': 2,
'feb': 2,
'märz': 3,
'mär': 3,
'april': 4,
'apr': 4,
'mai': 5,
'juni': 6,
'jun': 6,
'juli': 7,
'jul': 7,
'august': 8,
'aug': 8,
'september': 9,
'sep': 9,
'sept': 9,
'oktober': 10,
'okt': 10,
'november': 11,
'nov': 11,
'dezember': 12,
'dez': 12,
},
units: {
'tag': RepeatUnit.day,
'tage': RepeatUnit.day,
'tagen': RepeatUnit.day,
'woche': RepeatUnit.week,
'wochen': RepeatUnit.week,
'monat': RepeatUnit.month,
'monate': RepeatUnit.month,
'monaten': RepeatUnit.month,
'jahr': RepeatUnit.year,
'jahre': RepeatUnit.year,
'jahren': RepeatUnit.year,
},
adverbs: {
'täglich': RepeatUnit.day,
'wöchentlich': RepeatUnit.week,
'monatlich': RepeatUnit.month,
'jährlich': RepeatUnit.year,
},
every: {'jeden', 'jede', 'jedes', 'alle'},
ordinals: {
'zweiten': 2,
'zweite': 2,
'dritten': 3,
'dritte': 3,
'vierten': 4,
'vierte': 4,
},
workdays: {'werktag', 'wochentag'},
weekend: {'wochenende'},
next: {'nächsten', 'nächste', 'nächster', 'kommenden', 'kommende'},
inWords: {'in'},
at: {'um'},
and: {'und'},
oClock: {'uhr'},
daySuffixes: {'.'},
);
}

View file

@ -1,10 +1,15 @@
import 'package:clock/clock.dart';
import 'package:flutter/services.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/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.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';
@ -18,6 +23,14 @@ import 'package:material_ui/material_ui.dart';
/// 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.
///
/// It shows the title alone at first, its chips once typing starts, and its
/// description from its + or the down arrow (the developer's, 1 Nov, D15).
/// What the title says is read as it is typed (`parseQuickAdd`): `#project`,
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m`, dates, times and
/// recurrences in the app's language and English, each highlighted and
/// shown on its chip; a tap on one keeps it as text, and a chip picked
/// takes its token out of the title.
Future<void> showQuickAdd(
BuildContext context, {
String? projectId,
@ -34,6 +47,9 @@ Future<void> showQuickAdd(
),
);
/// The key of quick add's title field.
const quickAddTitleKey = ValueKey('quick-add-title');
class const _QuickAdd({
required final String? projectId,
required final String? sectionId,
@ -45,9 +61,10 @@ class const _QuickAdd({
}
class _QuickAddState() extends State<_QuickAdd> {
final _title = TextEditingController();
final _title = _TokenController();
final _description = TextEditingController();
final _focus = FocusNode();
final _descriptionFocus = FocusNode();
late TaskDate? _date = switch (widget.day) {
final day? => TaskDate(day),
null => null,
@ -56,28 +73,119 @@ class _QuickAddState() extends State<_QuickAdd> {
late String? _projectId = widget.projectId;
late String? _sectionId = widget.sectionId;
var _labelIds = <String>[];
Day? _deadline;
var _describing = false;
/// The tokens tapped off, by their text in lower case.
final _ignored = <String>{};
@override
void initState() {
super.initState();
_title.addListener(_changed);
}
@override
void dispose() {
_title.dispose();
_title
..removeListener(_changed)
..dispose();
_description.dispose();
_focus.dispose();
_descriptionFocus.dispose();
super.dispose();
}
void _changed() => setState(() {});
/// What the title says now.
ParsedTask _parse() {
final l10n = AppLocalizations.of(context);
final state = context.read<TodoCubit>().state;
return parseQuickAdd(
_title.text,
now: clock.now(),
words: QuickAddWords.forLanguage(
Localizations.localeOf(context).languageCode,
),
projects: [
for (final project in state.projects)
if (project.inbox) ...[
(id: project.id, name: l10n.shellTabInbox),
if (l10n.shellTabInbox != 'Inbox') (id: project.id, name: 'Inbox'),
] else if (project.content case final content?)
(id: project.id, name: content.name),
],
labels: [
for (final label in state.labels)
if (label.content case final content?)
(id: label.id, name: content.name),
],
ignored: _ignored,
);
}
/// Takes the tokens of [kinds] out of the title, a chip having been
/// picked for them.
void _dropTokens(Set<QuickAddKind> kinds) {
final tokens = _parse().tokens.where((token) => kinds.contains(token.kind));
var text = _title.text;
for (final token in tokens.toList().reversed) {
text = text.replaceRange(token.start, token.end, '');
}
text = text.replaceAll(RegExp(' +'), ' ').trim();
if (text != _title.text) {
_title.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
}
/// A tap in the title on a token keeps it as text.
void _tapped() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final offset = _title.selection.baseOffset;
for (final token in _title.tokens) {
if (offset > token.start && offset < token.end) {
setState(
() => _ignored.add(
_title.text.substring(token.start, token.end).toLowerCase(),
),
);
return;
}
}
});
}
void _describe() {
setState(() => _describing = true);
WidgetsBinding.instance.addPostFrameCallback(
(_) => _descriptionFocus.requestFocus(),
);
}
Future<void> _add() async {
final l10n = AppLocalizations.of(context);
final title = _title.text.trim();
final projectId = _projectId;
final parsed = _parse();
final title = parsed.title;
final projectId = parsed.projectId ?? _projectId;
if (title.isEmpty || projectId == null) {
return;
}
final sectionId = parsed.projectId == null || parsed.projectId == _projectId
? _sectionId
: null;
final todo = PersonScope.of<TodoPerson>(context);
final state = context.read<TodoCubit>().state;
final siblings = state.tasks.where(
(task) =>
task.content.projectId == projectId &&
task.content.sectionId == _sectionId &&
task.content.sectionId == sectionId &&
task.content.parentId == widget.parentId,
);
final last = siblings.fold<double>(
@ -85,18 +193,33 @@ class _QuickAddState() extends State<_QuickAdd> {
(position, task) =>
task.content.position > position ? task.content.position : position,
);
final lastLabel = state.labels.fold<double>(
0,
(position, label) => switch (label.content?.position) {
final at? when at > position => at,
_ => position,
},
);
try {
await PersonScope.of<TodoPerson>(context).tasks.add(
final made = <String>[
for (final (index, name) in parsed.newLabels.indexed)
await todo.labels.add(
LabelContent(name: name, position: lastLabel + 1 + index),
),
];
await todo.tasks.add(
TaskContent(
title: title,
projectId: projectId,
sectionId: _sectionId,
sectionId: sectionId,
parentId: widget.parentId,
description: _description.text.trim(),
position: siblings.isEmpty ? 0 : last + 1,
date: _date,
priority: _priority,
labelIds: _labelIds,
date: parsed.date ?? _date,
deadline: parsed.deadline ?? _deadline,
priority: parsed.priority ?? _priority,
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
reminders: parsed.reminders,
),
);
} on KeysMissing {
@ -116,6 +239,8 @@ class _QuickAddState() extends State<_QuickAdd> {
setState(() {
_priority = Priority.none;
_labelIds = [];
_deadline = null;
_ignored.clear();
});
_focus.requestFocus();
}
@ -123,12 +248,24 @@ class _QuickAddState() extends State<_QuickAdd> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final today = Day.of(clock.now());
final project = switch (_projectId) {
final parsed = _parse();
_title.tokens = parsed.tokens;
_title.highlight = theme.colorScheme.primaryContainer;
_title.onHighlight = theme.colorScheme.onPrimaryContainer;
final date = parsed.date ?? _date;
final priority = parsed.priority ?? _priority;
final deadline = parsed.deadline ?? _deadline;
final labelIds = {..._labelIds, ...parsed.labelIds};
final labelCount = labelIds.length + parsed.newLabels.length;
final projectId = parsed.projectId ?? _projectId;
final project = switch (projectId) {
final id? => state.projectOf(id),
null => null,
};
final typing = _title.text.trim().isNotEmpty;
return SheetScaffold(
title: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd,
hasSnackBars: true,
@ -136,118 +273,238 @@ class _QuickAddState() extends State<_QuickAdd> {
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(),
// The down arrow opens the description, as Todoist's.
CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowDown): _describe,
},
child: TextField(
key: quickAddTitleKey,
controller: _title,
focusNode: _focus,
autofocus: true,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: l10n.taskTitleHint,
helperText: parsed.tokens.isEmpty
? null
: l10n.quickAddTokenHint,
),
onTap: _tapped,
onSubmitted: (_) => _add(),
),
),
if (_describing)
TextField(
controller: _description,
focusNode: _descriptionFocus,
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,
// The chips come once there is something to add.
AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.topCenter,
child: !typing
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 12),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
ActionChip(
avatar: Icon(
date?.repeat == null
? LucideIcons.calendar
: LucideIcons.repeat,
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)) {
_dropTokens(const {
QuickAddKind.date,
QuickAddKind.time,
QuickAddKind.repeat,
QuickAddKind.reminder,
});
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 picked?) {
_dropTokens(const {QuickAddKind.priority});
setState(() => _priority = picked);
}
},
),
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?) {
_dropTokens(const {QuickAddKind.project});
setState(() {
if (id != _projectId) {
_sectionId = null;
}
_projectId = id;
});
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
labelCount == 0
? l10n.labelsTitle
: l10n.labelsCount(labelCount),
),
onPressed: () async {
if (await pickLabels(context, [...labelIds])
case final labels?) {
_dropTokens(const {QuickAddKind.label});
setState(() => _labelIds = labels);
}
},
),
if (deadline != null)
ActionChip(
avatar: const Icon(LucideIcons.target),
label: Text(
l10n.deadlineOn(
dayText(context, deadline, today),
),
),
onPressed: () => _pickDeadline(deadline),
),
_more(context, deadline: deadline),
],
),
),
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,
),
footer: FilledButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.taskAdd),
onPressed: parsed.title.isEmpty ? null : _add,
),
);
}
Future<void> _pickDeadline(Day? current) async {
if (await pickDeadline(context, current: current) case (:final day)) {
_dropTokens(const {QuickAddKind.deadline});
setState(() => _deadline = day);
}
}
/// The + holding what quick add doesn't show at first: the description,
/// the deadline.
Widget _more(BuildContext context, {required Day? deadline}) {
final l10n = AppLocalizations.of(context);
return PopupMenuButton<VoidCallback>(
tooltip: l10n.quickAddMore,
onSelected: (action) => action(),
itemBuilder: (context) => [
if (!_describing)
PopupMenuItem(
value: _describe,
child: MenuEntry(LucideIcons.text, l10n.taskDescription),
),
if (deadline == null)
PopupMenuItem(
value: () => _pickDeadline(null),
child: MenuEntry(LucideIcons.target, l10n.deadlineTitle),
),
],
icon: const Icon(LucideIcons.plus),
);
}
}
/// The title's controller, its [tokens] highlighted.
class _TokenController() extends TextEditingController {
List<QuickAddToken> tokens = const [];
Color? highlight;
Color? onHighlight;
@override
TextSpan buildTextSpan({
required BuildContext context,
required bool withComposing,
TextStyle? style,
}) {
final composing =
withComposing &&
value.isComposingRangeValid &&
!value.composing.isCollapsed;
if (tokens.isEmpty || composing) {
return super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
}
final marked = TextStyle(
backgroundColor: highlight,
color: onHighlight,
);
final children = <TextSpan>[];
var from = 0;
for (final token in tokens) {
if (token.end > text.length) {
break;
}
if (token.start > from) {
children.add(TextSpan(text: text.substring(from, token.start)));
}
children.add(
TextSpan(text: text.substring(token.start, token.end), style: marked),
);
from = token.end;
}
if (from < text.length) {
children.add(TextSpan(text: text.substring(from)));
}
return TextSpan(style: style, children: children);
}
}

View file

@ -134,7 +134,7 @@ class _SearchScreenState() extends State<SearchScreen> {
if (tasks.isNotEmpty) ...[
heading(l10n.searchTasks),
for (final task in tasks)
TaskTile(task: task, showProject: true),
TaskTile(task: task, showProject: true, selectable: false),
],
],
],

View file

@ -0,0 +1,305 @@
import 'dart:async';
import 'dart:collection';
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/core/widget/menu_entry.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/task_pickers.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The tasks chosen to act on together (the developer's, 1 Nov, ADR 0033,
/// D14, as Todoist's): a row swiped right, or held, starts choosing, a tap
/// then choosing more or fewer, until back or the bar's arrow ends it. One
/// for the whole frame, so choosing ends as another tab is chosen.
class TaskSelection() extends ChangeNotifier {
var _active = false;
final _ids = <String>{};
/// Whether tasks are being chosen.
bool get active => _active;
/// The tasks chosen.
Set<String> get ids => UnmodifiableSetView(_ids);
/// Starts choosing, none chosen yet (a menu's Select tasks).
void start() {
if (!_active) {
_active = true;
notifyListeners();
}
}
/// Chooses the task [id], or lets it go where it was chosen.
void toggle(String id) {
_active = true;
if (!_ids.remove(id)) {
_ids.add(id);
}
notifyListeners();
}
/// Stops choosing.
void end() {
if (_active) {
_active = false;
_ids.clear();
notifyListeners();
}
}
/// Keeps only the chosen tasks that still exist among [existing].
void keepOnly(Set<String> existing) {
if (_ids.any((id) => !existing.contains(id))) {
_ids.retainAll(existing);
notifyListeners();
}
}
}
/// Hands the frame's [TaskSelection] down to its screens.
class const TaskSelectionScope({
required TaskSelection super.notifier,
required super.child,
super.key,
}) extends InheritedNotifier<TaskSelection> {
/// The selection above [context], where there is one: not in a sheet,
/// which is above the frame.
static TaskSelection? maybeOf(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<TaskSelectionScope>()
?.notifier;
}
/// The bar at the foot while tasks are chosen, in the navigation bar's
/// place: their date, moving them, their labels, their priority, and more
/// (deleting, duplicating, completing them, their deadline), as Todoist's.
class const TaskSelectionBar({
required final TaskSelection selection,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final none = selection.ids.isEmpty;
final actions = _SelectionActions(context, selection);
Widget button(String tooltip, IconData icon, VoidCallback onPressed) =>
IconButton(
tooltip: tooltip,
icon: Icon(icon),
onPressed: none ? null : onPressed,
);
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
button(l10n.selectionDate, LucideIcons.calendar, actions.date),
button(l10n.selectionMove, LucideIcons.folderInput, actions.move),
button(l10n.selectionLabels, LucideIcons.tag, actions.labels),
button(l10n.selectionPriority, LucideIcons.flag, actions.priority),
PopupMenuButton<VoidCallback>(
tooltip: l10n.selectionMore,
enabled: !none,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) => action(),
itemBuilder: (context) {
final count = selection.ids.length;
return [
PopupMenuItem(
value: actions.complete,
child: MenuEntry(
LucideIcons.circleCheck,
l10n.selectionComplete(count),
),
),
PopupMenuItem(
value: actions.duplicate,
child: MenuEntry(
LucideIcons.copyPlus,
l10n.selectionDuplicate(count),
),
),
PopupMenuItem(
value: actions.deadline,
child: MenuEntry(LucideIcons.target, l10n.selectionDeadline),
),
const PopupMenuDivider(),
PopupMenuItem(
value: actions.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.selectionDelete(count),
destructive: true,
),
),
];
},
),
],
),
);
}
}
/// What the bar does to the chosen tasks, each written through the
/// person's [TaskRepository]; choosing ends once it is done.
class _SelectionActions(
final BuildContext _context,
final TaskSelection _selection,
) {
AppLocalizations get _l10n => AppLocalizations.of(_context);
TaskRepository get _tasks => PersonScope.of<TodoPerson>(_context).tasks;
/// The chosen tasks as they are now.
List<OpenTask> get _chosen {
final ids = _selection.ids;
return [
for (final task in _context.read<TodoCubit>().state.tasks)
if (ids.contains(task.id)) task,
];
}
/// Writes [change] into each chosen task, then stops choosing.
Future<void> _each(TaskContent Function(TaskContent content) change) =>
_guard(() async {
for (final task in _chosen) {
await _tasks.update(task.id, change(task.content));
}
});
Future<void> _guard(Future<void> Function() write) async {
final messenger = ScaffoldMessenger.of(_context);
final keysMissing = _l10n.keysMissing;
try {
await write();
_selection.end();
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(keysMissing)));
}
}
/// The value every chosen task shares, or null where they differ.
T? _shared<T>(T Function(TaskContent content) of) {
final values = {for (final task in _chosen) of(task.content)};
return values.length == 1 ? values.single : null;
}
Future<void> date() async {
if (await pickTaskDate(
_context,
current: _shared<TaskDate?>((content) => content.date),
)
case (:final date)) {
await _each((content) => content.copyWith(date: () => date));
}
}
Future<void> move() async {
if (await pickProject(
_context,
_shared((content) => content.projectId),
)
case final projectId?) {
final ids = _selection.ids.toList();
await _guard(() => _tasks.move(ids, projectId));
}
}
/// Ticked are the labels every chosen task has; the labels ticked are
/// added to each, and those every one had and are no longer ticked are
/// taken off, the others each has kept.
Future<void> labels() async {
final chosen = _chosen;
final common = chosen
.map((task) => task.content.labelIds.toSet())
.reduce((a, b) => a.intersection(b));
if (await pickLabels(_context, [...common]) case final picked?) {
final ticked = picked.toSet();
final dropped = common.difference(ticked);
await _each(
(content) => content.copyWith(
labelIds: {
...content.labelIds.where((id) => !dropped.contains(id)),
...ticked,
}.toList(),
),
);
}
}
Future<void> priority() async {
if (await pickPriority(
_context,
_shared((content) => content.priority) ?? Priority.none,
)
case final priority?) {
await _each((content) => content.copyWith(priority: priority));
}
}
Future<void> deadline() async {
if (await pickDeadline(
_context,
current: _shared<Day?>((content) => content.deadline),
)
case (:final day)) {
await _each((content) => content.copyWith(deadline: () => day));
}
}
Future<void> duplicate() => _guard(() async {
for (final task in _chosen) {
await _tasks.duplicate(task.id);
}
});
/// Marks each done (a recurring one moving on), with an Undo for all.
Future<void> complete() async {
final l10n = _l10n;
final context = _context;
final tasks = _tasks;
final chosen = _chosen.where((task) => !task.content.completed).toList();
final before = <StoredTask>[];
await _guard(() async {
for (final task in chosen) {
before.addAll(
await tasks.complete(StoredTask(task.id, task.content)),
);
}
});
if (before.isNotEmpty && context.mounted) {
showUndoSnackBar(
context,
message: l10n.tasksCompleted(chosen.length),
onUndo: () => unawaited(tasks.restore(before)),
);
}
}
Future<void> delete() async {
final l10n = _l10n;
final count = _selection.ids.length;
if (await confirmDestructiveAction(
_context,
title: l10n.selectionDeleteTitle,
message: l10n.selectionDeleteMessage(count),
confirmLabel: l10n.selectionDelete(count),
)) {
final ids = _selection.ids.toList();
await _guard(() async {
for (final id in ids) {
await _tasks.delete(id);
}
});
}
}
}

View file

@ -199,26 +199,15 @@ class _TaskSheetState() extends State<_TaskSheet> {
/// 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);
try {
await _tasks.move([widget.id], projectId);
} on KeysMissing {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).keysMissing)),
);
}
}
collect(widget.id);
for (final task in below) {
await _tasks.update(
task.id,
task.content.copyWith(projectId: projectId, sectionId: () => null),
);
}
}
/// Opens the task [id] in this one's place.
@ -615,7 +604,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
),
for (final sub in subTasks)
if (!(content.hideDoneSubTasks && sub.content.completed))
TaskTile(task: sub),
TaskTile(task: sub, selectable: false),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
@ -8,6 +9,8 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -18,11 +21,17 @@ import 'package:material_ui/material_ui.dart';
/// its title (struck through once done), the first line of its
/// description, and what it has: its date in the colour of how near it is,
/// its deadline, how many of its sub-tasks are done, its labels; where
/// [showProject], the project and section it is in at the end of that line. A tap opens its sheet; the check
/// marks it done, which can be undone for a moment.
/// [showProject], the project and section it is in at the end of that
/// line. A tap opens its sheet; the check marks it done, which can be
/// undone for a moment.
///
/// Where [selectable] and tasks can be chosen (D14, as Todoist's), the row
/// swiped right or held is chosen, a tap then choosing it or letting it go,
/// and swiped left asks for its date.
class const TaskTile({
required final OpenTask task,
final bool showProject = false,
final bool selectable = true,
super.key,
}) extends StatelessWidget {
@override
@ -121,7 +130,12 @@ class const TaskTile({
),
)
: null;
return ListTile(
final selection = selectable ? TaskSelectionScope.maybeOf(context) : null;
final chosen = selection?.ids.contains(task.id) ?? false;
final tile = ListTile(
selected: chosen,
selectedTileColor: theme.colorScheme.secondaryContainer,
selectedColor: theme.colorScheme.onSecondaryContainer,
contentPadding: const EdgeInsets.only(left: 4, right: 16),
leading: TaskCheck(task: task),
title: Text(
@ -165,8 +179,66 @@ class const TaskTile({
),
],
),
onTap: () => openTaskSheet(context, task.id),
onTap: selection != null && selection.active
? () => selection.toggle(task.id)
: () => openTaskSheet(context, task.id),
onLongPress: selection == null ? null : () => selection.toggle(task.id),
);
if (selection == null) {
return tile;
}
Widget swipe(IconData icon, Color color, Alignment alignment) => ColoredBox(
color: color,
child: Align(
alignment: alignment,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Icon(icon, color: Colors.white),
),
),
);
return Semantics(
customSemanticsActions: {
CustomSemanticsAction(label: l10n.swipeSelect): () =>
selection.toggle(task.id),
CustomSemanticsAction(label: l10n.swipeDate): () =>
unawaited(_pickDate(context)),
},
// Never dismissed: each swipe acts and the row springs back.
child: Dismissible(
key: ValueKey('swipe-${task.id}'),
background: swipe(
LucideIcons.squareCheck,
PaletteColor.pacific.outline,
Alignment.centerLeft,
),
secondaryBackground: swipe(
LucideIcons.calendar,
PaletteColor.carrot.outline,
Alignment.centerRight,
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
selection.toggle(task.id);
} else {
await _pickDate(context);
}
return false;
},
child: tile,
),
);
}
/// Asks for the task's date and writes it.
Future<void> _pickDate(BuildContext context) async {
final tasks = PersonScope.of<TodoPerson>(context).tasks;
final content = task.content;
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await tasks.update(task.id, content.copyWith(date: () => date));
}
}
}

View file

@ -1,11 +1,16 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/search_screen.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A tab's screen (ADR 0033, D3): its [title] in a transparent app bar over
/// Todo's wash from the top ([TopGradient], in the strawberry the app's
/// [ScreenColor] gives), as Train's tabs wash in carrot and Learn's in
/// cobalt.
/// cobalt. While tasks are being chosen (D14), the app bar counts them and
/// the bar of what to do with them stands at the foot.
class const TabScreen({
/// The tab's name, in the app bar.
required final String title,
@ -24,31 +29,85 @@ class const TabScreen({
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => TopGradient(
background: Theme.of(context).colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(title),
actions: [
if (search) const SearchButton(),
...actions,
const SizedBox(width: 4),
],
),
body: body,
// Clear of the navigation bar floating over the foot on a phone.
floatingActionButton: switch (floatingActionButton) {
final button? => Padding(
padding: EdgeInsets.only(
bottom: FloatingNavigationBar.coverOf(context),
),
child: button,
),
null => null,
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final selection = TaskSelectionScope.maybeOf(context);
// Tasks being chosen (D14): their count in the title, an arrow ending
// it, as back does, and the bar of what to do with them at the foot.
final choosing = selection != null && selection.active;
return PopScope(
canPop: !choosing,
onPopInvokedWithResult: (popped, _) {
if (!popped) {
selection?.end();
}
},
child: TopGradient(
background: Theme.of(context).colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
leading: choosing
? IconButton(
tooltip: l10n.selectionEnd,
icon: const Icon(LucideIcons.arrowLeft),
onPressed: selection.end,
)
: null,
title: Text(
choosing ? l10n.selectionCount(selection.ids.length) : title,
),
actions: choosing
? const []
: [
if (search) const SearchButton(),
...actions,
const SizedBox(width: 4),
],
),
body: body,
bottomNavigationBar: choosing
? TaskSelectionBar(selection: selection)
: null,
// Clear of the navigation bar floating over the foot on a phone.
floatingActionButton: switch (floatingActionButton) {
final button? when !choosing => Padding(
padding: EdgeInsets.only(
bottom: FloatingNavigationBar.coverOf(context),
),
child: button,
),
_ => null,
},
),
),
);
}
}
/// A list's ⋮ holding Select tasks (D14), for a tab with no menu of its
/// own; a menu of its own adds [selectTasksEntry].
class const SelectTasksMenu({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => PopupMenuButton<VoidCallback>(
tooltip: AppLocalizations.of(context).tasksMenu,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) => action(),
itemBuilder: (context) => [selectTasksEntry(context)],
);
}
/// The menu entry starting to choose tasks (D14).
PopupMenuItem<VoidCallback> selectTasksEntry(BuildContext context) {
final selection = TaskSelectionScope.maybeOf(context);
return PopupMenuItem(
enabled: selection != null,
value: () => selection?.start(),
child: MenuEntry(
LucideIcons.squareCheck,
AppLocalizations.of(context).selectTasks,
),
);
}

View file

@ -29,6 +29,7 @@ class const TodayTab({super.key}) extends StatelessWidget {
final (:overdue, :due) = todayOf(state.tasks, today);
return TabScreen(
title: l10n.shellTabToday,
actions: const [SelectTasksMenu()],
floatingActionButton: AddTaskButton(
onPressed: () => showQuickAdd(context, day: today),
),

View file

@ -2,6 +2,7 @@ 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_frame.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -17,46 +18,74 @@ class const TodoShell({
/// The tabs' navigators, from `StatefulShellRoute`.
required final StatefulNavigationShell shell,
super.key,
}) extends StatelessWidget {
}) extends StatefulWidget {
@override
State<TodoShell> createState() => _TodoShellState();
}
class _TodoShellState() extends State<TodoShell> {
/// The tasks chosen to act on together (D14): while choosing, the bar of
/// what to do with them takes the navigation bar's place.
final _selection = TaskSelection();
@override
void dispose() {
_selection.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return WhatsNewOnUpdate(
child: NavigationFrame(
appName: l10n.appTitle,
icon: todoIcon,
selectedIndex: shell.currentIndex,
// A tab chosen again goes back to its first screen.
onSelected: (index) => shell.goBranch(
index,
initialLocation: index == shell.currentIndex,
final shell = widget.shell;
return TaskSelectionScope(
notifier: _selection,
child: ListenableBuilder(
listenable: _selection,
builder: (context, child) => WhatsNewOnUpdate(
child: NavigationFrame(
appName: l10n.appTitle,
icon: todoIcon,
selectedIndex: shell.currentIndex,
hidesNavigationBar: _selection.active,
// A tab chosen again goes back to its first screen; choosing tasks
// ends with the tab.
onSelected: (index) {
_selection.end();
shell.goBranch(
index,
initialLocation: index == shell.currentIndex,
);
},
destinations: [
(
label: l10n.shellTabInbox,
icon: const Icon(LucideIcons.inbox),
selectedIcon: null,
),
(
label: l10n.shellTabToday,
icon: const Icon(LucideIcons.calendarCheck),
selectedIcon: null,
),
(
label: l10n.shellTabUpcoming,
icon: const Icon(LucideIcons.calendarDays),
selectedIcon: null,
),
(
label: l10n.shellTabBrowse,
// The cog to Settings is atop Browse: the count of accounts
// waiting for whoever operates the instance shows on its tab.
icon: const WaitingAccountsBadge(
child: Icon(LucideIcons.layoutList),
),
selectedIcon: null,
),
],
child: child!,
),
),
destinations: [
(
label: l10n.shellTabInbox,
icon: const Icon(LucideIcons.inbox),
selectedIcon: null,
),
(
label: l10n.shellTabToday,
icon: const Icon(LucideIcons.calendarCheck),
selectedIcon: null,
),
(
label: l10n.shellTabUpcoming,
icon: const Icon(LucideIcons.calendarDays),
selectedIcon: null,
),
(
label: l10n.shellTabBrowse,
// The cog to Settings is atop Browse: the count of accounts
// waiting for whoever operates the instance shows on its tab.
icon: const WaitingAccountsBadge(
child: Icon(LucideIcons.layoutList),
),
selectedIcon: null,
),
],
// From expanded windows on, a task opens beside the list (D2).
child: TaskPane(child: shell),
),

View file

@ -118,6 +118,7 @@ class _UpcomingTabState() extends State<UpcomingTab> {
};
return TabScreen(
title: l10n.shellTabUpcoming,
actions: const [SelectTasksMenu()],
floatingActionButton: AddTaskButton(
onPressed: () => showQuickAdd(context, day: selected),
),

View file

@ -6,19 +6,29 @@ import 'package:material_ui/material_ui.dart';
/// Upcoming's week strip (ADR 0033, D8): the month, the week around the
/// [selected] day from the language's first weekday, a dot under each day
/// with tasks ([busy]), today marked, the days before it off; a day, the
/// weeks before and after, and the month (Material's calendar, any day
/// ahead) choose where the agenda below goes ([onSelect]).
/// with tasks ([busy]), today marked, the days before it off; a day and the
/// weeks before and after choose where the agenda below goes ([onSelect]).
///
/// The month's name, or its handle dragged down, unfolds the whole month in
/// place beneath it, the arrows then going by months; the handle dragged up
/// or the name again folds it (the developer's, 1 Nov, D14, as Todoist's).
/// Once another week shows, a button beside the name goes back to today.
class const WeekStrip({
required final Day selected,
required final Day today,
required final Set<Day> busy,
required final ValueChanged<Day> onSelect,
super.key,
}) extends StatelessWidget {
}) extends StatefulWidget {
/// The key of [day]'s button.
static Key dayKey(Day day) => ValueKey('week-strip-$day');
/// The key of the month's name, which unfolds the month.
static const monthKey = ValueKey('week-strip-month');
/// The key of the handle below, dragged to unfold or fold the month.
static const handleKey = ValueKey('week-strip-handle');
/// The first day of [day]'s week, the language's [firstWeekday] (Material's
/// count: 0 for Sunday) starting it.
static Day weekOf(Day day, int firstWeekday) {
@ -26,6 +36,22 @@ class const WeekStrip({
return day.plus(-((day.weekday - first + 7) % 7));
}
@override
State<WeekStrip> createState() => _WeekStripState();
}
class _WeekStripState() extends State<WeekStrip> {
var _month = false;
/// How far the handle was dragged, down positive.
var _dragged = 0.0;
void _setMonth(bool month) {
if (month != _month) {
setState(() => _month = month);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -33,73 +59,202 @@ class const WeekStrip({
final scheme = theme.colorScheme;
final locale = Localizations.localeOf(context).toString();
final firstWeekday = MaterialLocalizations.of(context).firstDayOfWeekIndex;
final start = weekOf(selected, firstWeekday);
final thisWeek = weekOf(today, firstWeekday);
final selected = widget.selected;
final today = widget.today;
final start = WeekStrip.weekOf(selected, firstWeekday);
final thisWeek = WeekStrip.weekOf(today, firstWeekday);
final firstOfMonth = Day(selected.year, selected.month, 1);
final firstOfThisMonth = Day(today.year, today.month, 1);
void selectFrom(Day day) => widget.onSelect(day < today ? today : day);
Day monthsOn(int months) =>
Day.of(DateTime(firstOfMonth.year, firstOfMonth.month + months));
Widget dayButton(Day day, {required bool inMonth}) => _DayButton(
key: WeekStrip.dayKey(day),
day: day,
selected: day == selected,
today: day == today,
busy: widget.busy.contains(day),
muted: !inMonth,
monthLabel: !inMonth && day.day == 1
? DateFormat.MMM(locale).format(day.toDate())
: null,
onTap: day < today ? null : () => widget.onSelect(day),
weekday: _month ? null : DateFormat.E(locale).format(day.toDate()),
label: DateFormat.MMMMEEEEd(locale).format(day.toDate()),
scheme: scheme,
);
final weeks = _month
? [
for (
var week = WeekStrip.weekOf(firstOfMonth, firstWeekday);
!(Day(
selected.year,
selected.month,
selected.daysInMonth,
) <
week);
week = week.plus(7)
)
week,
]
: [start];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
icon: const Icon(LucideIcons.chevronDown),
iconAlignment: IconAlignment.end,
label: Text(
DateFormat.yMMMM(locale).format(selected.toDate()),
style: theme.textTheme.titleMedium,
// The name and the way back to today together, taking what
// the arrows leave, the name shortened before anything else.
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: TextButton.icon(
key: WeekStrip.monthKey,
icon: Icon(
_month
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
),
iconAlignment: IconAlignment.end,
label: Text(
DateFormat.yMMMM(locale).format(selected.toDate()),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: _month ? scheme.primary : null,
),
),
onPressed: () => _setMonth(!_month),
),
),
if (start != thisWeek)
IconButton(
tooltip: l10n.weekToday,
icon: const Icon(LucideIcons.calendar1),
color: scheme.primary,
onPressed: () => widget.onSelect(today),
),
],
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: selected.toDate(),
firstDate: today.toDate(),
lastDate: DateTime(today.year + 20),
);
if (picked != null) {
onSelect(Day.of(picked));
}
},
),
const Spacer(),
IconButton(
tooltip: l10n.weekPrevious,
icon: const Icon(LucideIcons.chevronLeft),
onPressed: start > thisWeek
? () {
final day = start.plus(-7);
onSelect(day < today ? today : day);
}
: null,
),
IconButton(
tooltip: l10n.weekNext,
icon: const Icon(LucideIcons.chevronRight),
onPressed: () => onSelect(start.plus(7)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_month) ...[
IconButton(
tooltip: l10n.monthPrevious,
icon: const Icon(LucideIcons.chevronLeft),
onPressed: firstOfThisMonth < firstOfMonth
? () => selectFrom(monthsOn(-1))
: null,
),
IconButton(
tooltip: l10n.monthNext,
icon: const Icon(LucideIcons.chevronRight),
onPressed: () => selectFrom(monthsOn(1)),
),
] else ...[
IconButton(
tooltip: l10n.weekPrevious,
icon: const Icon(LucideIcons.chevronLeft),
onPressed: thisWeek < start
? () => selectFrom(start.plus(-7))
: null,
),
IconButton(
tooltip: l10n.weekNext,
icon: const Icon(LucideIcons.chevronRight),
onPressed: () => widget.onSelect(start.plus(7)),
),
],
],
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: Row(
children: [
for (var i = 0; i < 7; i++)
if (start.plus(i) case final day)
Expanded(
child: _DayButton(
key: dayKey(day),
day: day,
selected: day == selected,
today: day == today,
busy: busy.contains(day),
onTap: day < today ? null : () => onSelect(day),
weekday: DateFormat.E(locale).format(day.toDate()),
label: DateFormat.MMMMEEEEd(locale).format(day.toDate()),
scheme: scheme,
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
if (_month)
Row(
children: [
for (var i = 0; i < 7; i++)
Expanded(
child: Center(
child: Text(
DateFormat.E(
locale,
).format(weeks.first.plus(i).toDate()),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
),
],
),
],
for (final week in weeks)
Row(
children: [
for (var i = 0; i < 7; i++)
if (week.plus(i) case final day)
Expanded(
child: dayButton(
day,
inMonth: !_month || day.month == selected.month,
),
),
],
),
],
),
),
),
Semantics(
button: true,
label: _month ? l10n.monthHide : l10n.monthShow,
excludeSemantics: true,
child: GestureDetector(
key: WeekStrip.handleKey,
behavior: HitTestBehavior.opaque,
onTap: () => _setMonth(!_month),
onVerticalDragStart: (_) => _dragged = 0,
onVerticalDragUpdate: (details) => _dragged += details.delta.dy,
onVerticalDragEnd: (details) {
final pull = _dragged + (details.primaryVelocity ?? 0) / 10;
if (pull > 24) {
_setMonth(true);
} else if (pull < -24) {
_setMonth(false);
}
},
child: SizedBox(
height: 20,
child: Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
),
],
@ -113,22 +268,35 @@ class const _DayButton({
required final bool today,
required final bool busy,
required final VoidCallback? onTap,
required final String weekday,
required final String label,
required final ColorScheme scheme,
/// Its weekday's short name above it; none in the month, whose weekdays
/// head its columns.
final String? weekday,
/// Whether it lies outside the month shown.
final bool muted = false,
/// The month's short name above it, for the first of the next month.
final String? monthLabel,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final off = onTap == null;
final faint = scheme.onSurface.withValues(alpha: 0.38);
final color = selected
? scheme.onPrimary
: off
? scheme.onSurface.withValues(alpha: 0.38)
? faint
: today
? scheme.primary
: muted
? scheme.onSurfaceVariant
: scheme.onSurface;
final above = weekday ?? monthLabel;
return Semantics(
label: label,
selected: selected,
@ -142,14 +310,13 @@ class const _DayButton({
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
weekday,
style: text.labelSmall?.copyWith(
color: off
? scheme.onSurface.withValues(alpha: 0.38)
: scheme.onSurfaceVariant,
if (above != null || weekday == null)
Text(
above ?? '',
style: text.labelSmall?.copyWith(
color: off ? faint : scheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 4),
Container(
width: 36,