feat(todo): Today laid out as a calendar, planned from its Plan by dragging, as Todoist's desktop Today (ADR 0033, D30)
The developer asked for Todoist's desktop Today planning: a day of hours and a Plan beside it, tasks dragged from it onto an hour. Today gets a Display, its layout alone (List or Calendar), kept in the person's sealed settings row as Upcoming's (`TodayView`, the payload's `today`, schema still 1). Its Calendar is one day of `HourGrid`; *Plan: n* in its app bar shows or hides the Plan (`PlanPanel`): Overdue with Reschedule, To plan (today's without a time) and Time-blocked (today's with one), each folding with its count, narrowed for the session by *All projects*' Filter by project (a search and a box a project, a sub-project with its parents' path). A row dropped on an hour is moved to today at that time, its duration kept; a block dropped on the Plan loses its time; both undone from the snack bar, through `moveTask`. From a medium width of Today's own on the Plan stands beside the day, shown or hidden as remembered; on a phone it is a sheet over the day's foot (the developer's choice, over the Plan on wide windows alone and over a strip above the day), part of the screen rather than a modal route, so that the day behind it takes the drop, and it lowers to its heading while one of its rows is held. Whats-New: Today's Display offers a Calendar layout: today's hours, with a Plan of what is overdue, to plan and time-blocked, whose tasks you drag onto an hour. Whats-New-De: Die Anzeige von Heute bietet das Layout Kalender: die Stunden des Tages, mit einem Plan aus Überfälligem, Zu planen und Eingeplant, dessen Aufgaben du auf eine Stunde ziehst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c53ce30ff5
commit
574b379f11
1 changed files with 1775 additions and 196 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue