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