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:
parent
bb7757d7f3
commit
952d2b0607
15 changed files with 237 additions and 30 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue