Skip to content

Commit 94c5b80

Browse files
committed
Fix source selection, paging, remote sync error handling, calendar boundary matching, and controller cleanup issues
1 parent 0fab4ea commit 94c5b80

22 files changed

Lines changed: 468 additions & 264 deletions

File tree

api/lib/models/event/item/database.dart

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,13 @@ class CalendarItemDatabaseService extends CalendarItemService
9595
final fixedWhere = baseWhere.copy();
9696
fixedWhere.add('runtimeType != ?', [_repeatingRuntimeType]);
9797
fixedWhere.add(
98-
'(start BETWEEN ? AND ? OR end BETWEEN ? AND ? OR (start <= ? AND end >= ?))',
98+
'(((end IS NULL OR end > ?) AND (start IS NULL OR start < ?)) OR '
99+
'(start = end AND start >= ? AND start < ?))',
99100
[
100101
window.start.secondsSinceEpoch,
101102
window.end.secondsSinceEpoch,
102103
window.start.secondsSinceEpoch,
103104
window.end.secondsSinceEpoch,
104-
window.start.secondsSinceEpoch,
105-
window.end.secondsSinceEpoch,
106105
],
107106
);
108107

@@ -400,8 +399,11 @@ class CalendarItemDatabaseService extends CalendarItemService
400399
DateTime rangeStart,
401400
DateTime rangeEnd,
402401
) {
403-
return (itemEnd == null || !itemEnd.isBefore(rangeStart)) &&
404-
(itemStart == null || !itemStart.isAfter(rangeEnd));
402+
if (itemStart != null && itemEnd != null && itemStart == itemEnd) {
403+
return !itemStart.isBefore(rangeStart) && itemStart.isBefore(rangeEnd);
404+
}
405+
return (itemEnd == null || itemEnd.isAfter(rangeStart)) &&
406+
(itemStart == null || itemStart.isBefore(rangeEnd));
405407
}
406408

407409
DateTime _endOfDay(DateTime date) =>

api/lib/models/event/item/model.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ sealed class CalendarItem
101101
}
102102

103103
bool collidesWith(CalendarItem date) {
104+
if (start != null && end != null && start == end) {
105+
return (date.start == null || !start!.isBefore(date.start!)) &&
106+
(date.end == null || start!.isBefore(date.end!));
107+
}
108+
if (date.start != null && date.end != null && date.start == date.end) {
109+
return (start == null || !date.start!.isBefore(start!)) &&
110+
(end == null || date.start!.isBefore(end!));
111+
}
104112
return (end == null || (date.start?.isBefore(end!) ?? true)) &&
105113
(start == null || (date.end?.isAfter(start!) ?? true));
106114
}

api/pubspec.lock

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ packages:
5353
dependency: transitive
5454
description:
5555
name: build
56-
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
56+
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
5757
url: "https://pub.dev"
5858
source: hosted
59-
version: "4.0.5"
59+
version: "4.0.6"
6060
build_config:
6161
dependency: transitive
6262
description:
@@ -77,10 +77,10 @@ packages:
7777
dependency: "direct dev"
7878
description:
7979
name: build_runner
80-
sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d"
80+
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
8181
url: "https://pub.dev"
8282
source: hosted
83-
version: "2.14.1"
83+
version: "2.15.0"
8484
built_collection:
8585
dependency: transitive
8686
description:
@@ -93,10 +93,10 @@ packages:
9393
dependency: transitive
9494
description:
9595
name: built_value
96-
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
96+
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
9797
url: "https://pub.dev"
9898
source: hosted
99-
version: "8.12.5"
99+
version: "8.12.6"
100100
checked_yaml:
101101
dependency: transitive
102102
description:
@@ -270,10 +270,10 @@ packages:
270270
dependency: transitive
271271
description:
272272
name: matcher
273-
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
273+
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
274274
url: "https://pub.dev"
275275
source: hosted
276-
version: "0.12.19"
276+
version: "0.12.20"
277277
meta:
278278
dependency: transitive
279279
description:
@@ -374,10 +374,10 @@ packages:
374374
dependency: transitive
375375
description:
376376
name: source_gen
377-
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
377+
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
378378
url: "https://pub.dev"
379379
source: hosted
380-
version: "4.2.2"
380+
version: "4.2.3"
381381
source_map_stack_trace:
382382
dependency: transitive
383383
description:
@@ -462,26 +462,26 @@ packages:
462462
dependency: "direct dev"
463463
description:
464464
name: test
465-
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
465+
sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f
466466
url: "https://pub.dev"
467467
source: hosted
468-
version: "1.31.0"
468+
version: "1.31.1"
469469
test_api:
470470
dependency: transitive
471471
description:
472472
name: test_api
473-
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
473+
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
474474
url: "https://pub.dev"
475475
source: hosted
476-
version: "0.7.11"
476+
version: "0.7.12"
477477
test_core:
478478
dependency: transitive
479479
description:
480480
name: test_core
481-
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
481+
sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5
482482
url: "https://pub.dev"
483483
source: hosted
484-
version: "0.6.17"
484+
version: "0.6.18"
485485
type_plus:
486486
dependency: transitive
487487
description:
@@ -502,10 +502,10 @@ packages:
502502
dependency: transitive
503503
description:
504504
name: vm_service
505-
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
505+
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
506506
url: "https://pub.dev"
507507
source: hosted
508-
version: "15.1.0"
508+
version: "15.2.0"
509509
watcher:
510510
dependency: transitive
511511
description:

