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
9 changed files with 475 additions and 7 deletions

View file

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
@ -5,6 +8,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_flutter/src/app/todo_router.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_scheduler.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.dart';
@ -119,18 +123,37 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
create: (context) =>
TodoCubit(PersonScope.of<TodoPerson>(context))..open(),
),
// The calendars linked to the projects (D39): fetched as the
// app opens and every hour while it is open.
BlocProvider(
create: (context) {
final person = PersonScope.of<TodoPerson>(context);
return CalendarCubit(
person.calendarFeeds,
person.calendarStore,
)..start();
},
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
),
],
child: TaskRevealScope(
notifier: _reveal,
child: ScreenColor(
color: PaletteColor.strawberry.background,
// Their tasks' reminders on this device (ADR 0033, D5).
child: ReminderScheduler(child: child!),
child: BlocListener<TodoCubit, TodoState>(
// A project linked to a calendar, or unlinked, on any device.
listenWhen: (before, now) =>
!listEquals(before.projects, now.projects),
listener: (context, state) => unawaited(
context.read<CalendarCubit>().follow(state.projects),
),
child: TaskRevealScope(
notifier: _reveal,
child: ScreenColor(
color: PaletteColor.strawberry.background,
// Their tasks' reminders on this device (ADR 0033, D5).
child: ReminderScheduler(child: child!),
),
),
),
),

View file

@ -11,6 +11,7 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart'
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/data/calendar_feeds.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/data/comment_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/data/sync_comment_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/data/filter_repository.dart';
@ -94,8 +95,20 @@ class TodoPerson(
/// How wide the device keeps its side panes: the device's, where it is
/// given; else for this session.
final PaneWidths? _paneWidths,
/// Where the calendars linked to their projects are fetched from, by the
/// device itself (D39); none in tests that don't say.
final CalendarFeedGateway calendarFeeds = const NoCalendarFeeds(),
/// Where the device keeps the last copy of each of those calendars: the
/// person's folder's, where it is given; else for this session.
final CalendarStore? _calendarStore,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// The last copy of each calendar linked to their projects (D39).
late final CalendarStore calendarStore =
_calendarStore ?? MemoryCalendarStore();
/// How wide the device keeps its side panes (ADR 0033, D33).
late final PaneWidths paneWidths = _paneWidths ?? PaneWidths();
@ -443,6 +456,10 @@ Future<TodoPerson> _openPersonData(
locationReminders: locationReminders,
locationAccess: locationAccess,
paneWidths: paneWidths,
calendarFeeds: HttpCalendarFeeds(),
calendarStore: FileCalendarStore(
Directory(p.join(folder.path, 'calendars')),
),
onDispose: () async {
try {
await projects.dispose();

View file

@ -0,0 +1,145 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/data/calendar_feeds.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/model/ical.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
/// An event of the calendar linked to the project [projectId] (D39).
final class const FeedEvent(final String projectId, final IcalEvent event)
with Equatable {
@override
List<Object?> get props => [projectId, event];
}
/// The calendars the person's projects show (ADR 0033, D39): each one's
/// file as last fetched or kept, and those whose last fetch failed.
final class CalendarState({
final Map<String, String> texts = const {},
final Set<String> failed = const {},
}) with Equatable {
/// What [eventsIn] read, by its span: read once a state.
final _read = <(DateTime, DateTime), List<FeedEvent>>{};
/// The events running within [from] to [until], of every calendar, in
/// order.
List<FeedEvent> eventsIn(DateTime from, DateTime until) =>
_read[(from, until)] ??= [
for (final MapEntry(key: projectId, value: text) in texts.entries)
for (final event in readIcal(text, from: from, until: until))
FeedEvent(projectId, event),
]..sort((a, b) => a.event.start.compareTo(b.event.start));
@override
// `_read` stays out: it caches what the others give.
// ignore: leancode_lint/missing_equatable_props
List<Object?> get props => [texts, failed];
}
/// Follows the calendars linked to the person's projects (D39): fetched by
/// the device as a project is linked, as the app opens, every hour while
/// it is open ([start]) and when a list is pulled down ([refresh]); the
/// last copy kept in [_store] for when the device is offline.
class CalendarCubit(
final CalendarFeedGateway _feeds,
final CalendarStore _store,
) extends Cubit<CalendarState> {
this : super(CalendarState());
/// The address each project's calendar is fetched from.
var _urls = <String, Uri>{};
Timer? _hourly;
/// Fetches every calendar every hour, until closed.
void start() {
_hourly?.cancel();
_hourly = Timer.periodic(const Duration(hours: 1), (_) => refresh());
}
/// Follows [projects]: a calendar linked newly or anew is shown from its
/// copy at once and fetched; an unlinked one's events and copy go.
Future<void> follow(List<StoredProject> projects) async {
final urls = <String, Uri>{
for (final project in projects)
project.id: ?feedUrl(project.content?.calendarUrl ?? ''),
};
final gone = {
..._urls.keys,
for (final project in projects)
if (project.content != null) project.id,
}.difference(urls.keys.toSet());
final fresh = [
for (final MapEntry(:key, :value) in urls.entries)
if (_urls[key] != value) key,
];
_urls = urls;
if (gone.isNotEmpty) {
for (final id in gone) {
await _store.delete(id);
}
_emit(
texts: {...state.texts}..removeWhere((id, _) => gone.contains(id)),
failed: state.failed.difference(gone),
);
}
for (final id in fresh) {
if (!state.texts.containsKey(id)) {
if (await _store.read(id) case final kept?) {
_emit(texts: {...state.texts, id: kept});
}
}
}
await Future.wait([for (final id in fresh) _fetch(id)]);
}
/// Fetches every calendar followed again.
Future<void> refresh() async {
await Future.wait([for (final id in _urls.keys) _fetch(id)]);
}
Future<void> _fetch(String projectId) async {
final url = _urls[projectId];
if (url == null) {
return;
}
String? text;
try {
text = await _feeds.fetch(url);
} on CalendarFeedFailed {
text = null;
}
// Unlinked or linked elsewhere meanwhile: this answer is old.
if (isClosed || _urls[projectId] != url) {
return;
}
if (text == null || !isIcal(text)) {
_emit(failed: {...state.failed, projectId});
return;
}
await _store.write(projectId, text);
if (!isClosed) {
_emit(
texts: {...state.texts, projectId: text},
failed: {...state.failed}..remove(projectId),
);
}
}
void _emit({Map<String, String>? texts, Set<String>? failed}) {
if (!isClosed) {
emit(
CalendarState(
texts: texts ?? state.texts,
failed: failed ?? state.failed,
),
);
}
}
@override
Future<void> close() {
_hourly?.cancel();
return super.close();
}
}

View file

@ -0,0 +1,122 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
/// Where a project's calendar is fetched from (ADR 0033, D39): the device
/// itself, so that the instance neither learns its address nor sees its
/// events.
abstract interface class CalendarFeedGateway() {
/// The file at [url]; throws [CalendarFeedFailed] where it can't be had.
Future<String> fetch(Uri url);
}
/// A calendar's feed that couldn't be fetched: offline, refused or gone.
final class const CalendarFeedFailed(final String reason) implements Exception {
@override
String toString() => 'CalendarFeedFailed: $reason';
}
/// The address a feed is fetched from: `webcal://`, as calendars hand out
/// their feeds, is HTTPS; none for an address that is neither.
Uri? feedUrl(String address) {
final url = Uri.tryParse(address.trim());
if (url == null || url.host.isEmpty) {
return null;
}
return switch (url.scheme.toLowerCase()) {
'webcal' || 'webcals' => url.replace(scheme: 'https'),
'https' || 'http' => url,
_ => null,
};
}
/// Fetches feeds over the device's network, a slow host given up after half
/// a minute.
class HttpCalendarFeeds([final http.Client? _given])
implements CalendarFeedGateway {
late final http.Client _client = _given ?? http.Client();
@override
Future<String> fetch(Uri url) async {
try {
final response = await _client
.get(url, headers: {'Accept': 'text/calendar, */*;q=0.5'})
.timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
throw CalendarFeedFailed('$url answered ${response.statusCode}');
}
return utf8.decode(response.bodyBytes, allowMalformed: true);
} on CalendarFeedFailed {
rethrow;
} on Exception catch (error) {
throw CalendarFeedFailed('$url: $error');
}
}
}
/// Fetches nothing, where none is given (tests that don't say).
class const NoCalendarFeeds() implements CalendarFeedGateway {
@override
Future<String> fetch(Uri url) async =>
throw const CalendarFeedFailed('no network here');
}
/// The last copy of each project's calendar the device fetched, for when it
/// is offline (D39); not synced.
abstract interface class CalendarStore() {
/// The project [projectId]'s copy; none where none is kept.
Future<String?> read(String projectId);
Future<void> write(String projectId, String text);
/// Deletes the project [projectId]'s copy, where one is kept.
Future<void> delete(String projectId);
}
/// The copies as files in [folder], the person's own folder's `calendars`:
/// `<project>.ics`.
class FileCalendarStore(final Directory folder) implements CalendarStore {
File _file(String projectId) =>
File(p.join(folder.path, '${Uri.encodeComponent(projectId)}.ics'));
@override
Future<String?> read(String projectId) async {
final file = _file(projectId);
return file.existsSync() ? await file.readAsString() : null;
}
@override
Future<void> write(String projectId, String text) async {
await folder.create(recursive: true);
// Written aside first, so that a copy is never half a file.
final file = _file(projectId);
final aside = File('${file.path}.new');
await aside.writeAsString(text, flush: true);
await aside.rename(file.path);
}
@override
Future<void> delete(String projectId) async {
final file = _file(projectId);
if (file.existsSync()) {
await file.delete();
}
}
}
/// The copies in memory, kept for the session alone.
class MemoryCalendarStore() implements CalendarStore {
final _texts = <String, String>{};
@override
Future<String?> read(String projectId) async => _texts[projectId];
@override
Future<void> write(String projectId, String text) async =>
_texts[projectId] = text;
@override
Future<void> delete(String projectId) async => _texts.remove(projectId);
}

View file

@ -1,5 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -15,7 +16,11 @@ class const TaskRefresh({
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => AppRefreshIndicator(
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
// Their calendars fetched again too (D39).
onRefresh: () => Future.wait([
syncAndWait(context.read<SyncStatusBloc>()),
context.read<CalendarCubit>().refresh(),
]),
child: fill ? PullRoom(child: child) : child,
);
}

View file

@ -52,6 +52,8 @@ dependencies:
# Noto Sans and the widgets every screen is built of.
halkyon_ui:
path: ../../../packages/halkyon_ui
# Calendars' feeds fetched by the device (ADR 0033, D39).
http: ^1.6.0
# gen-l10n's output imports it.
intl: ^0.20.3
latlong2: ^0.10.1

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'));
}
}