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:
Tizian 「ティツィアーン」 2026-11-04 22:39:38 +01:00
commit 574b379f11
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 1775 additions and 196 deletions

File diff suppressed because one or more lines are too long

View file

@ -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"
}

View file

@ -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."
}
}

View file

@ -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

View file

@ -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';
}

View file

@ -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`).

View file

@ -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(

View file

@ -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();

View file

@ -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];
}

View file

@ -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].

View file

@ -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);

View 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(' / ');
}

View file

@ -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

View file

@ -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'));

View file

@ -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),

View file

@ -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);
});
}