api/test/calendar_item_test.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import 'package:flow_api/models/event/item/model.dart';
2+
import 'package:test/test.dart';
3+
4+
void main() {
5+
group('CalendarItem.collidesWith', () {
6+
test('does not collide when one event ends at the next event start', () {
7+
final first = FixedCalendarItem(
8+
start: DateTime(2026, 5, 4, 9),
9+
end: DateTime(2026, 5, 4, 10),
10+
);
11+
final second = FixedCalendarItem(
12+
start: DateTime(2026, 5, 4, 10),
13+
end: DateTime(2026, 5, 4, 11),
14+
);
15+
16+
expect(first.collidesWith(second), isFalse);
17+
expect(second.collidesWith(first), isFalse);
18+
});
19+
20+
test('treats moments as contained instants', () {
21+
final appointment = FixedCalendarItem(
22+
start: DateTime(2026, 5, 4, 9),
23+
end: DateTime(2026, 5, 4, 10),
24+
);
25+
final insideMoment = FixedCalendarItem(
26+
start: DateTime(2026, 5, 4, 9, 30),
27+
end: DateTime(2026, 5, 4, 9, 30),
28+
);
29+
final boundaryMoment = FixedCalendarItem(
30+
start: DateTime(2026, 5, 4, 10),
31+
end: DateTime(2026, 5, 4, 10),
32+
);
33+
34+
expect(appointment.collidesWith(insideMoment), isTrue);
35+
expect(insideMoment.collidesWith(appointment), isTrue);
36+
expect(appointment.collidesWith(boundaryMoment), isFalse);
37+
expect(boundaryMoment.collidesWith(appointment), isFalse);
38+
});
39+
});
40+
}

app/lib/api/storage/remote/caldav.dart

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'package:flow_api/models/event/model.dart';
2121
import 'package:flow_api/models/extra.dart';
2222
import 'package:flow_api/services/database.dart';
2323
import 'package:xml/xml.dart';
24+
import 'package:collection/collection.dart';
2425

2526
import '../../../models/request.dart';
2627
import 'model.dart';
@@ -57,10 +58,11 @@ class CalDavRemoteService extends RemoteService<CalDavStorage> {
5758
Future<void> synchronize() async {
5859
await super.synchronize();
5960
final client = http.Client();
60-
final request = http.Request('REPORT', Uri.parse(remoteStorage.url));
61-
request.headers['Depth'] = '1';
62-
request.headers['Content-Type'] = 'application/xml; charset=utf-8';
63-
request.body = '''
61+
try {
62+
final request = http.Request('REPORT', Uri.parse(remoteStorage.url));
63+
request.headers['Depth'] = '1';
64+
request.headers['Content-Type'] = 'application/xml; charset=utf-8';
65+
request.body = '''
6466
<?xml version="1.0" encoding="utf-8" ?>
6567
<C:calendar-query xmlns:D="DAV:"
6668
xmlns:C="urn:ietf:params:xml:ns:caldav">
@@ -76,37 +78,49 @@ class CalDavRemoteService extends RemoteService<CalDavStorage> {
7678
</C:filter>
7779
</C:calendar-query>
7880
''';
79-
// Add auth basic
80-
request.headers['Authorization'] = _getAuthHeader();
81-
final response = await client.send(request);
82-
final xmlDocument = XmlDocument.parse(
83-
await response.stream.bytesToString(),
84-
);
85-
// Get /d:multistatus/d:response/d:propstat/d:prop/cal:calendar-data
86-
final data =
87-
xmlDocument.getElement("d:multistatus")?.findElements("d:response") ??
88-
[];
89-
final converter = ICalConverter();
90-
for (var element in data) {
91-
final href = element.getElement("d:href")?.innerText;
92-
final prop = element.getElement("d:propstat")?.getElement("d:prop");
93-
if (href == null) continue;
94-
final text = prop?.getElement("cal:calendar-data")?.innerText;
95-
if (text == null) continue;
96-
final etag = prop?.getElement("d:getetag")?.innerText;
97-
if (etag == null) continue;
98-
final name = href.substring(href.lastIndexOf('/') + 1);
99-
final id = createUniqueUint8List();
100-
converter.read(
101-
text.split('\n'),
102-
event: Event(
103-
name: name,
104-
id: id,
105-
).addExtra(CalDavExtraProperties(etag: etag, path: href)),
106-
notebook: Notebook(id: id, name: name),
81+
// Add auth basic
82+
request.headers['Authorization'] = _getAuthHeader();
83+
final response = await client.send(request);
84+
if (response.statusCode < 200 || response.statusCode >= 300) {
85+
throw http.ClientException(
86+
'Failed to synchronize CalDAV source: ${response.statusCode}',
87+
Uri.parse(remoteStorage.url),
88+
);
89+
}
90+
final xmlDocument = XmlDocument.parse(
91+
await response.stream.bytesToString(),
10792
);
93+
// Get /multistatus/response/propstat/prop/calendar-data,
94+
// regardless of which XML prefixes the server chooses.
95+
final data = xmlDocument.rootElement.name.local == 'multistatus'
96+
? xmlDocument.rootElement.findElementsByLocalName('response')
97+
: const <XmlElement>[];
98+
final converter = ICalConverter();
99+
for (var element in data) {
100+
final href = element.getElementByLocalName('href')?.innerText;
101+
final prop = element
102+
.getElementByLocalName('propstat')
103+
?.getElementByLocalName('prop');
104+
if (href == null) continue;
105+
final text = prop?.getElementByLocalName('calendar-data')?.innerText;
106+
if (text == null) continue;
107+
final etag = prop?.getElementByLocalName('getetag')?.innerText;
108+
if (etag == null) continue;
109+
final name = href.substring(href.lastIndexOf('/') + 1);
110+
final id = createUniqueUint8List();
111+
converter.read(
112+
text.split('\n'),
113+
event: Event(
114+
name: name,
115+
id: id,
116+
).addExtra(CalDavExtraProperties(etag: etag, path: href)),
117+
notebook: Notebook(id: id, name: name),
118+
);
119+
}
120+
if (converter.data != null) import(converter.data!);
121+
} finally {
122+
client.close();
108123
}
109-
if (converter.data != null) import(converter.data!);
110124
}
111125

112126
String _getAuthHeader() =>
@@ -165,6 +179,15 @@ class CalDavRemoteService extends RemoteService<CalDavStorage> {
165179
get groupLabel => local.groupLabel;
166180
}
167181

182+
extension _XmlElementLocalNameLookup on XmlElement {
183+
Iterable<XmlElement> findElementsByLocalName(String localName) => children
184+
.whereType<XmlElement>()
185+
.where((element) => element.name.local == localName);
186+
187+
XmlElement? getElementByLocalName(String localName) =>
188+
findElementsByLocalName(localName).firstOrNull;
189+
}
190+
168191
class EventModelCalDavConnector<I> extends ModelConnector<I, Event> {
169192
final CalDavRemoteService remote;
170193
final ModelConnector<I, Event> service;

app/lib/api/storage/remote/ical.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ class IcalRemoteService extends RemoteService<ICalStorage> {
2222
uri,
2323
headers: {'Authorization': _getAuthHeader()},
2424
);
25+
if (response.statusCode < 200 || response.statusCode >= 300) {
26+
throw http.ClientException(
27+
'Failed to synchronize iCalendar source: ${response.statusCode}',
28+
uri,
29+
);
30+
}
2531
final converter = ICalConverter();
2632
final name = remoteStorage.uri.host;
2733
converter.read(

app/lib/blocs/sourced_paging.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,20 @@ class SourcedPagingBloc<T>
116116
? state.dates
117117
: <List<SourcedModel<T>>>[];
118118
try {
119+
final sources = this.sources ?? cubit.getCurrentSources();
120+
if (sources.isEmpty) {
121+
emit(
122+
SourcedPagingSuccess(
123+
currentPageKey: const SourcedModel('', 0),
124+
dates: previousItems,
125+
hasReachedMax: true,
126+
currentDate: date,
127+
),
128+
);
129+
return;
130+
}
119131
final currentPageKey =
120-
state.currentPageKey ??
121-
SourcedModel(cubit.getCurrentSources().first, 0);
132+
state.currentPageKey ?? SourcedModel(sources.first, 0);
122133

123134
final fetchedItems =
124135
(await _fetch(
@@ -132,7 +143,6 @@ class SourcedPagingBloc<T>
132143
.map((e) => SourcedModel(currentPageKey.source, e))
133144
.toList();
134145

135-
final sources = this.sources ?? cubit.getCurrentSources();
136146
final currentSourceIndex = sources.indexOf(currentPageKey.source);
137147
final keepSource = fetchedItems.length >= pageSize;
138148
final isLastSource = currentSourceIndex >= sources.length - 1;

app/lib/cubits/flow.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ class FlowCubit extends Cubit<FlowState> {
2323
}
2424

2525
List<String> getCurrentSources() {
26-
return [
27-
'',
28-
...sourcesService.getRemotes().map((e) => e.identifier),
29-
].whereNot((source) => state.disabledSources.contains(source)).toList();
26+
return getSources()
27+
.whereNot((source) => state.disabledSources.contains(source))
28+
.toList();
29+
}
30+
31+
List<String> getSources() {
32+
return ['', ...sourcesService.getRemotes().map((e) => e.identifier)];
3033
}
3134

3235
List<RemoteStorage> getCurrentRemotes() {
@@ -61,7 +64,7 @@ class FlowCubit extends Cubit<FlowState> {
6164

6265
void setSources(List<String> sources) {
6366
setDisabledSources(
64-
getCurrentSources().whereNot((e) => sources.contains(e)).toList(),
67+
getSources().whereNot((e) => sources.contains(e)).toList(),
6568
);
6669
}
6770

0 commit comments

Comments
 (0)