feat(todo): a project linked to a calendar shows its events beside the tasks (ADR 0033, D39)
A project's editor takes a calendar's address (https:// or webcal://, refused otherwise), and its events show read-only, marked in the project's colour: in Today's list and Upcoming's agenda above each day's tasks, as blocks in the hour grids (Today's Calendar, Upcoming's 3-day) sharing the lanes with timed tasks and all-day ones under All day, in the project's Calendar (its agenda and month, its own events alone), and in saved filters of days. A tap shows what an event is: when, where, and whose calendar. The grid's lane layout is generic for it (`layOutItems`); a task's row's box is shared (`rowBox`). Whats-New: Link a calendar to a project in its settings: its events show beside your tasks in Today, Upcoming and the project. Whats-New-De: Verknüpfe einen Kalender in den Einstellungen eines Projekts: Seine Termine erscheinen neben deinen Aufgaben in Heute, Demnächst und im Projekt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
63d50f77d4
commit
85a022af98
1 changed files with 1032 additions and 83 deletions
|
|
@ -4585,6 +4585,247 @@ void main() {
|
|||
expect(yard?.view, const ProjectView(layout: ProjectLayout.board));
|
||||
});
|
||||
|
||||
group("a project's linked calendar (D39, the developer's decisions of "
|
||||
'10 Nov 2026)', () {
|
||||
ProjectRepository projects() => ProjectRepository(projectRows, keys);
|
||||
final school = Uri.parse('https://example.org/school.ics');
|
||||
|
||||
/// A calendar with "Maths" today, nine to ten, and "Sports day" all
|
||||
/// of today.
|
||||
String feed() {
|
||||
final today = Day.of(DateTime.now());
|
||||
String date(Day day) =>
|
||||
'${day.year}${'${day.month}'.padLeft(2, '0')}'
|
||||
'${'${day.day}'.padLeft(2, '0')}';
|
||||
return 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n'
|
||||
'BEGIN:VEVENT\r\nUID:maths@test\r\n'
|
||||
'DTSTART:${date(today)}T090000\r\nDTEND:${date(today)}T100000\r\n'
|
||||
'SUMMARY:Maths\r\nLOCATION:Room 4\r\nEND:VEVENT\r\n'
|
||||
'BEGIN:VEVENT\r\nUID:sports@test\r\n'
|
||||
'DTSTART;VALUE=DATE:${date(today)}\r\n'
|
||||
'SUMMARY:Sports day\r\nEND:VEVENT\r\n'
|
||||
'END:VCALENDAR\r\n';
|
||||
}
|
||||
|
||||
/// School, linked to its calendar, and Home, linked to none.
|
||||
Future<void> seedSchool(WidgetTester tester) async {
|
||||
calendarFeeds.served[school] = feed();
|
||||
await tester.runAsync(() async {
|
||||
await projects().save(
|
||||
ProjectContent(
|
||||
name: 'School',
|
||||
position: 1,
|
||||
calendarUrl: '$school',
|
||||
),
|
||||
);
|
||||
await projects().save(
|
||||
const ProjectContent(name: 'Home', position: 2),
|
||||
);
|
||||
});
|
||||
projectRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("linked in the project's editor, an address no calendar is "
|
||||
'at refused, and fetched by the device once saved', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await tester.runAsync(
|
||||
() => projects().save(const ProjectContent(name: 'School')),
|
||||
);
|
||||
projectRows.bringRemoteChanges();
|
||||
calendarFeeds.served[school] = feed();
|
||||
await goTab(tester, 'Browse');
|
||||
await tester.tap(find.text('School'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Edit project'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final address = find.widgetWithText(
|
||||
TextField,
|
||||
'Calendar address (iCal)',
|
||||
);
|
||||
await tester.ensureVisible(address);
|
||||
await tester.enterText(address, 'school timetable');
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text('An address starting with https:// or webcal://'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, LucideIcons.check),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
|
||||
await tester.enterText(address, 'webcal://example.org/school.ics');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stored = await tester.runAsync(projects().all);
|
||||
expect(
|
||||
stored!.singleWhere((p) => !p.inbox).content?.calendarUrl,
|
||||
'webcal://example.org/school.ics',
|
||||
);
|
||||
expect(calendarFeeds.fetched, [school]);
|
||||
});
|
||||
|
||||
testWidgets('its events show in Upcoming and Today, read-only, a tap '
|
||||
'opening what they are', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedSchool(tester);
|
||||
|
||||
await goTab(tester, 'Upcoming');
|
||||
expect(find.text('Maths'), findsOneWidget);
|
||||
expect(find.text('Sports day'), findsOneWidget);
|
||||
await tester.tap(find.text('Maths'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("From School's calendar"), findsOneWidget);
|
||||
expect(find.text('Room 4'), findsWidgets);
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await goTab(tester, 'Today');
|
||||
expect(find.text('Maths'), findsOneWidget);
|
||||
expect(find.text('Sports day'), findsOneWidget);
|
||||
expect(find.byType(DraggableTask), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("in Upcoming's 3-day grid, a timed one a block at its "
|
||||
'hours, an all-day one under All day; a tap opens what it is', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedSchool(tester);
|
||||
await goTab(tester, 'Upcoming');
|
||||
await tester.tap(find.byTooltip('Display'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('3-day'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.pumpAndSettle();
|
||||
final today = Day.of(DateTime.now());
|
||||
|
||||
final maths = find.byKey(HourGrid.eventKey('maths@test', today));
|
||||
final column = find.byKey(HourGrid.columnKey(today));
|
||||
// An hour is 60 logical pixels down the day.
|
||||
expect(
|
||||
tester.getTopLeft(maths).dy - tester.getTopLeft(column).dy,
|
||||
closeTo(9 * 60, 2),
|
||||
);
|
||||
expect(tester.getSize(maths).height, closeTo(58, 2));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(HourGrid.allDayKey(today)),
|
||||
matching: find.text('Sports day'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.ensureVisible(maths);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(maths);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("From School's calendar"), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("in its project's month, on a wide window, before the "
|
||||
"day's tasks", (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedSchool(tester);
|
||||
tester.view.physicalSize = const Size(1000, 900);
|
||||
final all = await tester.runAsync(projects().all);
|
||||
final id = all!.singleWhere((p) => p.content?.name == 'School').id;
|
||||
await tester.runAsync(
|
||||
() => projects().update(
|
||||
id,
|
||||
all
|
||||
.singleWhere((p) => p.id == id)
|
||||
.content!
|
||||
.copyWith(
|
||||
view: const ProjectView(layout: ProjectLayout.calendar),
|
||||
),
|
||||
),
|
||||
);
|
||||
projectRows.bringRemoteChanges();
|
||||
await goTab(tester, 'Browse');
|
||||
await tester.tap(find.text('School'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final today = Day.of(DateTime.now());
|
||||
expect(
|
||||
find.byKey(MonthGrid.eventKey('maths@test', today)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('09:00 Maths'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("in a saved filter of days, today's; not in one of its "
|
||||
'project alone', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedSchool(tester);
|
||||
await tester.runAsync(() async {
|
||||
final filters = FilterRepository(filterRows, keys);
|
||||
await filters.add(
|
||||
const FilterContent(name: 'Day', query: 'today', favorite: true),
|
||||
);
|
||||
await filters.add(
|
||||
const FilterContent(
|
||||
name: 'All of school',
|
||||
query: '#School',
|
||||
favorite: true,
|
||||
),
|
||||
);
|
||||
});
|
||||
filterRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
await goTab(tester, 'Browse');
|
||||
|
||||
await tester.tap(find.text('Day'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Maths'), findsOneWidget);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('All of school'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Maths'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("in its project's Calendar, but not in another's", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seedSchool(tester);
|
||||
final all = await tester.runAsync(projects().all);
|
||||
for (final project in all!.where((p) => !p.inbox)) {
|
||||
await tester.runAsync(
|
||||
() => projects().update(
|
||||
project.id,
|
||||
project.content!.copyWith(
|
||||
view: const ProjectView(layout: ProjectLayout.calendar),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
projectRows.bringRemoteChanges();
|
||||
await goTab(tester, 'Browse');
|
||||
|
||||
await tester.tap(find.text('School'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Maths'), findsOneWidget);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Home'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Maths'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group("a project's editor as Todoist's Add project (D28)", () {
|
||||
ProjectRepository projects() => ProjectRepository(projectRows, keys);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue