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:
parent
bc3e8d9ac3
commit
ab4542f143
15 changed files with 318 additions and 19 deletions
|
|
@ -153,7 +153,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
||||||
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
|
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
|
||||||
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
|
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
|
||||||
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
|
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
|
||||||
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
||||||
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
|
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
|
||||||
|
|
|
||||||
|
|
@ -476,6 +476,7 @@
|
||||||
"syncNoRunYet": "Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen",
|
"syncNoRunYet": "Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen",
|
||||||
"syncLastFailed": "Letzter Versuch fehlgeschlagen um {time}",
|
"syncLastFailed": "Letzter Versuch fehlgeschlagen um {time}",
|
||||||
"syncNow": "Jetzt synchronisieren",
|
"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",
|
"syncTroubleLabel": "Synchronisierung benötigt Aufmerksamkeit",
|
||||||
"syncMismatchBanner": "Diese App kann nicht mehr mit {server} synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.",
|
"syncMismatchBanner": "Diese App kann nicht mehr mit {server} synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.",
|
||||||
"syncMismatchMore": "Mehr",
|
"syncMismatchMore": "Mehr",
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,8 @@
|
||||||
"syncLastFailed": "Last attempt failed at {time}",
|
"syncLastFailed": "Last attempt failed at {time}",
|
||||||
"@syncLastFailed": {"placeholders": {"time": {"type": "String"}}},
|
"@syncLastFailed": {"placeholders": {"time": {"type": "String"}}},
|
||||||
"syncNow": "Sync now",
|
"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",
|
"syncTroubleLabel": "Sync needs attention",
|
||||||
"syncMismatchBanner": "This app can't sync with {server} any more. Update it to keep syncing.",
|
"syncMismatchBanner": "This app can't sync with {server} any more. Update it to keep syncing.",
|
||||||
"@syncMismatchBanner": {"placeholders": {"server": {"type": "String"}}},
|
"@syncMismatchBanner": {"placeholders": {"server": {"type": "String"}}},
|
||||||
|
|
|
||||||
|
|
@ -2878,6 +2878,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Sync now'**
|
/// **'Sync now'**
|
||||||
String get syncNow;
|
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.
|
/// No description provided for @syncTroubleLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1682,6 +1682,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get syncNow => 'Jetzt synchronisieren';
|
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
|
@override
|
||||||
String get syncTroubleLabel => 'Synchronisierung benötigt Aufmerksamkeit';
|
String get syncTroubleLabel => 'Synchronisierung benötigt Aufmerksamkeit';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1670,6 +1670,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get syncNow => 'Sync now';
|
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
|
@override
|
||||||
String get syncTroubleLabel => 'Sync needs attention';
|
String get syncTroubleLabel => 'Sync needs attention';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_picker.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/files/file_saver.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/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_service.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
|
|
@ -251,6 +252,9 @@ Future<PersonDependencies> _openPerson(
|
||||||
),
|
),
|
||||||
compatibility,
|
compatibility,
|
||||||
),
|
),
|
||||||
|
pendingChanges: FilePendingChangesStore(
|
||||||
|
File(p.join(folder.path, 'sync_pending.json')),
|
||||||
|
),
|
||||||
)..start();
|
)..start();
|
||||||
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
|
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
|
||||||
final cards = CardRepository(
|
final cards = CardRepository(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:developer';
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:flutter/widgets.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/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';
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
||||||
|
|
||||||
/// Syncs once and calls `onMerge` whenever changes from other devices arrive.
|
/// 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.
|
/// 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
|
/// A failed sync, for example without a connection, is logged and retried on
|
||||||
/// the next trigger; [status] tells how the last one went.
|
/// 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(
|
class SyncService(
|
||||||
final SyncOnce _syncOnce, {
|
final SyncOnce _syncOnce, {
|
||||||
|
|
||||||
|
|
@ -23,6 +30,10 @@ class SyncService(
|
||||||
|
|
||||||
/// How often it syncs while nothing else triggers it.
|
/// How often it syncs while nothing else triggers it.
|
||||||
final Duration interval = const Duration(minutes: 5),
|
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 _merges = StreamController<void>.broadcast();
|
||||||
final _synced = StreamController<void>.broadcast();
|
final _synced = StreamController<void>.broadcast();
|
||||||
|
|
@ -35,6 +46,19 @@ class SyncService(
|
||||||
bool _runAgain = false;
|
bool _runAgain = false;
|
||||||
bool _stopping = 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.
|
/// Fires whenever a sync brought changes from other devices.
|
||||||
Stream<void> get merges => _merges.stream;
|
Stream<void> get merges => _merges.stream;
|
||||||
|
|
||||||
|
|
@ -50,13 +74,15 @@ class SyncService(
|
||||||
|
|
||||||
/// Syncs now, then on the triggers.
|
/// Syncs now, then on the triggers.
|
||||||
void start() {
|
void start() {
|
||||||
|
unawaited(_restoring);
|
||||||
_periodic = Timer.periodic(interval, (_) => _run());
|
_periodic = Timer.periodic(interval, (_) => _run());
|
||||||
_lifecycle = AppLifecycleListener(onResume: _run);
|
_lifecycle = AppLifecycleListener(onResume: _run);
|
||||||
_run();
|
_run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Syncs shortly, once local changes have settled.
|
/// Syncs shortly, once local changes have settled; a change was saved.
|
||||||
void requestSync() {
|
void requestSync() {
|
||||||
|
_setPending(_pending + 1);
|
||||||
_debounceTimer?.cancel();
|
_debounceTimer?.cancel();
|
||||||
_debounceTimer = Timer(debounce, _run);
|
_debounceTimer = Timer(debounce, _run);
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +119,7 @@ class SyncService(
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWhileTriggered() async {
|
Future<void> _syncWhileTriggered() async {
|
||||||
|
await _restoring;
|
||||||
do {
|
do {
|
||||||
_runAgain = false;
|
_runAgain = false;
|
||||||
_report(
|
_report(
|
||||||
|
|
@ -100,11 +127,17 @@ class SyncService(
|
||||||
syncing: true,
|
syncing: true,
|
||||||
lastSyncedAt: _status.lastSyncedAt,
|
lastSyncedAt: _status.lastSyncedAt,
|
||||||
failure: _status.failure,
|
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 {
|
try {
|
||||||
await _syncOnce(onMerge: () => _merges.add(null));
|
await _syncOnce(onMerge: () => _merges.add(null));
|
||||||
_report(SyncStatus(lastSyncedAt: clock.now()));
|
_pending -= sending;
|
||||||
|
_report(SyncStatus(lastSyncedAt: clock.now(), pending: _pending));
|
||||||
|
_persistPending();
|
||||||
_synced.add(null);
|
_synced.add(null);
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
|
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
|
||||||
|
|
@ -115,6 +148,7 @@ class SyncService(
|
||||||
at: clock.now(),
|
at: clock.now(),
|
||||||
appMismatch: error is AppServerMismatchException,
|
appMismatch: error is AppServerMismatchException,
|
||||||
),
|
),
|
||||||
|
pending: _pending,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +157,55 @@ class SyncService(
|
||||||
|
|
||||||
void _report(SyncStatus status) {
|
void _report(SyncStatus status) {
|
||||||
_status = status;
|
_status = status;
|
||||||
|
if (!_statusChanges.isClosed) {
|
||||||
_statusChanges.add(status);
|
_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;
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ final class const SyncStatus({
|
||||||
/// How the most recent sync failed, or null if it went through (or none
|
/// How the most recent sync failed, or null if it went through (or none
|
||||||
/// has run yet).
|
/// has run yet).
|
||||||
final SyncFailure? failure,
|
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 {
|
}) with Equatable {
|
||||||
/// Whether sync needs the person's attention: its last outcome was a
|
/// Whether sync needs the person's attention: its last outcome was a
|
||||||
/// failure. A sync running meanwhile doesn't hide it.
|
/// failure. A sync running meanwhile doesn't hide it.
|
||||||
|
|
@ -26,5 +30,5 @@ final class const SyncStatus({
|
||||||
bool get appMismatch => failure?.appMismatch ?? false;
|
bool get appMismatch => failure?.appMismatch ?? false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [syncing, lastSyncedAt, failure];
|
List<Object?> get props => [syncing, lastSyncedAt, failure, pending];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
/// 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
|
/// 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 {
|
class const SyncStatusCard({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -25,10 +25,14 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
|
||||||
final at? => l10n.syncLastCompleted(time(at)),
|
final at? => l10n.syncLastCompleted(time(at)),
|
||||||
null => l10n.syncNoRunYet,
|
null => l10n.syncNoRunYet,
|
||||||
};
|
};
|
||||||
|
final pending = switch (status.pending) {
|
||||||
|
0 => null,
|
||||||
|
final count => l10n.syncPending(count),
|
||||||
|
};
|
||||||
final (state, details) = switch (status) {
|
final (state, details) = switch (status) {
|
||||||
SyncStatus(appMismatch: true) => (
|
SyncStatus(appMismatch: true) => (
|
||||||
l10n.syncStateMismatch(server),
|
l10n.syncStateMismatch(server),
|
||||||
[l10n.syncMismatchDetail, completed],
|
[l10n.syncMismatchDetail, ?pending, completed],
|
||||||
),
|
),
|
||||||
SyncStatus(syncing: true, :final lastSyncedAt) => (
|
SyncStatus(syncing: true, :final lastSyncedAt) => (
|
||||||
l10n.syncStateSyncing,
|
l10n.syncStateSyncing,
|
||||||
|
|
@ -36,10 +40,15 @@ class const SyncStatusCard({super.key}) extends StatelessWidget {
|
||||||
),
|
),
|
||||||
SyncStatus(failure: (:final at, appMismatch: _)) => (
|
SyncStatus(failure: (:final at, appMismatch: _)) => (
|
||||||
l10n.syncStateFailing,
|
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]),
|
SyncStatus(lastSyncedAt: _?) => (l10n.syncStateSynced, [completed]),
|
||||||
_ => (l10n.syncStateNotYet, <String>[]),
|
_ => (l10n.syncStateNotYet, [?pending]),
|
||||||
};
|
};
|
||||||
return SectionCard(
|
return SectionCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/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_service.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
||||||
|
|
||||||
|
import '../../support/memory_pending_changes_store.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
|
@ -17,14 +20,17 @@ void main() {
|
||||||
// While set and not completed, a sync keeps running.
|
// While set and not completed, a sync keeps running.
|
||||||
late Completer<void>? hold;
|
late Completer<void>? hold;
|
||||||
|
|
||||||
SyncService service() => SyncService(({required onMerge}) async {
|
SyncService service({PendingChangesStore? pendingChanges}) => SyncService(
|
||||||
|
({required onMerge}) async {
|
||||||
syncs++;
|
syncs++;
|
||||||
merge = onMerge;
|
merge = onMerge;
|
||||||
await hold?.future;
|
await hold?.future;
|
||||||
if (failWith case final error?) {
|
if (failWith case final error?) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
pendingChanges: pendingChanges,
|
||||||
|
);
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
syncs = 0;
|
syncs = 0;
|
||||||
|
|
@ -272,4 +278,80 @@ void main() {
|
||||||
sync.dispose();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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/app_compatibility.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../../../support/memory_pending_changes_store.dart';
|
||||||
import '../../../support/pump_app.dart';
|
import '../../../support/pump_app.dart';
|
||||||
import '../../../support/test_dependencies.dart';
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
||||||
|
|
@ -9,7 +10,9 @@ void main() {
|
||||||
/// What the next sync does: null goes through, anything else is thrown.
|
/// What the next sync does: null goes through, anything else is thrown.
|
||||||
late Object? nextSync;
|
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.physicalSize = const Size(412, 900);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
|
@ -20,6 +23,7 @@ void main() {
|
||||||
final Object error => Future<void>.error(error),
|
final Object error => Future<void>.error(error),
|
||||||
null => Future<void>.value(),
|
null => Future<void>.value(),
|
||||||
},
|
},
|
||||||
|
pendingChanges: MemoryPendingChangesStore(pending),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
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 '
|
testWidgets('Sync now syncs, and the trouble goes once a sync went '
|
||||||
'through', (tester) async {
|
'through', (tester) async {
|
||||||
nextSync = Exception('No connection');
|
nextSync = Exception('No connection');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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/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_picker.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/files/file_saver.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/sync/sync_service.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
|
|
@ -143,6 +144,7 @@ Dependencies testDependencies({
|
||||||
FeedbackWriteGateway? feedbackWriteGateway,
|
FeedbackWriteGateway? feedbackWriteGateway,
|
||||||
FeedbackServerGateway? feedbackServerGateway,
|
FeedbackServerGateway? feedbackServerGateway,
|
||||||
SyncOnce? syncOnce,
|
SyncOnce? syncOnce,
|
||||||
|
PendingChangesStore? pendingChanges,
|
||||||
OpenPerson? openPerson,
|
OpenPerson? openPerson,
|
||||||
}) => Dependencies(
|
}) => Dependencies(
|
||||||
serverName: 'learn.example.org',
|
serverName: 'learn.example.org',
|
||||||
|
|
@ -168,7 +170,10 @@ Dependencies testDependencies({
|
||||||
openPerson:
|
openPerson:
|
||||||
openPerson ??
|
openPerson ??
|
||||||
(person) async {
|
(person) async {
|
||||||
final sync = SyncService(syncOnce ?? ({required onMerge}) async {});
|
final sync = SyncService(
|
||||||
|
syncOnce ?? ({required onMerge}) async {},
|
||||||
|
pendingChanges: pendingChanges,
|
||||||
|
);
|
||||||
if (syncOnce != null) {
|
if (syncOnce != null) {
|
||||||
sync.syncNow();
|
sync.syncNow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue