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

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