feat(settings): how many changes wait to sync

The Sync card in Settings says how many changes made on this device
haven't reached the server yet: "3 changes wait to sync", in place of
"Everything synced" until a sync takes them, and beside a failure.

The sync engine can't tell, since the server only says what it has at
the start of each sync, so the app counts: every save asks for a sync,
and a sync that goes through takes the saves made before it started.
The count is kept in the person's folder, so it outlives the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 05:29:43 +02:00
commit ab4542f143
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 318 additions and 19 deletions

View file

@ -476,6 +476,7 @@
"syncNoRunYet": "Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen",
"syncLastFailed": "Letzter Versuch fehlgeschlagen um {time}",
"syncNow": "Jetzt synchronisieren",
"syncPending": "{count, plural, =1{1 Änderung wartet auf die Synchronisierung} other{{count} Änderungen warten auf die Synchronisierung}}",
"syncTroubleLabel": "Synchronisierung benötigt Aufmerksamkeit",
"syncMismatchBanner": "Diese App kann nicht mehr mit {server} synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.",
"syncMismatchMore": "Mehr",

View file

@ -513,6 +513,8 @@
"syncLastFailed": "Last attempt failed at {time}",
"@syncLastFailed": {"placeholders": {"time": {"type": "String"}}},
"syncNow": "Sync now",
"syncPending": "{count, plural, =1{1 change waits to sync} other{{count} changes wait to sync}}",
"@syncPending": {"placeholders": {"count": {"type": "int"}}},
"syncTroubleLabel": "Sync needs attention",
"syncMismatchBanner": "This app can't sync with {server} any more. Update it to keep syncing.",
"@syncMismatchBanner": {"placeholders": {"server": {"type": "String"}}},

View file

@ -2878,6 +2878,12 @@ abstract class AppLocalizations {
/// **'Sync now'**
String get syncNow;
/// No description provided for @syncPending.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 change waits to sync} other{{count} changes wait to sync}}'**
String syncPending(int count);
/// No description provided for @syncTroubleLabel.
///
/// In en, this message translates to:

View file

@ -1682,6 +1682,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get syncNow => 'Jetzt synchronisieren';
@override
String syncPending(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Änderungen warten auf die Synchronisierung',
one: '1 Änderung wartet auf die Synchronisierung',
);
return '$_temp0';
}
@override
String get syncTroubleLabel => 'Synchronisierung benötigt Aufmerksamkeit';

View file

@ -1670,6 +1670,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get syncNow => 'Sync now';
@override
String syncPending(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count changes wait to sync',
one: '1 change waits to sync',
);
return '$_temp0';
}
@override
String get syncTroubleLabel => 'Sync needs attention';

View file

@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_sett
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
@ -251,6 +252,9 @@ Future<PersonDependencies> _openPerson(
),
compatibility,
),
pendingChanges: FilePendingChangesStore(
File(p.join(folder.path, 'sync_pending.json')),
),
)..start();
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
final cards = CardRepository(

View file

@ -0,0 +1,45 @@
import 'dart:convert';
import 'dart:developer';
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();
/// Keeps [count].
Future<void> write(int count);
}
/// Keeps the count as JSON in [_file], in the person's folder, which goes
/// with them; written to a temporary file first and renamed into place.
class FilePendingChangesStore(final File _file) implements PendingChangesStore {
// One write at a time: they share the temporary file.
final _queue = SerialQueue();
@override
Future<int> read() async {
try {
return switch (jsonDecode(await _file.readAsString())) {
{'pending': final int count} when count >= 0 => count,
_ => 0,
};
} on FileSystemException {
return 0;
} on FormatException catch (error) {
log('The pending changes are unreadable', name: 'Sync', error: error);
return 0;
}
}
@override
Future<void> write(int count) => _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.rename(_file.path);
});
}

