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
5 changed files with 475 additions and 7 deletions
|
|
@ -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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue