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
5 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,
);
}