feat(todo): Today laid out as a calendar, planned from its Plan by dragging, as Todoist's desktop Today (ADR 0033, D30)
The developer asked for Todoist's desktop Today planning: a day of hours and a Plan beside it, tasks dragged from it onto an hour. Today gets a Display, its layout alone (List or Calendar), kept in the person's sealed settings row as Upcoming's (`TodayView`, the payload's `today`, schema still 1). Its Calendar is one day of `HourGrid`; *Plan: n* in its app bar shows or hides the Plan (`PlanPanel`): Overdue with Reschedule, To plan (today's without a time) and Time-blocked (today's with one), each folding with its count, narrowed for the session by *All projects*' Filter by project (a search and a box a project, a sub-project with its parents' path). A row dropped on an hour is moved to today at that time, its duration kept; a block dropped on the Plan loses its time; both undone from the snack bar, through `moveTask`. From a medium width of Today's own on the Plan stands beside the day, shown or hidden as remembered; on a phone it is a sheet over the day's foot (the developer's choice, over the Plan on wide windows alone and over a strip above the day), part of the screen rather than a modal route, so that the day behind it takes the drop, and it lowers to its heading while one of its rows is held. Whats-New: Today's Display offers a Calendar layout: today's hours, with a Plan of what is overdue, to plan and time-blocked, whose tasks you drag onto an hour. Whats-New-De: Die Anzeige von Heute bietet das Layout Kalender: die Stunden des Tages, mit einem Plan aus Überfälligem, Zu planen und Eingeplant, dessen Aufgaben du auf eine Stunde ziehst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c53ce30ff5
commit
574b379f11
17 changed files with 1775 additions and 196 deletions
File diff suppressed because one or more lines are too long
|
|
@ -257,5 +257,18 @@
|
|||
"monthMore": "{count} weitere",
|
||||
"dayHeadingPattern": "EEEE, d. MMM",
|
||||
"overdueHide": "Überfälliges ausblenden",
|
||||
"overdueShow": "Überfälliges einblenden"
|
||||
"overdueShow": "Überfälliges einblenden",
|
||||
"planButton": "Plan: {count}",
|
||||
"planTitle": "Plan",
|
||||
"planShow": "Plan einblenden",
|
||||
"planHide": "Plan ausblenden",
|
||||
"planToPlan": "Zu planen",
|
||||
"planTimeBlocked": "Eingeplant",
|
||||
"planEmpty": "Nichts zu planen",
|
||||
"planDropHint": "Hier ablegen, um die Uhrzeit zu entfernen",
|
||||
"planGroupHide": "{group} ausblenden",
|
||||
"planGroupShow": "{group} einblenden",
|
||||
"planAllProjects": "Alle Projekte",
|
||||
"planProjectsCount": "{count, plural, =1{1 Projekt} other{{count} Projekte}}",
|
||||
"planFilterTitle": "Nach Projekt filtern"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -826,5 +826,77 @@
|
|||
"description": "intl's DateFormat pattern of a day's heading in Today and Upcoming, as Todoist's (\"Tuesday, 3 Nov\"): EEEE the weekday, d the day, MMM the short month."
|
||||
},
|
||||
"overdueHide": "Hide what is overdue",
|
||||
"overdueShow": "Show what is overdue"
|
||||
"overdueShow": "Show what is overdue",
|
||||
"planButton": "Plan: {count}",
|
||||
"@planButton": {
|
||||
"description": "Today's app bar in its Calendar layout (ADR 0033, D30, as Todoist's): shows or hides the Plan, counting the tasks it holds.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planTitle": "Plan",
|
||||
"@planTitle": {
|
||||
"description": "Today's Plan beside or below its day of hours (ADR 0033, D30)."
|
||||
},
|
||||
"planShow": "Show the Plan",
|
||||
"@planShow": {
|
||||
"description": "Tooltip of Today's Plan button while the Plan is hidden (ADR 0033, D30)."
|
||||
},
|
||||
"planHide": "Hide the Plan",
|
||||
"@planHide": {
|
||||
"description": "Tooltip of Today's Plan button and the Plan's close button while it shows (ADR 0033, D30)."
|
||||
},
|
||||
"planToPlan": "To plan",
|
||||
"@planToPlan": {
|
||||
"description": "Today's Plan (ADR 0033, D30): the group of today's tasks without a time, to be given one."
|
||||
},
|
||||
"planTimeBlocked": "Time-blocked",
|
||||
"@planTimeBlocked": {
|
||||
"description": "Today's Plan (ADR 0033, D30, Todoist's word): the group of today's tasks with a time."
|
||||
},
|
||||
"planEmpty": "Nothing to plan",
|
||||
"@planEmpty": {
|
||||
"description": "Today's Plan (ADR 0033, D30) while it holds no task."
|
||||
},
|
||||
"planDropHint": "Drop here to take its time away",
|
||||
"@planDropHint": {
|
||||
"description": "Today's Plan (ADR 0033, D30) while a block of the day is dragged over it."
|
||||
},
|
||||
"planGroupHide": "Hide {group}",
|
||||
"@planGroupHide": {
|
||||
"description": "Tooltip folding a group of Today's Plan (ADR 0033, D30).",
|
||||
"placeholders": {
|
||||
"group": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planGroupShow": "Show {group}",
|
||||
"@planGroupShow": {
|
||||
"description": "Tooltip unfolding a group of Today's Plan (ADR 0033, D30).",
|
||||
"placeholders": {
|
||||
"group": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planAllProjects": "All projects",
|
||||
"@planAllProjects": {
|
||||
"description": "Today's Plan's project filter (ADR 0033, D30) while it keeps every project, and the button putting it back."
|
||||
},
|
||||
"planProjectsCount": "{count, plural, =1{1 project} other{{count} projects}}",
|
||||
"@planProjectsCount": {
|
||||
"description": "Today's Plan's project filter (ADR 0033, D30) while it keeps several projects.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planFilterTitle": "Filter by project",
|
||||
"@planFilterTitle": {
|
||||
"description": "Today's Plan's project filter (ADR 0033, D30): its search field's hint and its name."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1647,6 +1647,84 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Show what is overdue'**
|
||||
String get overdueShow;
|
||||
|
||||
/// Today's app bar in its Calendar layout (ADR 0033, D30, as Todoist's): shows or hides the Plan, counting the tasks it holds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan: {count}'**
|
||||
String planButton(int count);
|
||||
|
||||
/// Today's Plan beside or below its day of hours (ADR 0033, D30).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan'**
|
||||
String get planTitle;
|
||||
|
||||
/// Tooltip of Today's Plan button while the Plan is hidden (ADR 0033, D30).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show the Plan'**
|
||||
String get planShow;
|
||||
|
||||
/// Tooltip of Today's Plan button and the Plan's close button while it shows (ADR 0033, D30).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide the Plan'**
|
||||
String get planHide;
|
||||
|
||||
/// Today's Plan (ADR 0033, D30): the group of today's tasks without a time, to be given one.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To plan'**
|
||||
String get planToPlan;
|
||||
|
||||
/// Today's Plan (ADR 0033, D30, Todoist's word): the group of today's tasks with a time.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Time-blocked'**
|
||||
String get planTimeBlocked;
|
||||
|
||||
/// Today's Plan (ADR 0033, D30) while it holds no task.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing to plan'**
|
||||
String get planEmpty;
|
||||
|
||||
/// Today's Plan (ADR 0033, D30) while a block of the day is dragged over it.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drop here to take its time away'**
|
||||
String get planDropHint;
|
||||
|
||||
/// Tooltip folding a group of Today's Plan (ADR 0033, D30).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide {group}'**
|
||||
String planGroupHide(String group);
|
||||
|
||||
/// Tooltip unfolding a group of Today's Plan (ADR 0033, D30).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show {group}'**
|
||||
String planGroupShow(String group);
|
||||
|
||||
/// Today's Plan's project filter (ADR 0033, D30) while it keeps every project, and the button putting it back.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All projects'**
|
||||
String get planAllProjects;
|
||||
|
||||
/// Today's Plan's project filter (ADR 0033, D30) while it keeps several projects.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 project} other{{count} projects}}'**
|
||||
String planProjectsCount(int count);
|
||||
|
||||
/// Today's Plan's project filter (ADR 0033, D30): its search field's hint and its name.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filter by project'**
|
||||
String get planFilterTitle;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1019,4 +1019,57 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get overdueShow => 'Überfälliges einblenden';
|
||||
|
||||
@override
|
||||
String planButton(int count) {
|
||||
return 'Plan: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planTitle => 'Plan';
|
||||
|
||||
@override
|
||||
String get planShow => 'Plan einblenden';
|
||||
|
||||
@override
|
||||
String get planHide => 'Plan ausblenden';
|
||||
|
||||
@override
|
||||
String get planToPlan => 'Zu planen';
|
||||
|
||||
@override
|
||||
String get planTimeBlocked => 'Eingeplant';
|
||||
|
||||
@override
|
||||
String get planEmpty => 'Nichts zu planen';
|
||||
|
||||
@override
|
||||
String get planDropHint => 'Hier ablegen, um die Uhrzeit zu entfernen';
|
||||
|
||||
@override
|
||||
String planGroupHide(String group) {
|
||||
return '$group ausblenden';
|
||||
}
|
||||
|
||||
@override
|
||||
String planGroupShow(String group) {
|
||||
return '$group einblenden';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planAllProjects => 'Alle Projekte';
|
||||
|
||||
@override
|
||||
String planProjectsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Projekte',
|
||||
one: '1 Projekt',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planFilterTitle => 'Nach Projekt filtern';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,59 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get overdueShow => 'Show what is overdue';
|
||||
|
||||
@override
|
||||
String planButton(int count) {
|
||||
return 'Plan: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planTitle => 'Plan';
|
||||
|
||||
@override
|
||||
String get planShow => 'Show the Plan';
|
||||
|
||||
@override
|
||||
String get planHide => 'Hide the Plan';
|
||||
|
||||
@override
|
||||
String get planToPlan => 'To plan';
|
||||
|
||||
@override
|
||||
String get planTimeBlocked => 'Time-blocked';
|
||||
|
||||
@override
|
||||
String get planEmpty => 'Nothing to plan';
|
||||
|
||||
@override
|
||||
String get planDropHint => 'Drop here to take its time away';
|
||||
|
||||
@override
|
||||
String planGroupHide(String group) {
|
||||
return 'Hide $group';
|
||||
}
|
||||
|
||||
@override
|
||||
String planGroupShow(String group) {
|
||||
return 'Show $group';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planAllProjects => 'All projects';
|
||||
|
||||
@override
|
||||
String planProjectsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count projects',
|
||||
one: '1 project',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planFilterTitle => 'Filter by project';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ final class const DisplayTarget({
|
|||
/// The sortings it offers; manual first, named [manualText].
|
||||
final List<TaskSorting> sortings = TaskSorting.values,
|
||||
final String Function(AppLocalizations l10n)? manualText,
|
||||
|
||||
/// Whether it sets its layout alone (Today's, D30): no done tasks,
|
||||
/// sorting nor filters.
|
||||
final bool layoutOnly = false,
|
||||
});
|
||||
|
||||
bool _never(TodoState state) => false;
|
||||
|
|
@ -153,6 +157,38 @@ final upcomingDisplay = DisplayTarget(
|
|||
},
|
||||
);
|
||||
|
||||
/// Today's Display (ADR 0033, D30, as Todoist's): its layout alone, List
|
||||
/// or Calendar; kept in the person's settings as Upcoming's.
|
||||
final todayDisplay = DisplayTarget(
|
||||
viewOf: (_) => const ProjectView(),
|
||||
write: (_, _, _) async {},
|
||||
isDefault: (state) => state.settings.today.isDefault,
|
||||
reset: (todo, state) => todo.settings.write(
|
||||
state.settings.copyWith(
|
||||
today: state.settings.today.copyWith(layout: TodayLayout.list),
|
||||
),
|
||||
),
|
||||
layoutOnly: true,
|
||||
layout: (context, state, apply) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final today = state.settings.today;
|
||||
return [
|
||||
LayoutPicker<TodayLayout>(
|
||||
selected: today.layout,
|
||||
segments: [
|
||||
(TodayLayout.list, LucideIcons.list, l10n.layoutList),
|
||||
(TodayLayout.calendar, LucideIcons.calendarDays, l10n.layoutCalendar),
|
||||
],
|
||||
onPicked: (layout) => apply(
|
||||
(todo) => todo.settings.write(
|
||||
state.settings.copyWith(today: today.copyWith(layout: layout)),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _writeUpcoming(
|
||||
TodoPerson todo,
|
||||
TodoState state,
|
||||
|
|
@ -256,135 +292,137 @@ class const _DisplaySheet({required final DisplayTarget target})
|
|||
children: [
|
||||
heading(l10n.layoutTitle),
|
||||
RoundedGroup(children: target.layout(context, state, apply)),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.circleCheck),
|
||||
title: Text(l10n.displayCompleted),
|
||||
value: view.completed,
|
||||
onChanged: (on) => set(view.copyWith(completed: on)),
|
||||
),
|
||||
if (target.occurrences(state))
|
||||
if (!target.layoutOnly) ...[
|
||||
RoundedGroup(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.repeat),
|
||||
title: Text(l10n.displayOccurrences),
|
||||
value: view.occurrences,
|
||||
onChanged: (on) => set(view.copyWith(occurrences: on)),
|
||||
secondary: const Icon(LucideIcons.circleCheck),
|
||||
title: Text(l10n.displayCompleted),
|
||||
value: view.completed,
|
||||
onChanged: (on) => set(view.copyWith(completed: on)),
|
||||
),
|
||||
],
|
||||
),
|
||||
heading(l10n.displaySort),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
if (target.grouping(state))
|
||||
if (target.occurrences(state))
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.repeat),
|
||||
title: Text(l10n.displayOccurrences),
|
||||
value: view.occurrences,
|
||||
onChanged: (on) => set(view.copyWith(occurrences: on)),
|
||||
),
|
||||
],
|
||||
),
|
||||
heading(l10n.displaySort),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
if (target.grouping(state))
|
||||
choice(
|
||||
LucideIcons.group,
|
||||
l10n.displayGrouping,
|
||||
groupingText(l10n, view.grouping),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displayGrouping,
|
||||
TaskGrouping.values,
|
||||
view.grouping,
|
||||
(grouping) => groupingText(l10n, grouping),
|
||||
)
|
||||
case final grouping?) {
|
||||
await set(view.copyWith(grouping: grouping));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.group,
|
||||
l10n.displayGrouping,
|
||||
groupingText(l10n, view.grouping),
|
||||
LucideIcons.arrowUpDown,
|
||||
l10n.displaySorting,
|
||||
sortingName(view.sorting),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displayGrouping,
|
||||
TaskGrouping.values,
|
||||
view.grouping,
|
||||
(grouping) => groupingText(l10n, grouping),
|
||||
l10n.displaySorting,
|
||||
target.sortings,
|
||||
view.sorting,
|
||||
sortingName,
|
||||
)
|
||||
case final grouping?) {
|
||||
await set(view.copyWith(grouping: grouping));
|
||||
case final sorting?) {
|
||||
await set(view.copyWith(sorting: sorting));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.arrowUpDown,
|
||||
l10n.displaySorting,
|
||||
sortingName(view.sorting),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displaySorting,
|
||||
target.sortings,
|
||||
view.sorting,
|
||||
sortingName,
|
||||
)
|
||||
case final sorting?) {
|
||||
await set(view.copyWith(sorting: sorting));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
heading(l10n.displayFilter),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
if (target.dateFilter)
|
||||
],
|
||||
),
|
||||
heading(l10n.displayFilter),
|
||||
RoundedGroup(
|
||||
children: [
|
||||
if (target.dateFilter)
|
||||
choice(
|
||||
LucideIcons.calendar,
|
||||
l10n.dateTitle,
|
||||
rangeText(l10n, view.date),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.dateTitle,
|
||||
DayRange.values,
|
||||
view.date,
|
||||
(range) => rangeText(l10n, range),
|
||||
)
|
||||
case final range?) {
|
||||
await set(view.copyWith(date: range));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.calendar,
|
||||
l10n.dateTitle,
|
||||
rangeText(l10n, view.date),
|
||||
LucideIcons.target,
|
||||
l10n.displayDeadline,
|
||||
rangeText(l10n, view.deadline, deadline: true),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.dateTitle,
|
||||
l10n.displayDeadline,
|
||||
DayRange.values,
|
||||
view.date,
|
||||
(range) => rangeText(l10n, range),
|
||||
view.deadline,
|
||||
(range) => rangeText(l10n, range, deadline: true),
|
||||
)
|
||||
case final range?) {
|
||||
await set(view.copyWith(date: range));
|
||||
await set(view.copyWith(deadline: range));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.target,
|
||||
l10n.displayDeadline,
|
||||
rangeText(l10n, view.deadline, deadline: true),
|
||||
() async {
|
||||
if (await _pickOne(
|
||||
context,
|
||||
l10n.displayDeadline,
|
||||
DayRange.values,
|
||||
view.deadline,
|
||||
(range) => rangeText(l10n, range, deadline: true),
|
||||
)
|
||||
case final range?) {
|
||||
await set(view.copyWith(deadline: range));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.flag,
|
||||
l10n.priorityTitle,
|
||||
view.priorities.isEmpty
|
||||
? l10n.displayAll
|
||||
: [
|
||||
for (final priority in Priority.values)
|
||||
if (view.priorities.contains(priority))
|
||||
priorityText(l10n, priority),
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await _pickPriorities(context, view.priorities)
|
||||
case final picked?) {
|
||||
await set(view.copyWith(priorities: picked));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.tag,
|
||||
l10n.displayLabel,
|
||||
view.labelIds.isEmpty
|
||||
? l10n.displayAny
|
||||
: [
|
||||
for (final id in view.labelIds) ?labels[id],
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await pickLabels(context, [...view.labelIds])
|
||||
case final picked?) {
|
||||
await set(view.copyWith(labelIds: {...picked}));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
choice(
|
||||
LucideIcons.flag,
|
||||
l10n.priorityTitle,
|
||||
view.priorities.isEmpty
|
||||
? l10n.displayAll
|
||||
: [
|
||||
for (final priority in Priority.values)
|
||||
if (view.priorities.contains(priority))
|
||||
priorityText(l10n, priority),
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await _pickPriorities(context, view.priorities)
|
||||
case final picked?) {
|
||||
await set(view.copyWith(priorities: picked));
|
||||
}
|
||||
},
|
||||
),
|
||||
choice(
|
||||
LucideIcons.tag,
|
||||
l10n.displayLabel,
|
||||
view.labelIds.isEmpty
|
||||
? l10n.displayAny
|
||||
: [
|
||||
for (final id in view.labelIds) ?labels[id],
|
||||
].join(', '),
|
||||
() async {
|
||||
if (await pickLabels(context, [...view.labelIds])
|
||||
case final picked?) {
|
||||
await set(view.copyWith(labelIds: {...picked}));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
footer: Row(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ ByteData encodeSettings(TodoSettings settings) =>
|
|||
encodeJson(settingsSchemaVersion, {
|
||||
if (!settings.upcoming.isDefault)
|
||||
'upcoming': _encodeUpcoming(settings.upcoming),
|
||||
if (settings.today != const TodayView())
|
||||
'today': {
|
||||
if (settings.today.layout != TodayLayout.list)
|
||||
'layout': settings.today.layout.name,
|
||||
if (!settings.today.plan) 'plan': false,
|
||||
},
|
||||
if (settings.tasks != const TaskSettings())
|
||||
'tasks': {
|
||||
'weekStart': ?settings.tasks.weekStart,
|
||||
|
|
@ -40,11 +46,22 @@ TodoSettings? decodeSettings(ByteData payload) => decodeJson(
|
|||
name: 'Settings',
|
||||
read: (json) => TodoSettings(
|
||||
upcoming: _decodeUpcoming(json['upcoming']),
|
||||
today: _decodeToday(json['today']),
|
||||
tasks: _decodeTasks(json['tasks']),
|
||||
reminders: _decodeReminders(json['reminders']),
|
||||
),
|
||||
);
|
||||
|
||||
TodayView _decodeToday(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const TodayView();
|
||||
}
|
||||
return TodayView(
|
||||
layout: TodayLayout.values.asNameMap()[json['layout']] ?? TodayLayout.list,
|
||||
plan: json['plan'] != false,
|
||||
);
|
||||
}
|
||||
|
||||
TaskSettings _decodeTasks(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const TaskSettings();
|
||||
|
|
|
|||
|
|
@ -43,6 +43,33 @@ final class const UpcomingView({
|
|||
List<Object?> get props => [layout, coloring, filters];
|
||||
}
|
||||
|
||||
/// How Today lays out its tasks (ADR 0033, D30, as Todoist's).
|
||||
enum TodayLayout() {
|
||||
/// What is overdue, then today's, as a list.
|
||||
list,
|
||||
|
||||
/// Today's hours, its timed tasks as blocks, beside the Plan.
|
||||
calendar,
|
||||
}
|
||||
|
||||
/// Today's Display (D30): its [layout], and whether its Plan stands beside
|
||||
/// the day where the window is wide enough ([plan]; a phone's opens as a
|
||||
/// sheet when asked).
|
||||
final class const TodayView({
|
||||
final TodayLayout layout = TodayLayout.list,
|
||||
final bool plan = true,
|
||||
}) with Equatable {
|
||||
/// Whether its Display is as at first: the Plan beside it doesn't count,
|
||||
/// being shown or hidden from Today's app bar.
|
||||
bool get isDefault => layout == TodayLayout.list;
|
||||
|
||||
TodayView copyWith({TodayLayout? layout, bool? plan}) =>
|
||||
TodayView(layout: layout ?? this.layout, plan: plan ?? this.plan);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [layout, plan];
|
||||
}
|
||||
|
||||
/// The person's settings of tasks (ADR 0033, D4).
|
||||
final class const TaskSettings({
|
||||
/// The weekday weeks start on (`DateTime.monday` to `sunday`); none: the
|
||||
|
|
@ -82,23 +109,26 @@ final class const ReminderSettings({
|
|||
}
|
||||
|
||||
/// The person's settings of Todo's (D4, D17): one sealed row of theirs,
|
||||
/// synced to every device (`todo_settings`): [upcoming]'s Display, and
|
||||
/// those of [tasks] and [reminders].
|
||||
/// synced to every device (`todo_settings`): [upcoming]'s and [today]'s
|
||||
/// Displays, and the settings of [tasks] and [reminders].
|
||||
final class const TodoSettings({
|
||||
final UpcomingView upcoming = const UpcomingView(),
|
||||
final TodayView today = const TodayView(),
|
||||
final TaskSettings tasks = const TaskSettings(),
|
||||
final ReminderSettings reminders = const ReminderSettings(),
|
||||
}) with Equatable {
|
||||
TodoSettings copyWith({
|
||||
UpcomingView? upcoming,
|
||||
TodayView? today,
|
||||
TaskSettings? tasks,
|
||||
ReminderSettings? reminders,
|
||||
}) => TodoSettings(
|
||||
upcoming: upcoming ?? this.upcoming,
|
||||
today: today ?? this.today,
|
||||
tasks: tasks ?? this.tasks,
|
||||
reminders: reminders ?? this.reminders,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [upcoming, tasks, reminders];
|
||||
List<Object?> get props => [upcoming, today, tasks, reminders];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,43 @@ List<OpenTask> subTasksOf(List<OpenTask> tasks, String parentId) => [
|
|||
return (overdue: overdue, due: due..sort(byTime));
|
||||
}
|
||||
|
||||
/// What Today's Plan holds (ADR 0033, D30, as Todoist's): the `overdue`
|
||||
/// tasks, today's without a time `toPlan`, and today's with one `timed`.
|
||||
typedef TodayPlan = ({
|
||||
List<OpenTask> overdue,
|
||||
List<OpenTask> toPlan,
|
||||
List<OpenTask> timed,
|
||||
});
|
||||
|
||||
/// [TodayPlan] on [today]: the overdue tasks oldest first, today's by
|
||||
/// time; none done. Where [projectIds] names projects, only their tasks;
|
||||
/// none named keeps every project's.
|
||||
TodayPlan planOf(
|
||||
List<OpenTask> tasks,
|
||||
Day today, {
|
||||
Set<String> projectIds = const {},
|
||||
}) {
|
||||
final (:overdue, :due) = todayOf(tasks, today);
|
||||
bool kept(OpenTask task) =>
|
||||
projectIds.isEmpty || projectIds.contains(task.content.projectId);
|
||||
return (
|
||||
overdue: overdue.where(kept).toList(),
|
||||
toPlan: [
|
||||
for (final task in due)
|
||||
if (kept(task) && task.content.date?.time == null) task,
|
||||
],
|
||||
timed: [
|
||||
for (final task in due)
|
||||
if (kept(task) && task.content.date?.time != null) task,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// How many tasks the plan holds, as Today's Plan button counts them.
|
||||
extension PlanCount on TodayPlan {
|
||||
int get count => overdue.length + toPlan.length + timed.length;
|
||||
}
|
||||
|
||||
/// What Upcoming shows from [today] on (ADR 0033, D8): the overdue tasks,
|
||||
/// then each of the [days] days with its tasks (none for a day without),
|
||||
/// done ones too where [completed].
|
||||
|
|
|
|||
|
|
@ -249,10 +249,12 @@ class _TaskDropTargetState() extends State<TaskDropTarget> {
|
|||
/// A task's row dragged after a long press (D16), into another place in
|
||||
/// its project, or a little to the right to become a sub-task, the list
|
||||
/// scrolling at its edges, and a board's columns too at theirs (D17); not
|
||||
/// while tasks are being chosen.
|
||||
/// while tasks are being chosen. [onDragging] hears when its drag starts
|
||||
/// (true) and ends (false), as Today's Plan on a phone steps aside (D30).
|
||||
class const DraggableTask({
|
||||
required final OpenTask task,
|
||||
required final Widget child,
|
||||
final ValueChanged<bool>? onDragging,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -270,11 +272,14 @@ class _DraggableTaskState() extends State<DraggableTask> {
|
|||
scroller.stopAutoScroll();
|
||||
}
|
||||
_scrollers = [];
|
||||
widget.onDragging?.call(false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
for (final scroller in _scrollers) {
|
||||
scroller.stopAutoScroll();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +296,7 @@ class _DraggableTaskState() extends State<DraggableTask> {
|
|||
data: _drag,
|
||||
dragAnchorStrategy: pointerDragAnchorStrategy,
|
||||
onDragStarted: () {
|
||||
widget.onDragging?.call(true);
|
||||
_scrollers = [
|
||||
for (
|
||||
var scrollable = Scrollable.maybeOf(context);
|
||||
|
|
|
|||
436
apps/todo/halkyon_todo_flutter/lib/src/tabs/plan_panel.dart
Normal file
436
apps/todo/halkyon_todo_flutter/lib/src/tabs/plan_panel.dart
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.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_search.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A group of Today's Plan (D30).
|
||||
enum _PlanGroup() {
|
||||
overdue,
|
||||
toPlan,
|
||||
timed,
|
||||
}
|
||||
|
||||
/// Today's Plan (ADR 0033, D30, as Todoist's desktop Today): beside the day
|
||||
/// of hours from medium widths on, a sheet over its foot on a phone. Its
|
||||
/// tasks ([planOf]) in three groups, each folding under its heading with its
|
||||
/// count: *Overdue* with Reschedule, *To plan* (today's without a time) and
|
||||
/// *Time-blocked* (today's with one), narrowed to the projects [projectIds]
|
||||
/// names (none: all), picked from *All projects* ([onProjects]). A row held
|
||||
/// and dropped on an hour of the day goes there, today; a block of the day
|
||||
/// dropped on the Plan loses its time and stays today ([moveTask], which can
|
||||
/// be undone). [onDragging] hears when one of its rows is being dragged, as
|
||||
/// the phone's sheet then steps aside; [onClose] closes it where given.
|
||||
class const PlanPanel({
|
||||
required final Set<String> projectIds,
|
||||
required final ValueChanged<Set<String>> onProjects,
|
||||
final ValueChanged<bool>? onDragging,
|
||||
final VoidCallback? onClose,
|
||||
final VoidCallback? onHeaderTap,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// The key of the Plan, where a block dropped loses its time.
|
||||
static const dropKey = ValueKey('plan-drop');
|
||||
|
||||
/// The key of the button picking the projects shown.
|
||||
static const projectsKey = ValueKey('plan-projects');
|
||||
|
||||
@override
|
||||
State<PlanPanel> createState() => _PlanPanelState();
|
||||
}
|
||||
|
||||
class _PlanPanelState() extends State<PlanPanel> {
|
||||
/// The groups folded away, for as long as the Plan shows.
|
||||
final _folded = <_PlanGroup>{};
|
||||
|
||||
/// Whether one of the Plan's own rows is being dragged: dropped back on
|
||||
/// it, nothing changes.
|
||||
bool _dragging = false;
|
||||
|
||||
void _drag(bool dragging) {
|
||||
if (dragging != _dragging) {
|
||||
setState(() => _dragging = dragging);
|
||||
}
|
||||
widget.onDragging?.call(dragging);
|
||||
}
|
||||
|
||||
@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 plan = planOf(state.tasks, today, projectIds: widget.projectIds);
|
||||
bool timedToday(String id) => state.tasks.any(
|
||||
(task) =>
|
||||
task.id == id &&
|
||||
!task.content.completed &&
|
||||
task.content.date?.day == today &&
|
||||
task.content.date?.time != null,
|
||||
);
|
||||
final groups = [
|
||||
(_PlanGroup.overdue, l10n.overdue, plan.overdue),
|
||||
(_PlanGroup.toPlan, l10n.planToPlan, plan.toPlan),
|
||||
(_PlanGroup.timed, l10n.planTimeBlocked, plan.timed),
|
||||
];
|
||||
return DragTarget<TaskDrag>(
|
||||
key: PlanPanel.dropKey,
|
||||
// A block of the day, not one of the Plan's own rows dropped back.
|
||||
onWillAcceptWithDetails: (details) =>
|
||||
!_dragging && timedToday(details.data.id),
|
||||
onAcceptWithDetails: (details) => unawaited(
|
||||
moveTask(context, details.data.id, today, allDay: true),
|
||||
),
|
||||
builder: (context, candidates, _) => Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_PlanHeading(
|
||||
count: plan.count,
|
||||
projectIds: widget.projectIds,
|
||||
onProjects: widget.onProjects,
|
||||
onClose: widget.onClose,
|
||||
onTap: widget.onHeaderTap,
|
||||
),
|
||||
Expanded(
|
||||
child: plan.count == 0
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.planEmpty,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 16 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
children: [
|
||||
for (final (group, title, tasks) in groups)
|
||||
if (tasks.isNotEmpty) ...[
|
||||
_GroupHeading(
|
||||
title: title,
|
||||
count: tasks.length,
|
||||
folded: _folded.contains(group),
|
||||
onFold: () => setState(() {
|
||||
if (!_folded.remove(group)) {
|
||||
_folded.add(group);
|
||||
}
|
||||
}),
|
||||
action: group == _PlanGroup.overdue
|
||||
? TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: paletteText(
|
||||
context,
|
||||
PaletteColor.cerise,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
reschedule(context, tasks),
|
||||
child: Text(l10n.reschedule),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (!_folded.contains(group))
|
||||
...taskGroup([
|
||||
for (final task in tasks)
|
||||
(position) => DraggableTask(
|
||||
key: ValueKey('plan-${task.id}'),
|
||||
task: task,
|
||||
onDragging: _drag,
|
||||
child: TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
selectable: false,
|
||||
position: position,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (candidates.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 64),
|
||||
child: Text(
|
||||
l10n.planDropHint,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Plan's heading: its name and count, the projects it shows, and
|
||||
/// where given its close button; [onTap] where the heading itself is tapped.
|
||||
class const _PlanHeading({
|
||||
required final int count,
|
||||
required final Set<String> projectIds,
|
||||
required final ValueChanged<Set<String>> onProjects,
|
||||
final VoidCallback? onClose,
|
||||
final VoidCallback? onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final label = switch (projectIds.length) {
|
||||
0 => l10n.planAllProjects,
|
||||
1 => _projectName(l10n, state.projectOf(projectIds.single)),
|
||||
final many => l10n.planProjectsCount(many),
|
||||
};
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(l10n.planTitle, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
flex: 4,
|
||||
child: TextButton.icon(
|
||||
key: PlanPanel.projectsKey,
|
||||
iconAlignment: IconAlignment.end,
|
||||
icon: const Icon(LucideIcons.chevronDown, size: 18),
|
||||
label: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onPressed: () =>
|
||||
_pickProjects(context, projectIds, onProjects),
|
||||
),
|
||||
),
|
||||
if (onClose case final close?)
|
||||
IconButton(
|
||||
tooltip: l10n.planHide,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: close,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's heading in the Plan: a chevron folding it, its name and count,
|
||||
/// and an [action] at its end.
|
||||
class const _GroupHeading({
|
||||
required final String title,
|
||||
required final int count,
|
||||
required final bool folded,
|
||||
required final VoidCallback onFold,
|
||||
final Widget? action,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: folded
|
||||
? l10n.planGroupShow(title)
|
||||
: l10n.planGroupHide(title),
|
||||
icon: Icon(
|
||||
folded ? LucideIcons.chevronRight : LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: onFold,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [project]'s name, the Inbox's in the app's language.
|
||||
String _projectName(AppLocalizations l10n, StoredProject? project) =>
|
||||
switch (project) {
|
||||
StoredProject(inbox: true) => l10n.shellTabInbox,
|
||||
final project? => project.content?.name ?? '',
|
||||
null => '',
|
||||
};
|
||||
|
||||
/// Asks which projects the Plan shows, as Todoist's *Filter by project*: a
|
||||
/// field searching their names and a box to tick for each, the Inbox first,
|
||||
/// a sub-project under the path of its parents (D28); each tick applied at
|
||||
/// once through [onProjects], none ticked showing all.
|
||||
Future<void> _pickProjects(
|
||||
BuildContext context,
|
||||
Set<String> current,
|
||||
ValueChanged<Set<String>> onProjects,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.read<TodoCubit>().state;
|
||||
final inbox = state.projects.where((project) => project.inbox).firstOrNull;
|
||||
final choices = [
|
||||
if (inbox != null) (project: inbox, path: null as String?),
|
||||
for (final node in projectTree(state.projects))
|
||||
(project: node.project, path: _pathOf(state, node.project)),
|
||||
];
|
||||
var picked = {...current};
|
||||
var query = '';
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final words = folded(query.trim());
|
||||
final shown = [
|
||||
for (final choice in choices)
|
||||
if (words.isEmpty ||
|
||||
folded(_projectName(l10n, choice.project)).contains(words))
|
||||
choice,
|
||||
];
|
||||
void set(Set<String> ids) {
|
||||
setState(() => picked = ids);
|
||||
onProjects(ids);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(l10n.planFilterTitle),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
hintText: l10n.planFilterTitle,
|
||||
),
|
||||
onChanged: (text) => setState(() => query = text),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final (:project, :path) in shown)
|
||||
CheckboxListTile(
|
||||
secondary: Icon(
|
||||
project.inbox
|
||||
? LucideIcons.inbox
|
||||
: LucideIcons.hash,
|
||||
color: switch (project.content?.color) {
|
||||
final color? => paletteText(context, color),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
title: Text(_projectName(l10n, project)),
|
||||
subtitle: path == null ? null : Text(path),
|
||||
value: picked.contains(project.id),
|
||||
onChanged: (on) => set(
|
||||
(on ?? false)
|
||||
? {...picked, project.id}
|
||||
: ({...picked}..remove(project.id)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: picked.isEmpty ? null : () => set({}),
|
||||
child: Text(l10n.planAllProjects),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.displayDone),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The path of [project]'s parents, outermost first ("Work / Clients");
|
||||
/// null for a project of its own. Stops at a parent already passed, as
|
||||
/// parents run in a circle where two devices moved each under the other.
|
||||
String? _pathOf(TodoState state, StoredProject project) {
|
||||
final names = <String>[];
|
||||
final passed = {project.id};
|
||||
var parentId = project.content?.parentId;
|
||||
while (parentId != null && passed.add(parentId)) {
|
||||
final parent = state.projectOf(parentId);
|
||||
if (parent == null || parent.inbox || parent.content == null) {
|
||||
break;
|
||||
}
|
||||
names.insert(0, parent.content!.name);
|
||||
parentId = parent.content!.parentId;
|
||||
}
|
||||
return names.isEmpty ? null : names.join(' / ');
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.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';
|
||||
|
|
@ -11,19 +14,32 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dar
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Today (ADR 0033, D2, D8), where the app opens: at most two groups, as
|
||||
/// Todoist's (the developer's review, 3 Nov 2026): what is overdue, folding
|
||||
/// under its heading, with Reschedule moving all of it to a day at once,
|
||||
/// then today's, headed by the day alone; each with its project. A button
|
||||
/// adds a task for today. A reset of the person's keys waits above it
|
||||
/// (`KeysResetBanner`).
|
||||
/// Today (ADR 0033, D2, D8), where the app opens, laid out as its Display
|
||||
/// sets (D30, kept in the person's settings). As a list, at most two
|
||||
/// groups, as Todoist's (the developer's review, 3 Nov 2026): what is
|
||||
/// overdue, folding under its heading, with Reschedule moving all of it to
|
||||
/// a day at once, then today's, headed by the day alone; each with its
|
||||
/// project. As a calendar, today's hours ([HourGrid], one day) and the Plan
|
||||
/// ([PlanPanel]) its app bar's Plan button shows or hides: beside the day
|
||||
/// from a medium width on (remembered), over its foot on a phone, stepping
|
||||
/// down out of the way while one of its rows is dragged onto an hour. A
|
||||
/// button adds a task for today. A reset of the person's keys waits above
|
||||
/// it (`KeysResetBanner`).
|
||||
class const TodayTab({super.key}) extends StatefulWidget {
|
||||
/// The key of the app bar's Plan button (D30).
|
||||
static const planKey = ValueKey('today-plan');
|
||||
|
||||
/// The key of the phone's Plan over the day's foot (D30).
|
||||
static const planSheetKey = ValueKey('today-plan-sheet');
|
||||
|
||||
@override
|
||||
State<TodayTab> createState() => _TodayTabState();
|
||||
}
|
||||
|
|
@ -32,6 +48,33 @@ class _TodayTabState() extends State<TodayTab> {
|
|||
/// Whether what is overdue is folded away.
|
||||
bool _overdueFolded = false;
|
||||
|
||||
/// The projects the Plan shows, for as long as the app runs (none: all).
|
||||
Set<String> _projects = const {};
|
||||
|
||||
/// Whether the Plan stands beside the day, as the body's width allows,
|
||||
/// rather than over its foot (as last laid out).
|
||||
bool _wide = false;
|
||||
|
||||
/// Whether the phone's Plan is open over the day's foot.
|
||||
bool _sheet = false;
|
||||
|
||||
/// Whether the phone's Plan has stepped down, one of its rows dragged.
|
||||
bool _lowered = false;
|
||||
|
||||
/// The Plan shown or hidden: beside the day, remembered with the
|
||||
/// person's settings; on a phone, its sheet.
|
||||
Future<void> _togglePlan(TodoState state) async {
|
||||
if (!_wide) {
|
||||
setState(() => _sheet = !_sheet);
|
||||
return;
|
||||
}
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
final view = state.settings.today;
|
||||
await todo.settings.write(
|
||||
state.settings.copyWith(today: view.copyWith(plan: !view.plan)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -41,93 +84,231 @@ class _TodayTabState() extends State<TodayTab> {
|
|||
// Overdue and today's counted together, as Todoist's (the developer's
|
||||
// review, 4 Nov 2026).
|
||||
final count = overdue.length + due.length;
|
||||
final view = state.settings.today;
|
||||
final calendar = view.layout == TodayLayout.calendar;
|
||||
final planned = planOf(state.tasks, today, projectIds: _projects).count;
|
||||
final planShown = _wide ? view.plan : _sheet;
|
||||
return TabScreen(
|
||||
title: l10n.shellTabToday,
|
||||
subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null,
|
||||
actions: const [SelectTasksMenu()],
|
||||
floatingActionButton: AddTaskButton(
|
||||
onPressed: () => showQuickAdd(context, day: today),
|
||||
),
|
||||
actions: [
|
||||
if (calendar)
|
||||
Tooltip(
|
||||
message: planShown ? l10n.planHide : l10n.planShow,
|
||||
child: TextButton.icon(
|
||||
key: TodayTab.planKey,
|
||||
icon: Icon(
|
||||
planShown
|
||||
? LucideIcons.panelRightClose
|
||||
: LucideIcons.panelRight,
|
||||
),
|
||||
label: Text(l10n.planButton(planned)),
|
||||
onPressed: () => _togglePlan(state),
|
||||
),
|
||||
),
|
||||
DisplayButton(target: todayDisplay),
|
||||
const SelectTasksMenu(),
|
||||
],
|
||||
floatingActionButton: calendar && !_wide && _sheet
|
||||
? null
|
||||
: AddTaskButton(onPressed: () => showQuickAdd(context, day: today)),
|
||||
body: Column(
|
||||
children: [
|
||||
const KeysResetBanner(),
|
||||
Expanded(
|
||||
// The add button dropped on the list adds a task for today (D16).
|
||||
child: NewTaskDropArea(
|
||||
onDrop: () => showQuickAdd(context, day: today),
|
||||
child: !state.loaded
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: overdue.isEmpty && due.isEmpty
|
||||
? TaskRefresh(
|
||||
fill: true,
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.calendarCheck,
|
||||
title: l10n.todayEmptyTitle,
|
||||
hint: l10n.todayEmptyHint,
|
||||
),
|
||||
)
|
||||
: TaskRefresh(
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
children: [
|
||||
if (overdue.isNotEmpty) ...[
|
||||
DayHeading(
|
||||
title: l10n.overdue,
|
||||
action: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
reschedule(context, overdue),
|
||||
child: Text(l10n.reschedule),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _overdueFolded
|
||||
? l10n.overdueShow
|
||||
: l10n.overdueHide,
|
||||
icon: Icon(
|
||||
_overdueFolded
|
||||
? LucideIcons.chevronDown
|
||||
: LucideIcons.chevronUp,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _overdueFolded = !_overdueFolded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_overdueFolded)
|
||||
...taskGroup([
|
||||
for (final task in overdue)
|
||||
(position) => TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
position: position,
|
||||
),
|
||||
]),
|
||||
],
|
||||
DayHeading(title: dayTitle(context, today)),
|
||||
...taskGroup([
|
||||
for (final task in due)
|
||||
(position) => TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
position: position,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: !state.loaded
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: calendar
|
||||
? _calendar(context, today, due, view)
|
||||
: _list(context, today, overdue, due),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Today's hours beside or under the Plan (D30).
|
||||
Widget _calendar(
|
||||
BuildContext context,
|
||||
Day today,
|
||||
List<OpenTask> due,
|
||||
TodayView view,
|
||||
) {
|
||||
ProjectCalendar calendarFor(int _) => (
|
||||
overdue: const [],
|
||||
days: [
|
||||
(today, [for (final task in due) (task: task, occurrence: false)]),
|
||||
],
|
||||
busy: const {},
|
||||
);
|
||||
// The day's last hours can be scrolled above a phone's Plan.
|
||||
Widget grid({double foot = 88}) => HourGrid(
|
||||
days: 1,
|
||||
foot: foot,
|
||||
coloring: TaskColoring.priority,
|
||||
// The hour before now at the top, as the day's rest is to plan.
|
||||
firstHour: clock.now().hour - 1,
|
||||
calendarFor: calendarFor,
|
||||
);
|
||||
void projects(Set<String> ids) => setState(() => _projects = ids);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = WindowSize.fromWidth(constraints.maxWidth).isAtLeastMedium;
|
||||
if (wide != _wide) {
|
||||
_wide = wide;
|
||||
// The app bar's Plan button, built before, says what it does now.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (_wide) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: grid()),
|
||||
if (view.plan) ...[
|
||||
const VerticalDivider(width: 1),
|
||||
SizedBox(
|
||||
width: 340,
|
||||
child: PlanPanel(
|
||||
projectIds: _projects,
|
||||
onProjects: projects,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
// A phone's Plan over the day's foot, above the floating bar; held
|
||||
// rows lower it to its heading, the day free behind it.
|
||||
final cover = FloatingNavigationBar.coverOf(context);
|
||||
final height = constraints.maxHeight * 0.5;
|
||||
const heading = 56.0;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: grid(foot: _sheet ? height : 88)),
|
||||
if (_sheet)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: height + cover,
|
||||
child: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
offset: Offset(
|
||||
0,
|
||||
_lowered ? (height - heading) / (height + cover) : 0,
|
||||
),
|
||||
child: Material(
|
||||
key: TodayTab.planSheetKey,
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: PlanPanel(
|
||||
projectIds: _projects,
|
||||
onProjects: projects,
|
||||
onClose: () => setState(() => _sheet = false),
|
||||
onHeaderTap: () => setState(() => _lowered = false),
|
||||
onDragging: (dragging) {
|
||||
if (dragging != _lowered) {
|
||||
setState(() => _lowered = dragging);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// What is overdue, then today's, as a list.
|
||||
Widget _list(
|
||||
BuildContext context,
|
||||
Day today,
|
||||
List<OpenTask> overdue,
|
||||
List<OpenTask> due,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// The add button dropped on the list adds a task for today (D16).
|
||||
return NewTaskDropArea(
|
||||
onDrop: () => showQuickAdd(context, day: today),
|
||||
child: overdue.isEmpty && due.isEmpty
|
||||
? TaskRefresh(
|
||||
fill: true,
|
||||
child: EmptyListMessage(
|
||||
icon: LucideIcons.calendarCheck,
|
||||
title: l10n.todayEmptyTitle,
|
||||
hint: l10n.todayEmptyHint,
|
||||
),
|
||||
)
|
||||
: TaskRefresh(
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 88 + FloatingNavigationBar.coverOf(context),
|
||||
),
|
||||
children: [
|
||||
if (overdue.isNotEmpty) ...[
|
||||
DayHeading(
|
||||
title: l10n.overdue,
|
||||
action: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => reschedule(context, overdue),
|
||||
child: Text(l10n.reschedule),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _overdueFolded
|
||||
? l10n.overdueShow
|
||||
: l10n.overdueHide,
|
||||
icon: Icon(
|
||||
_overdueFolded
|
||||
? LucideIcons.chevronDown
|
||||
: LucideIcons.chevronUp,
|
||||
),
|
||||
onPressed: () => setState(
|
||||
() => _overdueFolded = !_overdueFolded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_overdueFolded)
|
||||
...taskGroup([
|
||||
for (final task in overdue)
|
||||
(position) => TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
position: position,
|
||||
),
|
||||
]),
|
||||
],
|
||||
DayHeading(title: dayTitle(context, today)),
|
||||
...taskGroup([
|
||||
for (final task in due)
|
||||
(position) => TaskTile(
|
||||
task: task,
|
||||
showProject: true,
|
||||
position: position,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A day's heading in Today's and Upcoming's lists, muted where
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
|
@ -1292,6 +1294,479 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("Today's Calendar and Plan (D30, as Todoist's desktop Today)", () {
|
||||
final today = Day.of(DateTime.now());
|
||||
|
||||
/// Picks Today's Calendar layout in its Display.
|
||||
Future<void> calendar(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Display'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Calendar'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Scrolls the day so that [hour] is at the top of its hours.
|
||||
Future<void> showHour(WidgetTester tester, int hour) async {
|
||||
tester
|
||||
.state<ScrollableState>(
|
||||
find.descendant(
|
||||
of: find.byKey(HourGrid.hoursKey),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
)
|
||||
.position
|
||||
.jumpTo(hour * 60.0);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Holds [from], moves to [to] in steps and lets go.
|
||||
Future<void> drag(WidgetTester tester, Offset from, Offset to) async {
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
|
||||
for (var step = 1; step <= 10; step++) {
|
||||
await gesture.moveTo(Offset.lerp(from, to, step / 10)!);
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Where [minutes] after midnight is on today's column, a little in.
|
||||
Offset at(WidgetTester tester, int minutes) =>
|
||||
tester.getTopLeft(find.byKey(HourGrid.columnKey(today))) +
|
||||
Offset(20, minutes.toDouble());
|
||||
|
||||
Future<String> idOf(WidgetTester tester, String title) async =>
|
||||
(await tester.runAsync(
|
||||
() => tasks().all(),
|
||||
))!.singleWhere((t) => t.content?.title == title).id;
|
||||
|
||||
Future<TaskDate?> dateOf(WidgetTester tester, String title) async =>
|
||||
(await tester.runAsync(
|
||||
() => tasks().all(),
|
||||
))!.singleWhere((t) => t.content?.title == title).content!.date;
|
||||
|
||||
Finder inPlan(String text) => find.descendant(
|
||||
of: find.byType(PlanPanel),
|
||||
matching: find.text(text),
|
||||
);
|
||||
|
||||
/// A wide window, where the Plan stands beside the day.
|
||||
void wide(WidgetTester tester) =>
|
||||
tester.view.physicalSize = const Size(1000, 900);
|
||||
|
||||
testWidgets("Today's Display lays it out as a list or a calendar; the "
|
||||
"calendar is today's hours, a timed task a block at its hour as "
|
||||
'long as it lasts, one without a time All day, a line at the time '
|
||||
'now; kept in the settings, sealed', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Dentist',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(
|
||||
today,
|
||||
time: 9 * 60,
|
||||
duration: const Duration(hours: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(title: 'Shop', projectId: inbox(), date: TaskDate(today)),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Late',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today.plus(-1)),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Display'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('List'), findsOneWidget);
|
||||
expect(find.text('Board'), findsNothing, reason: 'no board of a day');
|
||||
expect(
|
||||
find.text('Completed tasks'),
|
||||
findsNothing,
|
||||
reason: 'its layout alone',
|
||||
);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.pumpAndSettle();
|
||||
await calendar(tester);
|
||||
|
||||
expect(find.byType(HourGrid), findsOneWidget);
|
||||
expect(find.byKey(HourGrid.columnKey(today)), findsOneWidget);
|
||||
expect(find.byKey(HourGrid.columnKey(today.plus(1))), findsNothing);
|
||||
await showHour(tester, 8);
|
||||
final dentist = find.byKey(
|
||||
HourGrid.blockKey(await idOf(tester, 'Dentist'), today),
|
||||
);
|
||||
final column = find.byKey(HourGrid.columnKey(today));
|
||||
expect(
|
||||
tester.getTopLeft(dentist).dy - tester.getTopLeft(column).dy,
|
||||
closeTo(9 * 60, 2),
|
||||
);
|
||||
expect(tester.getSize(dentist).height, closeTo(58, 1));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(HourGrid.allDayKey(today)),
|
||||
matching: find.text('Shop'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(HourGrid),
|
||||
matching: find.text('Late'),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'what is overdue waits in the Plan',
|
||||
);
|
||||
expect(find.byKey(HourGrid.nowKey), findsOneWidget);
|
||||
final settings = await tester.runAsync(
|
||||
() => SettingsRepository(settingsRows, keys).read(),
|
||||
);
|
||||
expect(settings!.today.layout, TodayLayout.calendar);
|
||||
expect(settingsRows.byId.values.single.keyId, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('a block of the day dragged to 15:00 moves it there, its '
|
||||
'duration kept', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Dentist',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(
|
||||
today,
|
||||
time: 9 * 60,
|
||||
duration: const Duration(hours: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
await calendar(tester);
|
||||
await showHour(tester, 8);
|
||||
final block = find.byKey(
|
||||
HourGrid.blockKey(await idOf(tester, 'Dentist'), today),
|
||||
);
|
||||
// Held 20 minutes below its top, its top dropped at 15:00.
|
||||
await drag(
|
||||
tester,
|
||||
tester.getTopLeft(block) + const Offset(10, 20),
|
||||
at(tester, 15 * 60 + 20),
|
||||
);
|
||||
|
||||
final moved = (await dateOf(tester, 'Dentist'))!;
|
||||
expect(moved.day, today);
|
||||
expect(moved.time, 15 * 60);
|
||||
expect(moved.duration, const Duration(hours: 1));
|
||||
});
|
||||
|
||||
group('beside the day on a wide window', () {
|
||||
/// Life, Home under it, and Work.
|
||||
Future<Map<String, String>> seedProjects(WidgetTester tester) async {
|
||||
final ids = await tester.runAsync(() async {
|
||||
final projects = ProjectRepository(projectRows, keys);
|
||||
final life = await projects.save(
|
||||
const ProjectContent(name: 'Life', position: 1),
|
||||
);
|
||||
final home = await projects.save(
|
||||
ProjectContent(name: 'Home', position: 2, parentId: life),
|
||||
);
|
||||
final work = await projects.save(
|
||||
const ProjectContent(name: 'Work', position: 3),
|
||||
);
|
||||
return {'Life': life, 'Home': home, 'Work': work};
|
||||
});
|
||||
projectRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
return ids!;
|
||||
}
|
||||
|
||||
testWidgets('the Plan holds Overdue with Reschedule, To plan and '
|
||||
'Time-blocked, each counted and folding; All projects narrows '
|
||||
'it to the projects ticked, searched by name, a sub-project '
|
||||
"under its parent's name; Plan hides it, remembered", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
wide(tester);
|
||||
await tester.pumpAndSettle();
|
||||
final projects = await seedProjects(tester);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Late',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today.plus(-1)),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Shop',
|
||||
projectId: projects['Home']!,
|
||||
date: TaskDate(today),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Dentist',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today, time: 9 * 60),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Gym',
|
||||
projectId: projects['Home']!,
|
||||
date: TaskDate(today, time: 18 * 60),
|
||||
),
|
||||
);
|
||||
await calendar(tester);
|
||||
|
||||
expect(find.byType(PlanPanel), findsOneWidget, reason: 'open');
|
||||
expect(find.text('Plan: 4'), findsOneWidget);
|
||||
Finder heading(String group) => find
|
||||
.ancestor(of: inPlan(group), matching: find.byType(Row))
|
||||
.first;
|
||||
for (final (group, count) in [
|
||||
('Overdue', '1'),
|
||||
('To plan', '1'),
|
||||
('Time-blocked', '2'),
|
||||
]) {
|
||||
expect(
|
||||
find.descendant(of: heading(group), matching: find.text(count)),
|
||||
findsOneWidget,
|
||||
reason: group,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
find.descendant(
|
||||
of: heading('Overdue'),
|
||||
matching: find.text('Reschedule'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
for (final title in ['Late', 'Shop', 'Dentist', 'Gym']) {
|
||||
expect(inPlan(title), findsOneWidget, reason: title);
|
||||
}
|
||||
await tester.tap(find.byTooltip('Hide Time-blocked'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(inPlan('Dentist'), findsNothing);
|
||||
await tester.tap(find.byTooltip('Show Time-blocked'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(PlanPanel.projectsKey));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Filter by project'), findsWidgets);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(CheckboxListTile, 'Home'),
|
||||
matching: find.text('Life'),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: "a sub-project under its parent's name",
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).last, 'wor');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(CheckboxListTile, 'Home'), findsNothing);
|
||||
expect(find.widgetWithText(CheckboxListTile, 'Work'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).last, '');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(CheckboxListTile, 'Home'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(inPlan('Shop'), findsOneWidget);
|
||||
expect(inPlan('Gym'), findsOneWidget);
|
||||
expect(inPlan('Late'), findsNothing);
|
||||
expect(inPlan('Dentist'), findsNothing);
|
||||
expect(find.text('Plan: 2'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(PlanPanel.projectsKey),
|
||||
matching: find.text('Home'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(HourGrid),
|
||||
matching: find.text('Dentist'),
|
||||
),
|
||||
findsOneWidget,
|
||||
reason: 'the filter is the Plan’s, the day shows all',
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(TodayTab.planKey));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PlanPanel), findsNothing);
|
||||
final settings = await tester.runAsync(
|
||||
() => SettingsRepository(settingsRows, keys).read(),
|
||||
);
|
||||
expect(settings!.today.plan, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a row of the Plan dropped on 14:00 is planned then, '
|
||||
'today; a block dropped on the Plan loses its time, staying '
|
||||
'today; either can be undone', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
wide(tester);
|
||||
await tester.pumpAndSettle();
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Late',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today.plus(-2)),
|
||||
),
|
||||
);
|
||||
await calendar(tester);
|
||||
await showHour(tester, 13);
|
||||
|
||||
await drag(
|
||||
tester,
|
||||
tester.getCenter(inPlan('Late')),
|
||||
at(tester, 14 * 60 + 5),
|
||||
);
|
||||
final planned = (await dateOf(tester, 'Late'))!;
|
||||
expect(planned.day, today);
|
||||
expect(planned.time, 14 * 60);
|
||||
expect(planned.duration, isNull);
|
||||
expect(find.text('Undo'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(HourGrid),
|
||||
matching: find.text('Late'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
final block = find.byKey(
|
||||
HourGrid.blockKey(await idOf(tester, 'Late'), today),
|
||||
);
|
||||
await drag(
|
||||
tester,
|
||||
tester.getTopLeft(block) + const Offset(10, 10),
|
||||
tester.getCenter(find.byKey(PlanPanel.dropKey)),
|
||||
);
|
||||
final unplanned = (await dateOf(tester, 'Late'))!;
|
||||
expect(unplanned.day, today);
|
||||
expect(unplanned.time, isNull);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(PlanPanel),
|
||||
matching: find.text('To plan'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect((await dateOf(tester, 'Late'))!.time, 14 * 60);
|
||||
});
|
||||
|
||||
testWidgets('nothing done nor a later time of a repeating task is '
|
||||
"offered to plan, and a Plan's row dropped back on the Plan "
|
||||
'changes nothing', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
wide(tester);
|
||||
await tester.pumpAndSettle();
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Done',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today, time: 10 * 60),
|
||||
completedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Water plants',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(
|
||||
today.plus(-1),
|
||||
time: 8 * 60,
|
||||
repeat: const Repeat(RepeatUnit.day),
|
||||
),
|
||||
),
|
||||
);
|
||||
await calendar(tester);
|
||||
|
||||
expect(find.text('Done'), findsNothing);
|
||||
expect(inPlan('Water plants'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(HourGrid),
|
||||
matching: find.text('Water plants'),
|
||||
),
|
||||
findsNothing,
|
||||
reason: 'its later times are no tasks of their own to plan',
|
||||
);
|
||||
await drag(
|
||||
tester,
|
||||
tester.getCenter(inPlan('Water plants')),
|
||||
tester.getCenter(inPlan('Water plants')) + const Offset(0, 40),
|
||||
);
|
||||
final date = (await dateOf(tester, 'Water plants'))!;
|
||||
expect(date.day, today.plus(-1));
|
||||
expect(date.time, 8 * 60);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('on a phone, Plan opens a sheet over the day; a row held '
|
||||
'there lowers it out of the way and dropped on 14:00 is planned '
|
||||
'then', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(title: 'Shop', projectId: inbox(), date: TaskDate(today)),
|
||||
);
|
||||
await calendar(tester);
|
||||
expect(find.byType(PlanPanel), findsNothing, reason: 'until asked');
|
||||
await showHour(tester, 13);
|
||||
await tester.tap(find.byKey(TodayTab.planKey));
|
||||
await tester.pumpAndSettle();
|
||||
final sheet = find.byKey(TodayTab.planSheetKey);
|
||||
expect(sheet, findsOneWidget);
|
||||
expect(inPlan('To plan'), findsOneWidget);
|
||||
final up = tester.getTopLeft(sheet).dy;
|
||||
|
||||
final from = tester.getCenter(inPlan('Shop'));
|
||||
final gesture = await tester.startGesture(from);
|
||||
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
|
||||
await gesture.moveBy(const Offset(0, -20));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(
|
||||
tester.getTopLeft(sheet).dy,
|
||||
greaterThan(up + 100),
|
||||
reason: 'lowered out of the way',
|
||||
);
|
||||
final to = at(tester, 14 * 60 + 5);
|
||||
for (var step = 1; step <= 10; step++) {
|
||||
await gesture.moveTo(Offset.lerp(from, to, step / 10)!);
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final planned = (await dateOf(tester, 'Shop'))!;
|
||||
expect(planned.day, today);
|
||||
expect(planned.time, 14 * 60);
|
||||
expect(tester.getTopLeft(sheet).dy, closeTo(up, 1), reason: 'back');
|
||||
});
|
||||
});
|
||||
|
||||
group('quick add reads what is typed (D15)', () {
|
||||
Future<void> open(WidgetTester tester, String text) async {
|
||||
await tester.tap(find.byTooltip('Add task'));
|
||||
|
|
|
|||
|
|
@ -69,6 +69,25 @@ void main() {
|
|||
expect(decodeSettings(payload({'schemaVersion': 2})), isNull);
|
||||
});
|
||||
|
||||
test("Today's Display and whether its Plan stands beside the day read "
|
||||
'back as they were set (D30); an unknown layout as at first', () {
|
||||
const settings = TodoSettings(
|
||||
today: TodayView(layout: TodayLayout.calendar, plan: false),
|
||||
);
|
||||
expect(decodeSettings(encodeSettings(settings)), settings);
|
||||
expect(written(settings)['today'], {'layout': 'calendar', 'plan': false});
|
||||
expect(const TodoSettings().today.plan, isTrue, reason: 'open at first');
|
||||
expect(
|
||||
decodeSettings(
|
||||
payload({
|
||||
'schemaVersion': 1,
|
||||
'today': {'layout': 'board'},
|
||||
}),
|
||||
),
|
||||
const TodoSettings(),
|
||||
);
|
||||
});
|
||||
|
||||
test("D4's settings of tasks and reminders read back as they were set", () {
|
||||
const settings = TodoSettings(
|
||||
tasks: TaskSettings(weekStart: DateTime.sunday, resetSubTasks: false),
|
||||
|
|
|
|||
|
|
@ -116,4 +116,30 @@ void main() {
|
|||
expect(addedAt('not-an-id'), isNull);
|
||||
expect(addedAt('019a3e2f-8c00-4000-8000-000000000000'), isNull);
|
||||
});
|
||||
|
||||
test("Today's Plan (D30): overdue, today's without a time to plan, and "
|
||||
"today's with a time, done ones left out; narrowed to the projects "
|
||||
'picked, none picked keeping all', () {
|
||||
final tasks = [
|
||||
task('late', day: today.plus(-2), time: 9 * 60),
|
||||
task('open', day: today, project: 'home'),
|
||||
task('nine', day: today, time: 9 * 60),
|
||||
task('eight', day: today, time: 8 * 60, project: 'home'),
|
||||
task('done', day: today, time: 7 * 60, completed: true),
|
||||
task('tomorrow', day: today.plus(1), time: 9 * 60),
|
||||
task('undated'),
|
||||
];
|
||||
|
||||
final plan = planOf(tasks, today);
|
||||
expect(plan.overdue.map((t) => t.id), ['late']);
|
||||
expect(plan.toPlan.map((t) => t.id), ['open']);
|
||||
expect(plan.timed.map((t) => t.id), ['eight', 'nine']);
|
||||
expect(plan.count, 4);
|
||||
|
||||
final home = planOf(tasks, today, projectIds: {'home'});
|
||||
expect(home.overdue, isEmpty);
|
||||
expect(home.toPlan.map((t) => t.id), ['open']);
|
||||
expect(home.timed.map((t) => t.id), ['eight']);
|
||||
expect(home.count, 2);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ alternative was put to them, the choice is theirs.
|
|||
today. Upcoming's rows, overdue ones too, held and dropped on another day move there,
|
||||
their time, duration and recurrence kept, *Moved to …* offering to undo it; their
|
||||
order within a day stays their times' and priorities', and Today's rows aren't
|
||||
dragged.
|
||||
dragged (its Plan's are since D30).
|
||||
- **D17 Each project's Display, synced** (the developer's, 1 Nov, as Todoist's; D8's
|
||||
later step): a project's screen, the Inbox's too, has a Display button beside its ⋮, a
|
||||
dot on it while anything differs from how a project shows at first. Its sheet sets
|
||||
|
|
@ -288,7 +288,7 @@ alternative was put to them, the choice is theirs.
|
|||
project alike (`ProjectView`, the payload's `view`, written only once set; values a
|
||||
version doesn't know read as at first). The schema stays 1: an older version reads
|
||||
such a project, and renaming it there drops its Display, which beats a newer schema
|
||||
it couldn't read at all. Upcoming's Display (Today has none, as in Todoist) is in
|
||||
it couldn't read at all. Upcoming's Display (Today had none until D30) is in
|
||||
a sealed person-settings row (`todo_settings`, below), which is also to hold D4's
|
||||
settings. **The Board** (as built, 1 Nov), the Display's Layout (List,
|
||||
Board): a column for the tasks in no section, *(No section)*, always first, then one
|
||||
|
|
@ -515,6 +515,51 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
|
|||
and the recognition service it queries, iOS and macOS the usage texts and macOS the
|
||||
audio input entitlement, as Learn's. *Alternatives:* a microphone beside the send
|
||||
button always (one more button in a bar already full, and Todoist's swaps them).
|
||||
- **D30 Today planned on a calendar, as Todoist's desktop Today** (the developer's, 4
|
||||
Nov 2026, from their screenshots of Todoist's desktop app): Today gets a Display
|
||||
(amends D17's "Today has none"), its **Layout** alone, *List* or *Calendar*, with
|
||||
Display's pictures (`LayoutPicker`; no Board, a day having no sections), kept in the
|
||||
person's sealed settings row as Upcoming's (`TodayView` in `todo_settings`, the
|
||||
payload's `today`, schema still 1: an older version reads it, and writing its settings
|
||||
there drops it, as D17 accepted for a project's view). *Calendar* is **one day of
|
||||
hours**, Upcoming's 3-day grid generalised to any number of days (`HourGrid`, D17's
|
||||
`ThreeDayGrid` renamed, `days: 1`): today's tasks without a time *All day* on top,
|
||||
timed ones as blocks as long as they last, overlapping ones side by side, a **line at
|
||||
the time it is now** (its time in cerise in the hours' column, drawn again each
|
||||
minute; Upcoming's grid has it too), a block dragged to another hour moving there
|
||||
(snapping to a quarter hour), onto *All day* losing its time, a long press adding a
|
||||
task, as D17. With one day there are no day names, arrows nor *Overdue* button: what
|
||||
is overdue waits in the Plan. The day scrolls to the hour before now at first.
|
||||
**The Plan** (`PlanPanel`): *Plan: 3* in Today's app bar, in the Calendar layout
|
||||
alone, shows or hides it, counting what it holds. Its groups, each folding under its
|
||||
heading with its count (the fold not kept): *Overdue* with Reschedule (Today's, in
|
||||
cerise as Todoist's), *To plan* (today's tasks without a time; the developer's
|
||||
working name, Todoist naming no such group), *Time-blocked* (today's with a time,
|
||||
Todoist's word; German *Zu planen* and *Eingeplant*); done tasks and later times of
|
||||
repeating ones are never in it, nor in the day, so neither is planned. *All
|
||||
projects* opens *Filter by project*, a field searching the projects' names and a box
|
||||
to tick for each (the Inbox first, a sub-project with its parents' path, "Life /
|
||||
Home", D28), several at once, applied as ticked; it narrows the Plan alone, not the
|
||||
day, and is kept while the app runs, not synced (agent's choice: a filter for one
|
||||
planning session). A row held and dropped on an hour is moved to **today at that
|
||||
time** (its top where the pointer is, snapped as a block's), its duration kept or,
|
||||
without one, half an hour drawn; a block dropped on the Plan loses its time and stays
|
||||
today; both through `moveTask`, *Moved to …* offering to undo it; a Plan's row dropped
|
||||
back on the Plan changes nothing. **Where it stands**: from a medium width of
|
||||
Today's own on (the window-size classes over the body's width, as the board's D17,
|
||||
so the task pane beside the tabs counts), a panel 340 wide beside the day, shown or
|
||||
hidden as the person last left it, remembered with the settings (`TodayView.plan`,
|
||||
open at first); **on a phone, a sheet over the day's foot** (the developer's choice,
|
||||
over the Plan on wide windows alone and over a strip of tasks above the day), half
|
||||
the body high above the floating bar, closed until asked and while the app runs;
|
||||
a row held there **lowers the sheet to its heading**, the day free behind it, and it
|
||||
rises again once the row is dropped. The sheet is part of Today's screen, not a
|
||||
modal route (agent's choice): a modal sheet's barrier would take the drop meant for
|
||||
the day behind it, and closing the route under a drag ends the row's draggable.
|
||||
The add button hides while the phone's sheet is open. *Alternatives:* the Plan on
|
||||
wide windows alone (a phone couldn't plan by dragging), a strip above the day
|
||||
(too little room for three groups), the project filter synced (a planning
|
||||
session's narrowing would follow the person to their other devices).
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue