feat(library): an empty library says it is on its way during the first sync

A device's first sync brings a person's whole library at its end, so an
empty library said "No studysets yet" until then. Until a sync has gone
through on this device for this person, it now says "Your library is on
its way from <instance>…" with a spinner, unless the sync failed. Whether
one has is kept beside the pending count in sync_pending.json (a file
from before counts as synced), read as the person's data opens, and
carried in SyncStatus.syncedHere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-17 14:09:59 +02:00
commit 952d2b0607
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 237 additions and 30 deletions

View file

@ -0,0 +1,40 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
void main() {
late Directory folder;
late File file;
setUp(() {
folder = Directory.systemTemp.createTempSync('sync_pending');
file = File('${folder.path}/sync_pending.json');
});
tearDown(() => folder.deleteSync(recursive: true));
test('nothing kept yet: none pending, never synced here', () async {
expect(await FilePendingChangesStore(file).read(), (
pending: 0,
synced: false,
));
});
test('keeps the count and whether a sync went through', () async {
await FilePendingChangesStore(file).write((pending: 3, synced: true));
expect(await FilePendingChangesStore(file).read(), (
pending: 3,
synced: true,
));
});
test('a file from before it kept that is a device that synced', () async {
file.writeAsStringSync('{"pending": 2}');
expect(await FilePendingChangesStore(file).read(), (
pending: 2,
synced: true,
));
});
}

View file

@ -20,7 +20,10 @@ void main() {
// While set and not completed, a sync keeps running.
late Completer<void>? hold;
SyncService service({PendingChangesStore? pendingChanges}) => SyncService(
SyncService service({
PendingChangesStore? pendingChanges,
bool syncedBefore = false,
}) => SyncService(
({required onMerge}) async {
syncs++;
merge = onMerge;
@ -30,6 +33,7 @@ void main() {
}
},
pendingChanges: pendingChanges,
syncedBefore: syncedBefore,
);
setUp(() {
@ -182,7 +186,10 @@ void main() {
async.flushMicrotasks();
expect(
sync.status,
SyncStatus(lastSyncedAt: noon.add(const Duration(seconds: 3))),
SyncStatus(
lastSyncedAt: noon.add(const Duration(seconds: 3)),
syncedHere: true,
),
);
sync.dispose();
});
@ -205,6 +212,7 @@ void main() {
at: noon.add(const Duration(minutes: 5)),
appMismatch: false,
),
syncedHere: true,
),
);
expect(sync.status.hasTrouble, isTrue);
@ -213,7 +221,10 @@ void main() {
async.elapse(const Duration(minutes: 5));
expect(
sync.status,
SyncStatus(lastSyncedAt: noon.add(const Duration(minutes: 10))),
SyncStatus(
lastSyncedAt: noon.add(const Duration(minutes: 10)),
syncedHere: true,
),
);
sync.dispose();
});
@ -354,4 +365,30 @@ void main() {
});
});
});
test('says whether a sync ever went through on this device: as kept from '
'before, and from the first that goes through, kept for next '
'time', () {
fakeAsync((async) {
expect(service(syncedBefore: true).status.syncedHere, isTrue);
failWith = Exception('No connection');
final kept = MemoryPendingChangesStore(0, false);
final sync = service(pendingChanges: kept)..start();
async.flushMicrotasks();
expect(sync.status.syncedHere, isFalse);
expect(sync.status.hasTrouble, isTrue);
failWith = null;
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.syncedHere, isTrue);
expect(kept.synced, isTrue);
sync.requestSync();
async.flushMicrotasks();
expect(sync.status.syncedHere, isTrue);
sync.dispose();
});
});
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
@ -114,6 +116,35 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets("until this device's first sync went through, an empty "
'library says it is on its way; then that there are no studysets '
'yet', (tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final first = Completer<void>();
await pumpApp(
tester,
testDependencies(
studysetGateway: gateway,
syncedBefore: false,
syncOnce: ({required onMerge}) => first.future,
),
);
// Its spinner turns without end: frames, not settling.
await tester.tap(tab('Library'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.textContaining('Your library is on its way from'), findsOne);
expect(find.text('No studysets yet'), findsNothing);
first.complete();
await tester.pumpAndSettle();
expect(find.textContaining('on its way'), findsNothing);
expect(find.text('No studysets yet'), findsOneWidget);
});
testWidgets('says when there are no studysets yet', (tester) async {
await showLibrary(tester);

View file

@ -1,16 +1,24 @@
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
/// Keeps the count of changes not on the server yet in memory.
class MemoryPendingChangesStore([int initial = 0])
/// Keeps the count of changes not on the server yet, and whether a sync
/// went through, in memory.
class MemoryPendingChangesStore([int initial = 0, bool synced = true])
implements PendingChangesStore {
var _count = initial;
var _synced = synced;
/// The count kept now.
int get kept => _count;
@override
Future<int> read() async => _count;
/// Whether it keeps that a sync went through.
bool get synced => _synced;
@override
Future<void> write(int count) async => _count = count;
Future<KeptSync> read() async => (pending: _count, synced: _synced);
@override
Future<void> write(KeptSync kept) async {
_count = kept.pending;
_synced = kept.synced;
}
}

View file

@ -175,6 +175,9 @@ Dependencies testDependencies({
DiscussionGateway? discussionGateway,
SyncOnce? syncOnce,
PendingChangesStore? pendingChanges,
// A device that synced before, as most are; false for one whose first
// sync is still to go through.
bool syncedBefore = true,
OpenPerson? openPerson,
AssistantHandOff? assistantHandOff,
WebSearch? webSearch,
@ -237,6 +240,7 @@ Dependencies testDependencies({
final sync = SyncService(
syncOnce ?? ({required onMerge}) async {},
pendingChanges: pendingChanges,
syncedBefore: syncedBefore,
);
if (syncOnce != null) {
sync.syncNow();