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",
|
"monthMore": "{count} weitere",
|
||||||
"dayHeadingPattern": "EEEE, d. MMM",
|
"dayHeadingPattern": "EEEE, d. MMM",
|
||||||
"overdueHide": "Überfälliges ausblenden",
|
"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."
|
"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",
|
"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:
|
/// In en, this message translates to:
|
||||||
/// **'Show what is overdue'**
|
/// **'Show what is overdue'**
|
||||||
String get overdueShow;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1019,4 +1019,57 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get overdueShow => 'Überfälliges einblenden';
|
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
|
@override
|
||||||
String get overdueShow => 'Show what is overdue';
|
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`).
|
/// 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].
|
/// The sortings it offers; manual first, named [manualText].
|
||||||
final List<TaskSorting> sortings = TaskSorting.values,
|
final List<TaskSorting> sortings = TaskSorting.values,
|
||||||
final String Function(AppLocalizations l10n)? manualText,
|
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;
|
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(
|
Future<void> _writeUpcoming(
|
||||||
TodoPerson todo,
|
TodoPerson todo,
|
||||||
TodoState state,
|
TodoState state,
|
||||||
|
|
@ -256,6 +292,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
||||||
children: [
|
children: [
|
||||||
heading(l10n.layoutTitle),
|
heading(l10n.layoutTitle),
|
||||||
RoundedGroup(children: target.layout(context, state, apply)),
|
RoundedGroup(children: target.layout(context, state, apply)),
|
||||||
|
if (!target.layoutOnly) ...[
|
||||||
RoundedGroup(
|
RoundedGroup(
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
|
@ -386,6 +423,7 @@ class const _DisplaySheet({required final DisplayTarget target})
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
footer: Row(
|
footer: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ ByteData encodeSettings(TodoSettings settings) =>
|
||||||
encodeJson(settingsSchemaVersion, {
|
encodeJson(settingsSchemaVersion, {
|
||||||
if (!settings.upcoming.isDefault)
|
if (!settings.upcoming.isDefault)
|
||||||
'upcoming': _encodeUpcoming(settings.upcoming),
|
'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())
|
if (settings.tasks != const TaskSettings())
|
||||||
'tasks': {
|
'tasks': {
|
||||||
'weekStart': ?settings.tasks.weekStart,
|
'weekStart': ?settings.tasks.weekStart,
|
||||||
|
|
@ -40,11 +46,22 @@ TodoSettings? decodeSettings(ByteData payload) => decodeJson(
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
read: (json) => TodoSettings(
|
read: (json) => TodoSettings(
|
||||||
upcoming: _decodeUpcoming(json['upcoming']),
|
upcoming: _decodeUpcoming(json['upcoming']),
|
||||||
|
today: _decodeToday(json['today']),
|
||||||
tasks: _decodeTasks(json['tasks']),
|
tasks: _decodeTasks(json['tasks']),
|
||||||
reminders: _decodeReminders(json['reminders']),
|
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) {
|
TaskSettings _decodeTasks(Object? json) {
|
||||||
if (json is! Map<String, Object?>) {
|
if (json is! Map<String, Object?>) {
|
||||||
return const TaskSettings();
|
return const TaskSettings();
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,33 @@ final class const UpcomingView({
|
||||||
List<Object?> get props => [layout, coloring, filters];
|
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).
|
/// The person's settings of tasks (ADR 0033, D4).
|
||||||
final class const TaskSettings({
|
final class const TaskSettings({
|
||||||
/// The weekday weeks start on (`DateTime.monday` to `sunday`); none: the
|
/// 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,
|
/// The person's settings of Todo's (D4, D17): one sealed row of theirs,
|
||||||
/// synced to every device (`todo_settings`): [upcoming]'s Display, and
|
/// synced to every device (`todo_settings`): [upcoming]'s and [today]'s
|
||||||
/// those of [tasks] and [reminders].
|
/// Displays, and the settings of [tasks] and [reminders].
|
||||||
final class const TodoSettings({
|
final class const TodoSettings({
|
||||||
final UpcomingView upcoming = const UpcomingView(),
|
final UpcomingView upcoming = const UpcomingView(),
|
||||||
|
final TodayView today = const TodayView(),
|
||||||
final TaskSettings tasks = const TaskSettings(),
|
final TaskSettings tasks = const TaskSettings(),
|
||||||
final ReminderSettings reminders = const ReminderSettings(),
|
final ReminderSettings reminders = const ReminderSettings(),
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
TodoSettings copyWith({
|
TodoSettings copyWith({
|
||||||
UpcomingView? upcoming,
|
UpcomingView? upcoming,
|
||||||
|
TodayView? today,
|
||||||
TaskSettings? tasks,
|
TaskSettings? tasks,
|
||||||
ReminderSettings? reminders,
|
ReminderSettings? reminders,
|
||||||
}) => TodoSettings(
|
}) => TodoSettings(
|
||||||
upcoming: upcoming ?? this.upcoming,
|
upcoming: upcoming ?? this.upcoming,
|
||||||
|
today: today ?? this.today,
|
||||||
tasks: tasks ?? this.tasks,
|
tasks: tasks ?? this.tasks,
|
||||||
reminders: reminders ?? this.reminders,
|
reminders: reminders ?? this.reminders,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@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));
|
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,
|
/// 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),
|
/// then each of the [days] days with its tasks (none for a day without),
|
||||||
/// done ones too where [completed].
|
/// 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
|
/// 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
|
/// 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
|
/// 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({
|
class const DraggableTask({
|
||||||
required final OpenTask task,
|
required final OpenTask task,
|
||||||
required final Widget child,
|
required final Widget child,
|
||||||
|
final ValueChanged<bool>? onDragging,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -270,11 +272,14 @@ class _DraggableTaskState() extends State<DraggableTask> {
|
||||||
scroller.stopAutoScroll();
|
scroller.stopAutoScroll();
|
||||||
}
|
}
|
||||||
_scrollers = [];
|
_scrollers = [];
|
||||||
|
widget.onDragging?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stop();
|
for (final scroller in _scrollers) {
|
||||||
|
scroller.stopAutoScroll();
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,6 +296,7 @@ class _DraggableTaskState() extends State<DraggableTask> {
|
||||||
data: _drag,
|
data: _drag,
|
||||||
dragAnchorStrategy: pointerDragAnchorStrategy,
|
dragAnchorStrategy: pointerDragAnchorStrategy,
|
||||||
onDragStarted: () {
|
onDragStarted: () {
|
||||||
|
widget.onDragging?.call(true);
|
||||||
_scrollers = [
|
_scrollers = [
|
||||||
for (
|
for (
|
||||||
var scrollable = Scrollable.maybeOf(context);
|
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_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.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/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/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.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.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_drag.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.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/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_todo_flutter/src/tabs/tab_screen.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Today (ADR 0033, D2, D8), where the app opens: at most two groups, as
|
/// Today (ADR 0033, D2, D8), where the app opens, laid out as its Display
|
||||||
/// Todoist's (the developer's review, 3 Nov 2026): what is overdue, folding
|
/// sets (D30, kept in the person's settings). As a list, at most two
|
||||||
/// under its heading, with Reschedule moving all of it to a day at once,
|
/// groups, as Todoist's (the developer's review, 3 Nov 2026): what is
|
||||||
/// then today's, headed by the day alone; each with its project. A button
|
/// overdue, folding under its heading, with Reschedule moving all of it to
|
||||||
/// adds a task for today. A reset of the person's keys waits above it
|
/// a day at once, then today's, headed by the day alone; each with its
|
||||||
/// (`KeysResetBanner`).
|
/// 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 {
|
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
|
@override
|
||||||
State<TodayTab> createState() => _TodayTabState();
|
State<TodayTab> createState() => _TodayTabState();
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +48,33 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
/// Whether what is overdue is folded away.
|
/// Whether what is overdue is folded away.
|
||||||
bool _overdueFolded = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
@ -41,23 +84,166 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
// Overdue and today's counted together, as Todoist's (the developer's
|
// Overdue and today's counted together, as Todoist's (the developer's
|
||||||
// review, 4 Nov 2026).
|
// review, 4 Nov 2026).
|
||||||
final count = overdue.length + due.length;
|
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(
|
return TabScreen(
|
||||||
title: l10n.shellTabToday,
|
title: l10n.shellTabToday,
|
||||||
subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null,
|
subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null,
|
||||||
actions: const [SelectTasksMenu()],
|
actions: [
|
||||||
floatingActionButton: AddTaskButton(
|
if (calendar)
|
||||||
onPressed: () => showQuickAdd(context, day: today),
|
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(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
const KeysResetBanner(),
|
const KeysResetBanner(),
|
||||||
Expanded(
|
Expanded(
|
||||||
// The add button dropped on the list adds a task for today (D16).
|
|
||||||
child: NewTaskDropArea(
|
|
||||||
onDrop: () => showQuickAdd(context, day: today),
|
|
||||||
child: !state.loaded
|
child: !state.loaded
|
||||||
? const Center(child: AppLoadingIndicator())
|
? const Center(child: AppLoadingIndicator())
|
||||||
: overdue.isEmpty && due.isEmpty
|
: 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(
|
? TaskRefresh(
|
||||||
fill: true,
|
fill: true,
|
||||||
child: EmptyListMessage(
|
child: EmptyListMessage(
|
||||||
|
|
@ -80,8 +266,7 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () => reschedule(context, overdue),
|
||||||
reschedule(context, overdue),
|
|
||||||
child: Text(l10n.reschedule),
|
child: Text(l10n.reschedule),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -122,10 +307,6 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_sheet.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.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/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_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_todo_flutter/src/tabs/week_strip.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:intl/intl.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)', () {
|
group('quick add reads what is typed (D15)', () {
|
||||||
Future<void> open(WidgetTester tester, String text) async {
|
Future<void> open(WidgetTester tester, String text) async {
|
||||||
await tester.tap(find.byTooltip('Add task'));
|
await tester.tap(find.byTooltip('Add task'));
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,25 @@ void main() {
|
||||||
expect(decodeSettings(payload({'schemaVersion': 2})), isNull);
|
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", () {
|
test("D4's settings of tasks and reminders read back as they were set", () {
|
||||||
const settings = TodoSettings(
|
const settings = TodoSettings(
|
||||||
tasks: TaskSettings(weekStart: DateTime.sunday, resetSubTasks: false),
|
tasks: TaskSettings(weekStart: DateTime.sunday, resetSubTasks: false),
|
||||||
|
|
|
||||||
|
|
@ -116,4 +116,30 @@ void main() {
|
||||||
expect(addedAt('not-an-id'), isNull);
|
expect(addedAt('not-an-id'), isNull);
|
||||||
expect(addedAt('019a3e2f-8c00-4000-8000-000000000000'), 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,
|
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
|
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
|
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
|
- **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
|
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
|
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
|
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
|
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
|
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
|
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,
|
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
|
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
|
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
|
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).
|
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
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue