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

View file

@ -271,7 +271,7 @@ alternative was put to them, the choice is theirs.
today. Upcoming's rows, overdue ones too, held and dropped on another day move there,
their time, duration and recurrence kept, *Moved to …* offering to undo it; their
order within a day stays their times' and priorities', and Today's rows aren't
dragged.
dragged (its Plan's are since D30).
- **D17 Each project's Display, synced** (the developer's, 1 Nov, as Todoist's; D8's
later step): a project's screen, the Inbox's too, has a Display button beside its ⋮, a
dot on it while anything differs from how a project shows at first. Its sheet sets
@ -288,7 +288,7 @@ alternative was put to them, the choice is theirs.
project alike (`ProjectView`, the payload's `view`, written only once set; values a
version doesn't know read as at first). The schema stays 1: an older version reads
such a project, and renaming it there drops its Display, which beats a newer schema
it couldn't read at all. Upcoming's Display (Today has none, as in Todoist) is in
it couldn't read at all. Upcoming's Display (Today had none until D30) is in
a sealed person-settings row (`todo_settings`, below), which is also to hold D4's
settings. **The Board** (as built, 1 Nov), the Display's Layout (List,
Board): a column for the tasks in no section, *(No section)*, always first, then one
@ -515,6 +515,51 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
and the recognition service it queries, iOS and macOS the usage texts and macOS the
audio input entitlement, as Learn's. *Alternatives:* a microphone beside the send
button always (one more button in a bar already full, and Todoist's swaps them).
- **D30 Today planned on a calendar, as Todoist's desktop Today** (the developer's, 4
Nov 2026, from their screenshots of Todoist's desktop app): Today gets a Display
(amends D17's "Today has none"), its **Layout** alone, *List* or *Calendar*, with
Display's pictures (`LayoutPicker`; no Board, a day having no sections), kept in the
person's sealed settings row as Upcoming's (`TodayView` in `todo_settings`, the
payload's `today`, schema still 1: an older version reads it, and writing its settings
there drops it, as D17 accepted for a project's view). *Calendar* is **one day of
hours**, Upcoming's 3-day grid generalised to any number of days (`HourGrid`, D17's
`ThreeDayGrid` renamed, `days: 1`): today's tasks without a time *All day* on top,
timed ones as blocks as long as they last, overlapping ones side by side, a **line at
the time it is now** (its time in cerise in the hours' column, drawn again each
minute; Upcoming's grid has it too), a block dragged to another hour moving there
(snapping to a quarter hour), onto *All day* losing its time, a long press adding a
task, as D17. With one day there are no day names, arrows nor *Overdue* button: what
is overdue waits in the Plan. The day scrolls to the hour before now at first.
**The Plan** (`PlanPanel`): *Plan: 3* in Today's app bar, in the Calendar layout
alone, shows or hides it, counting what it holds. Its groups, each folding under its
heading with its count (the fold not kept): *Overdue* with Reschedule (Today's, in
cerise as Todoist's), *To plan* (today's tasks without a time; the developer's
working name, Todoist naming no such group), *Time-blocked* (today's with a time,
Todoist's word; German *Zu planen* and *Eingeplant*); done tasks and later times of
repeating ones are never in it, nor in the day, so neither is planned. *All
projects* opens *Filter by project*, a field searching the projects' names and a box
to tick for each (the Inbox first, a sub-project with its parents' path, "Life /
Home", D28), several at once, applied as ticked; it narrows the Plan alone, not the
day, and is kept while the app runs, not synced (agent's choice: a filter for one
planning session). A row held and dropped on an hour is moved to **today at that
time** (its top where the pointer is, snapped as a block's), its duration kept or,
without one, half an hour drawn; a block dropped on the Plan loses its time and stays
today; both through `moveTask`, *Moved to …* offering to undo it; a Plan's row dropped
back on the Plan changes nothing. **Where it stands**: from a medium width of
Today's own on (the window-size classes over the body's width, as the board's D17,
so the task pane beside the tabs counts), a panel 340 wide beside the day, shown or
hidden as the person last left it, remembered with the settings (`TodayView.plan`,
open at first); **on a phone, a sheet over the day's foot** (the developer's choice,
over the Plan on wide windows alone and over a strip of tasks above the day), half
the body high above the floating bar, closed until asked and while the app runs;
a row held there **lowers the sheet to its heading**, the day free behind it, and it
rises again once the row is dropped. The sheet is part of Today's screen, not a
modal route (agent's choice): a modal sheet's barrier would take the drop meant for
the day behind it, and closing the route under a drag ends the row's draggable.
The add button hides while the phone's sheet is open. *Alternatives:* the Plan on
wide windows alone (a phone couldn't plan by dragging), a strip above the day
(too little room for three groups), the project filter synced (a planning
session's narrowing would follow the person to their other devices).
## Steps