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
15 changed files with 237 additions and 30 deletions

View file

@ -15,6 +15,7 @@
"libraryTitle": "Bibliothek",
"libraryCreateStudyset": "Lernset erstellen",
"libraryEmpty": "Noch keine Lernsets",
"libraryOnItsWay": "Deine Bibliothek wird von {instance} geladen …",
"libraryUnreadable": "Deine Lernsets konnten nicht gelesen werden.",
"libraryArchivedSection": "Archiviert",
"librarySearchTooltip": "Suchen",

View file

@ -16,6 +16,13 @@
"libraryTitle": "Library",
"libraryCreateStudyset": "Create studyset",
"libraryEmpty": "No studysets yet",
"libraryOnItsWay": "Your library is on its way from {instance}…",
"@libraryOnItsWay": {
"description": "An empty library while this device's first sync brings the person's studysets.",
"placeholders": {
"instance": {"type": "String", "example": "learn.halkyon.eu"}
}
},
"libraryUnreadable": "Couldn't read your studysets.",
"libraryArchivedSection": "Archived",
"librarySearchTooltip": "Search",

View file

@ -166,6 +166,12 @@ abstract class AppLocalizations {
/// **'No studysets yet'**
String get libraryEmpty;
/// An empty library while this device's first sync brings the person's studysets.
///
/// In en, this message translates to:
/// **'Your library is on its way from {instance}…'**
String libraryOnItsWay(String instance);
/// No description provided for @libraryUnreadable.
///
/// In en, this message translates to:

View file

@ -42,6 +42,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryEmpty => 'Noch keine Lernsets';
@override
String libraryOnItsWay(String instance) {
return 'Deine Bibliothek wird von $instance geladen …';
}
@override
String get libraryUnreadable =>
'Deine Lernsets konnten nicht gelesen werden.';

View file

@ -42,6 +42,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryEmpty => 'No studysets yet';
@override
String libraryOnItsWay(String instance) {
return 'Your library is on its way from $instance…';
}
@override
String get libraryUnreadable => 'Couldn\'t read your studysets.';

View file

@ -375,6 +375,9 @@ Future<PersonDependencies> _openPerson(
p.join(folder.path, 'sync.sqlite'),
persistentUserId: UuidValue.withValidation(person.authUserId),
);
final kept = FilePendingChangesStore(
File(p.join(folder.path, 'sync_pending.json')),
);
final sync = SyncService(
compatibleSync(
({required onMerge}) => client.offlineSync.syncOnce(
@ -383,9 +386,8 @@ Future<PersonDependencies> _openPerson(
),
compatibility,
),
pendingChanges: FilePendingChangesStore(
File(p.join(folder.path, 'sync_pending.json')),
),
pendingChanges: kept,
syncedBefore: (await kept.read()).synced,
)..start();
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
final cards = CardRepository(

View file

@ -4,14 +4,19 @@ import 'dart:io';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
/// Where the device keeps how many of its changes haven't reached the
/// server yet, so the count outlives the app.
abstract interface class PendingChangesStore() {
/// The count kept; 0 when there is none or it can't be read.
Future<int> read();
/// What sync keeps for a person on the device: how many of their changes
/// haven't reached the server yet, and whether a sync ever went through
/// here (before it has, an empty library may be on its way).
typedef KeptSync = ({int pending, bool synced});
/// Keeps [count].
Future<void> write(int count);
/// Where the device keeps [KeptSync], so it outlives the app.
abstract interface class PendingChangesStore() {
/// What is kept: none pending and never synced when there is nothing or
/// it can't be read.
Future<KeptSync> read();
/// Keeps [kept].
Future<void> write(KeptSync kept);
}
/// Keeps the count as JSON in [_file], in the person's folder, which goes
@ -21,25 +26,32 @@ class FilePendingChangesStore(final File _file) implements PendingChangesStore {
final _queue = SerialQueue();
@override
Future<int> read() async {
Future<KeptSync> read() async {
try {
return switch (jsonDecode(await _file.readAsString())) {
{'pending': final int count} when count >= 0 => count,
_ => 0,
// A file from before `synced` was kept is a device's that synced.
{'pending': final int count} && final json when count >= 0 => (
pending: count,
synced: json['synced'] != false,
),
_ => (pending: 0, synced: true),
};
} on FileSystemException {
return 0;
return (pending: 0, synced: false);
} on FormatException catch (error) {
log('The pending changes are unreadable', name: 'Sync', error: error);
return 0;
return (pending: 0, synced: true);
}
}
@override
Future<void> write(int count) => _queue.run(() async {
Future<void> write(KeptSync kept) => _queue.run(() async {
await _file.parent.create(recursive: true);
final temporary = File('${_file.path}.new');
await temporary.writeAsString(jsonEncode({'pending': count}), flush: true);
await temporary.writeAsString(
jsonEncode({'pending': kept.pending, 'synced': kept.synced}),
flush: true,
);
await temporary.rename(_file.path);
});
}

View file

@ -35,11 +35,15 @@ class SyncService(
/// Where the count of changes not on the server yet is kept; only in
/// memory when null.
final PendingChangesStore? pendingChanges,
/// Whether a sync went through on this device before, as [pendingChanges]
/// kept it: read as the person's data opens, so the first status has it.
final bool syncedBefore = false,
}) {
final _merges = StreamController<void>.broadcast();
final _synced = StreamController<void>.broadcast();
final _statusChanges = StreamController<SyncStatus>.broadcast();
var _status = const SyncStatus();
late var _status = SyncStatus(syncedHere: syncedBefore);
Timer? _debounceTimer;
Timer? _periodic;
AppLifecycleListener? _lifecycle;
@ -70,7 +74,7 @@ class SyncService(
() async {
// Once stopping, the person's folder may be about to go.
if (pendingChanges case final store? when !_stopping) {
await store.write(_pending);
await store.write((pending: _pending, synced: _status.syncedHere));
}
},
onError: (error, _) =>
@ -141,6 +145,7 @@ class SyncService(
lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure,
pending: _pending,
syncedHere: _status.syncedHere,
),
);
// Those saved before the sync starts go with it; those saved while it
@ -149,7 +154,13 @@ class SyncService(
try {
await _syncOnce(onMerge: () => _merges.add(null));
_pending -= sending;
_report(SyncStatus(lastSyncedAt: clock.now(), pending: _pending));
_report(
SyncStatus(
lastSyncedAt: clock.now(),
pending: _pending,
syncedHere: true,
),
);
_persistPending();
_synced.add(null);
} on Object catch (error, st) {
@ -162,6 +173,7 @@ class SyncService(
appMismatch: error is AppServerMismatchException,
),
pending: _pending,
syncedHere: _status.syncedHere,
),
);
}
@ -175,7 +187,7 @@ class SyncService(
}
Future<void> _restore() async {
final kept = await pendingChanges?.read() ?? 0;
final kept = (await pendingChanges?.read())?.pending ?? 0;
if (kept > 0) {
_setPending(_pending + kept);
}
@ -189,6 +201,7 @@ class SyncService(
lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure,
pending: pending,
syncedHere: _status.syncedHere,
),
);
_persistPending();

View file

@ -20,6 +20,10 @@ final class const SyncStatus({
/// How many changes made on this device haven't reached the server yet,
/// as it counts them: the saves since the last sync that went through.
final int pending = 0,
/// Whether a sync ever went through on this device for this person, this
/// run or before: until one has, their library may be on its way.
final bool syncedHere = false,
}) with Equatable {
/// Whether sync needs the person's attention: its last outcome was a
/// failure. A sync running meanwhile doesn't hide it.
@ -30,5 +34,11 @@ final class const SyncStatus({
bool get appMismatch => failure?.appMismatch ?? false;
@override
List<Object?> get props => [syncing, lastSyncedAt, failure, pending];
List<Object?> get props => [
syncing,
lastSyncedAt,
failure,
pending,
syncedHere,
];
}

View file

@ -3,7 +3,9 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
@ -301,7 +303,7 @@ class _LibraryState() extends State<_Library> {
child: _Failed(),
),
StudysetListStateLoaded(studysets: [], deleted: []) =>
_Pullable(child: _Message(l10n.libraryEmpty)),
const _Pullable(child: _EmptyLibrary()),
StudysetListStateLoaded(
:final studysets,
:final progress,
@ -382,6 +384,30 @@ class const _Pullable({required final Widget child}) extends StatelessWidget {
);
}
/// What an empty library says: until this device's first sync has gone
/// through (decided 2026-10-17), that the library is on its way, as it
/// comes all at once; then that there are no studysets yet.
class const _EmptyLibrary() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final status = context.watch<SyncStatusBloc>().state;
if (status.syncedHere || status.hasTrouble) {
return _Message(l10n.libraryEmpty);
}
final instance =
InstanceScope.maybeOf(context)?.choice.current?.name ??
DependenciesScope.of(context).serverName;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppLoadingIndicator(),
_Message(l10n.libraryOnItsWay(instance)),
],
);
}
}
class const _Message(final String text) extends StatelessWidget {
@override
Widget build(BuildContext context) {

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();