feat(todo): the calendars linked to projects fetched by the device and kept for offline (ADR 0033, D39)

`CalendarCubit` follows the projects' calendar links: a calendar linked
is shown at once from the copy kept and fetched by the device over HTTPS
(`HttpCalendarFeeds`, `webcal://` as HTTPS), as the app opens, every hour
while it is open and when a list is pulled down; a fetch that fails, or
answers with something that is no calendar, keeps the last copy; a link
taken off, or its project gone, takes its events and its copy along. The
copies are files in the person's own folder (`calendars/<project>.ics`).
Nothing shows the events yet.

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:12:10 +01:00
commit 63d50f77d4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 475 additions and 7 deletions

View file

@ -62,6 +62,7 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_calendar_feeds.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_location_gateway.dart';
import '../support/memory_reminder_gateway.dart';
@ -86,6 +87,7 @@ void main() {
late MemorySealedRowGateway commentRows;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late FakeCalendarFeeds calendarFeeds;
late MemoryLocationGateway locationGateway;
late FakeSpeechInput speech;
@ -118,6 +120,7 @@ void main() {
commentRows = MemorySealedRowGateway();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
calendarFeeds = FakeCalendarFeeds();
locationGateway = MemoryLocationGateway();
speech = FakeSpeechInput();
store = MemoryInstanceStore();
@ -223,6 +226,7 @@ void main() {
settings: settings,
reminders: reminders,
locationReminders: locationGateway,
calendarFeeds: calendarFeeds,
);
},
),

View file

@ -0,0 +1,136 @@
import 'dart:io';
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/data/calendar_feeds.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import '../../support/fake_calendar_feeds.dart';
/// A calendar file with one event, [title], on 10 Nov 2026 at nine.
String feed(String title) =>
'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n'
'BEGIN:VEVENT\r\nUID:$title@test\r\nDTSTART:20261110T090000\r\n'
'DTEND:20261110T100000\r\nSUMMARY:$title\r\nEND:VEVENT\r\n'
'END:VCALENDAR\r\n';
void main() {
final from = DateTime(2026, 11, 10);
final until = DateTime(2026, 11, 11);
StoredProject school({String? url = 'https://example.org/school.ics'}) =>
StoredProject('school', ProjectContent(name: 'School', calendarUrl: url));
group("a project's calendar on the device (ADR 0033, D39)", () {
late FakeCalendarFeeds feeds;
late MemoryCalendarStore store;
late CalendarCubit cubit;
setUp(() {
feeds = FakeCalendarFeeds();
store = MemoryCalendarStore();
cubit = CalendarCubit(feeds, store);
});
tearDown(() => cubit.close());
test(
'a project linked is fetched, its events then by their project',
() async {
feeds.served[Uri.parse('https://example.org/school.ics')] = feed(
'Maths',
);
await cubit.follow([
school(),
const StoredProject('home', ProjectContent(name: 'Home')),
]);
final events = cubit.state.eventsIn(from, until);
expect(events.map((entry) => (entry.projectId, entry.event.title)), [
('school', 'Maths'),
]);
expect(await store.read('school'), feed('Maths'));
},
);
test(
'fetched once as long as its address stays; again on refresh',
() async {
feeds.served[Uri.parse('https://example.org/school.ics')] = feed(
'Maths',
);
await cubit.follow([school()]);
await cubit.follow([school()]);
expect(feeds.fetched, hasLength(1));
feeds.served[Uri.parse('https://example.org/school.ics')] = feed('Art');
await cubit.refresh();
expect(feeds.fetched, hasLength(2));
expect(
cubit.state.eventsIn(from, until).single.event.title,
'Art',
);
},
);
test(
'offline, the last copy kept is shown; a failed fetch keeps it',
() async {
await store.write('school', feed('Kept'));
await cubit.follow([school()]);
expect(cubit.state.eventsIn(from, until).single.event.title, 'Kept');
expect(cubit.state.failed, {'school'});
},
);
test('a page that is no calendar is not kept', () async {
await store.write('school', feed('Kept'));
feeds.served[Uri.parse('https://example.org/school.ics')] =
'<html>Sign in</html>';
await cubit.follow([school()]);
expect(cubit.state.eventsIn(from, until).single.event.title, 'Kept');
expect(await store.read('school'), feed('Kept'));
expect(cubit.state.failed, {'school'});
});
test('unlinked, or the project gone, its events and its copy go', () async {
feeds.served[Uri.parse('https://example.org/school.ics')] = feed('Maths');
await cubit.follow([school()]);
await cubit.follow([school(url: null)]);
expect(cubit.state.eventsIn(from, until), isEmpty);
expect(await store.read('school'), isNull);
});
test('a webcal address is fetched over HTTPS', () async {
feeds.served[Uri.parse('https://example.org/school.ics')] = feed('Maths');
await cubit.follow([school(url: 'webcal://example.org/school.ics')]);
expect(feeds.fetched, [Uri.parse('https://example.org/school.ics')]);
});
});
test(
'the files kept in a folder of their own, read back and deleted',
() async {
final folder = await Directory.systemTemp.createTemp('calendars');
addTearDown(() => folder.delete(recursive: true));
final store = FileCalendarStore(folder);
expect(await store.read('school'), isNull);
await store.write('school', feed('Maths'));
expect(await store.read('school'), feed('Maths'));
await store.delete('school');
expect(await store.read('school'), isNull);
await store.delete('school');
},
);
}

View file

@ -0,0 +1,14 @@
import 'package:halkyon_todo_flutter/src/feature/calendar/data/calendar_feeds.dart';
/// Serves the calendar files [served] by their address, as a feed's host
/// would, and remembers each address fetched; one it doesn't serve fails.
class FakeCalendarFeeds() implements CalendarFeedGateway {
final served = <Uri, String>{};
final fetched = <Uri>[];
@override
Future<String> fetch(Uri url) async {
fetched.add(url);
return served[url] ?? (throw CalendarFeedFailed('$url not served'));
}
}