View file

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:flutter/widgets.dart';
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
/// Syncs once and calls `onMerge` whenever changes from other devices arrive.
@ -15,6 +16,12 @@ typedef SyncOnce = Future<void> Function({required VoidCallback onMerge});
/// Continuous sync (the engine's live mode) comes once it has been hardened.
/// A failed sync, for example without a connection, is logged and retried on
/// the next trigger; [status] tells how the last one went.
///
/// It counts the changes made here that haven't reached the server: every
/// save asks for a sync ([requestSync]), and a sync that goes through takes
/// those made before it started. The engine itself can't say, as the server
/// only tells it what it has at the start of each sync. [pendingChanges]
/// keeps the count while the app is closed.
class SyncService(
final SyncOnce _syncOnce, {
@ -23,6 +30,10 @@ class SyncService(
/// How often it syncs while nothing else triggers it.
final Duration interval = const Duration(minutes: 5),
/// Where the count of changes not on the server yet is kept; only in
/// memory when null.
final PendingChangesStore? pendingChanges,
}) {
final _merges = StreamController<void>.broadcast();
final _synced = StreamController<void>.broadcast();
@ -35,6 +46,19 @@ class SyncService(
bool _runAgain = false;
bool _stopping = false;
/// The changes made here not on the server yet.
var _pending = 0;
/// Reading the count kept from before: begun at [start] or by the first
/// sync, whichever comes first, and done before that sync, which takes
/// those changes too.
late final Future<void> _restoring = _restore();
/// Whether a write of the count is under way, and whether another must
/// follow it, the count having changed meanwhile.
var _writing = false;
var _writeAgain = false;
/// Fires whenever a sync brought changes from other devices.
Stream<void> get merges => _merges.stream;
@ -50,13 +74,15 @@ class SyncService(
/// Syncs now, then on the triggers.
void start() {
unawaited(_restoring);
_periodic = Timer.periodic(interval, (_) => _run());
_lifecycle = AppLifecycleListener(onResume: _run);
_run();
}
/// Syncs shortly, once local changes have settled.
/// Syncs shortly, once local changes have settled; a change was saved.
void requestSync() {
_setPending(_pending + 1);
_debounceTimer?.cancel();
_debounceTimer = Timer(debounce, _run);
}
@ -93,6 +119,7 @@ class SyncService(
}
Future<void> _syncWhileTriggered() async {
await _restoring;
do {
_runAgain = false;
_report(
@ -100,11 +127,17 @@ class SyncService(
syncing: true,
lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure,
pending: _pending,
),
);
// Those saved before the sync starts go with it; those saved while it
// runs, perhaps too late for it, wait for the next.
final sending = _pending;
try {
await _syncOnce(onMerge: () => _merges.add(null));
_report(SyncStatus(lastSyncedAt: clock.now()));
_pending -= sending;
_report(SyncStatus(lastSyncedAt: clock.now(), pending: _pending));
_persistPending();
_synced.add(null);
} on Object catch (error, st) {
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
@ -115,6 +148,7 @@ class SyncService(
at: clock.now(),
appMismatch: error is AppServerMismatchException,
),
pending: _pending,
),
);
}
@ -123,6 +157,55 @@ class SyncService(
void _report(SyncStatus status) {
_status = status;
_statusChanges.add(status);
if (!_statusChanges.isClosed) {
_statusChanges.add(status);
}
}
Future<void> _restore() async {
final kept = await pendingChanges?.read() ?? 0;
if (kept > 0) {
_setPending(_pending + kept);
}
}
void _setPending(int pending) {
_pending = pending;
_report(
SyncStatus(
syncing: _status.syncing,
lastSyncedAt: _status.lastSyncedAt,
failure: _status.failure,
pending: pending,
),
);
_persistPending();
}
/// Keeps the count, one write at a time and the latest last: a burst of
/// saves writes it twice, not once each.
void _persistPending() {
final store = pendingChanges;
// Once stopping, the person's folder may be about to go.
if (store == null || _stopping) {
return;
}
if (_writing) {
_writeAgain = true;
return;
}
_writing = true;
unawaited(() async {
try {
do {
_writeAgain = false;
await store.write(_pending);
} while (_writeAgain);
} on Object catch (error) {
log('Keeping the pending changes failed', name: 'Sync', error: error);
} finally {
_writing = false;
}
}());
}
}

View file

@ -16,6 +16,10 @@ final class const SyncStatus({
/// How the most recent sync failed, or null if it went through (or none
/// has run yet).
final SyncFailure? failure,
/// 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,
}) with Equatable {
/// Whether sync needs the person's attention: its last outcome was a
/// failure. A sync running meanwhile doesn't hide it.
@ -26,5 +30,5 @@ final class const SyncStatus({
bool get appMismatch => failure?.appMismatch ?? false;
@override
List<Object?> get props => [syncing, lastSyncedAt, failure];
List<Object?> get props => [syncing, lastSyncedAt, failure, pending];
}

View file

@ -10,7 +10,7 @@ import 'package:material_ui/material_ui.dart';
/// The Sync card in Settings: where sync stands, in one line and a detail,
/// and Sync now. The one place that answers whether the person's changes
/// reach the server.
/// reach the server, and how many haven't yet.
class const SyncStatusCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -25,10 +25,14 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
final at? => l10n.syncLastCompleted(time(at)),
null => l10n.syncNoRunYet,
};
final pending = switch (status.pending) {
0 => null,
final count => l10n.syncPending(count),
};
final (state, details) = switch (status) {
SyncStatus(appMismatch: true) => (
l10n.syncStateMismatch(server),
[l10n.syncMismatchDetail, completed],
[l10n.syncMismatchDetail, ?pending, completed],
),
SyncStatus(syncing: true, :final lastSyncedAt) => (
l10n.syncStateSyncing,
@ -36,10 +40,15 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
),
SyncStatus(failure: (:final at, appMismatch: _)) => (
l10n.syncStateFailing,
[l10n.syncLastFailed(time(at)), completed],
[l10n.syncLastFailed(time(at)), ?pending, completed],
),
// Saved since, and on their way once the changes settle.
SyncStatus(lastSyncedAt: _?) when pending != null => (
pending,
[completed],
),
SyncStatus(lastSyncedAt: _?) => (l10n.syncStateSynced, [completed]),
_ => (l10n.syncStateNotYet, <String>[]),
_ => (l10n.syncStateNotYet, [?pending]),
};
return SectionCard(
child: Column(

View file

@ -5,9 +5,12 @@ import 'package:fake_async/fake_async.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
import '../../support/memory_pending_changes_store.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -17,14 +20,17 @@ void main() {
// While set and not completed, a sync keeps running.
late Completer<void>? hold;
SyncService service() => SyncService(({required onMerge}) async {
syncs++;
merge = onMerge;
await hold?.future;
if (failWith case final error?) {
throw error;
}
});
SyncService service({PendingChangesStore? pendingChanges}) => SyncService(
({required onMerge}) async {
syncs++;
merge = onMerge;
await hold?.future;
if (failWith case final error?) {
throw error;
}
},
pendingChanges: pendingChanges,
);
setUp(() {
syncs = 0;
@ -272,4 +278,80 @@ void main() {
sync.dispose();
});
});
group('changes not on the server yet', () {
test('are the saves since the last sync that went through', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync
..requestSync()
..requestSync();
expect(sync.status.pending, 2);
async.elapse(const Duration(seconds: 2));
expect(sync.status.pending, 0);
sync.dispose();
});
});
test('stay while syncs fail', () {
fakeAsync((async) {
failWith = Exception('No connection');
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
async.elapse(const Duration(seconds: 2));
expect(sync.status.pending, 1);
expect(sync.status.hasTrouble, isTrue);
failWith = null;
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.pending, 0);
sync.dispose();
});
});
test('saved while a sync runs, wait for the next', () {
fakeAsync((async) {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
running.complete();
async.flushMicrotasks();
expect(sync.status.pending, 1);
hold = null;
async.elapse(const Duration(seconds: 2));
expect(sync.status.pending, 0);
sync.dispose();
});
});
test('are kept while the app is closed, and taken by the first sync', () {
fakeAsync((async) {
failWith = Exception('No connection');
final kept = MemoryPendingChangesStore(3);
final sync = service(pendingChanges: kept)..start();
async.flushMicrotasks();
expect(sync.status.pending, 3);
sync.requestSync();
async.flushMicrotasks();
expect(kept.kept, 4);
failWith = null;
sync.syncNow();
async.flushMicrotasks();
expect(sync.status.pending, 0);
expect(kept.kept, 0);
sync.dispose();
});
});
});
}

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/memory_pending_changes_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -9,7 +10,9 @@ void main() {
/// What the next sync does: null goes through, anything else is thrown.
late Object? nextSync;
Future<void> showApp(WidgetTester tester) async {
/// Shows the app, with [pending] changes kept from before that haven't
/// reached the server.
Future<void> showApp(WidgetTester tester, {int pending = 0}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -20,6 +23,7 @@ void main() {
final Object error => Future<void>.error(error),
null => Future<void>.value(),
},
pendingChanges: MemoryPendingChangesStore(pending),
),
);
await tester.pump();
@ -62,6 +66,22 @@ void main() {
);
});
testWidgets('Settings counts the changes that wait to sync, until a sync '
'takes them', (tester) async {
nextSync = Exception('No connection');
await showApp(tester, pending: 2);
await openSettings(tester);
expect(find.text('Sync is failing'), findsOneWidget);
expect(find.text('2 changes wait to sync'), findsOneWidget);
nextSync = null;
await tester.tap(find.text('Sync now'));
await tester.pumpAndSettle();
expect(find.text('Everything synced'), findsOneWidget);
expect(find.textContaining('wait to sync'), findsNothing);
});
testWidgets('Sync now syncs, and the trouble goes once a sync went '
'through', (tester) async {
nextSync = Exception('No connection');

View file

@ -0,0 +1,16 @@
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])
implements PendingChangesStore {
var _count = initial;
/// The count kept now.
int get kept => _count;
@override
Future<int> read() async => _count;
@override
Future<void> write(int count) async => _count = count;
}

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_setti
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
@ -143,6 +144,7 @@ Dependencies testDependencies({
FeedbackWriteGateway? feedbackWriteGateway,
FeedbackServerGateway? feedbackServerGateway,
SyncOnce? syncOnce,
PendingChangesStore? pendingChanges,
OpenPerson? openPerson,
}) => Dependencies(
serverName: 'learn.example.org',
@ -168,7 +170,10 @@ Dependencies testDependencies({
openPerson:
openPerson ??
(person) async {
final sync = SyncService(syncOnce ?? ({required onMerge}) async {});
final sync = SyncService(
syncOnce ?? ({required onMerge}) async {},
pendingChanges: pendingChanges,
);
if (syncOnce != null) {
sync.syncNow();
}