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:
parent
b908019969
commit
4960234a63
20 changed files with 3619 additions and 383 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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: {'.'},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ class _UpcomingTabState() extends State<UpcomingTab> {
|
|||
};
|
||||
return TabScreen(
|
||||
title: l10n.shellTabUpcoming,
|
||||
actions: const [SelectTasksMenu()],
|
||||
floatingActionButton: AddTaskButton(
|
||||
onPressed: () => showQuickAdd(context, day: selected),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue