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:
Tizian 「ティツィアーン」 2026-11-10 12:37:18 +01:00
commit 85a022af98
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1032 additions and 83 deletions

View file

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

View file

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/model/ical.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_events.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// Which events of a calendar linked to a project a saved filter shows
/// (ADR 0033, D39): those its days take, by their project and day.
void main() {
const today = Day(2026, 11, 10);
const world = FilterWorld(
today: today,
projects: {
'school': (name: 'School', parentId: null),
'home': (name: 'Home', parentId: null),
},
);
FeedEvent maths(Day day, {String projectId = 'school'}) => FeedEvent(
projectId,
IcalEvent(
uid: 'maths',
title: 'Maths',
start: DateTime(day.year, day.month, day.day, 9),
end: DateTime(day.year, day.month, day.day, 10),
),
);
bool takes(String query, FeedEvent event, Day day) =>
switch (FilterQuery.parse(query)) {
FilterRead(:final query) => filterTakesEvent(query, event, day, world),
FilterUnread() => fail('$query not read'),
};
test('a filter of days takes the events on them, of its projects', () {
expect(takes('today', maths(today), today), isTrue);
expect(takes('today', maths(today.plus(1)), today.plus(1)), isFalse);
expect(
takes('7 days & #School', maths(today.plus(2)), today.plus(2)),
isTrue,
);
expect(
takes('7 days & #School', maths(today, projectId: 'home'), today),
isFalse,
);
});
test('a filter not of days takes none: its project, a label, no date', () {
expect(takes('#School', maths(today), today), isFalse);
expect(takes('no date', maths(today), today), isFalse);
expect(takes('p1 & today', maths(today), today), isFalse);
});
test(
'the events of the next four weeks a filter takes, each on its days',
() {
final events = eventsOfFilter(
switch (FilterQuery.parse('7 days')) {
FilterRead(:final query) => query,
FilterUnread() => fail('not read'),
},
[
for (var day = 0; day < 28; day++) [maths(today.plus(day))],
],
today,
world,
);
expect(events.map((event) => event.$2), [
for (var day = 0; day < 7; day++) today.plus(day),
]);
},
);
}

View file

@ -20,7 +20,7 @@ void main() {
occurrence: false,
);
String laid(List<GridBlock> blocks) => [
String laid(List<GridBlock<CalendarEntry>> blocks) => [
for (final b in blocks)
'${b.entry.task.id} ${b.start}-${b.end} ${b.lane}/${b.lanes}',
].join('; ');