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:
parent
892a58f4fd
commit
63d50f77d4
3 changed files with 475 additions and 7 deletions
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue