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

View file

@ -163,7 +163,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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.
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. **Signed out, the app asks how to start** (decided 2026-10-16, `StartScreen`): on the instance in use, with GitHub, by email or without an account, or on another instance; it makes an anonymous account by itself only at the very first start. A session the server ended leads there too. That the device is signed out is kept in the people's folder (`SignedOutMark`, `signed_out`), so a restart asks again. Settings names the kind of account and its instance ("Anonymous on learn.halkyon.eu").
- **Asking an AI assistant or searching the web** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): at the lower left of the Library, across from Create studyset, at the left above a studyset's bottom bar (floating alone at the lower left from expanded windows on), and at the lower left while studying, above the buttons that answer the card (decided 2026-10-16, as Vaia places it). A switch at the field's start sends to the AI assistant chosen in Settings, named in the field, or to a web search and is remembered (`assistantTarget`). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`: a card's front, back, each hint, its explanation, the studyset's name and description), none chosen, so only what the person picks goes along, each cut at 600 characters; a web search takes the question and the chosen text, never the studyset's name or description. Nothing is answered here: `AssistantHandOff` opens the chosen assistant's new conversation with the question and a compact line per chosen part written in, for the person to send (`AiAssistant`, decided 2026-10-14: Proton's Lumo by default, by its guest chat `https://lumo.proton.me/guest#prefill=…`, the question in the fragment, which reaches no server, as Lumo's page reads it, undocumented; its address for one's own account loses the question when nobody is signed in; then ChatGPT, Claude, Mistral, Perplexity, which answers at once, Duck.ai, Grok, which asks first, Kagi Assistant, HuggingChat, or the person's own address with `%s`; only assistants whose address fills a question in, so not Gemini, which has none, nor Copilot, whose stopped working in November 2025; each cut at what it takes, 8,000 to 12,000 characters), and `WebSearch` opens the chosen engine's results (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands the question to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Halkyon apps use no LLM API keys: their pricing is opaque and open to abuse, and the person decides each time what an LLM sees. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
- **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).
- **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, beside whether a sync ever went through on this device (`syncedHere`, read as the person's data opens): until one has, an empty library says it is on its way from the instance rather than that there are no studysets (decided 2026-10-17: a first sync brings everything at once, at its end). 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. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, branch `halkyon/byte-payloads`, decided 2026-10-12): v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes. Move the fork along when upgrading, and drop it once a release has the fix.
- **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, and says why a handle couldn't be theirs (`checkHandle`, null when it can: whose a handle is, `byHandle` tells anyone already); `ProfilesEndpoint` finds anyone's by handle, which the app shows in a sheet (`showProfileOf`, decided 2026-10-14) from a studyset's author in Explore and from a licence holder written as a handle, `© raisondetredev`, a link set apart from a name from elsewhere. 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. **A profile has a page on the web** (ADR 0011, decided 2026-10-15): `https://<account domain>/@handle` where the operator set `ACCOUNT_DOMAIN` (so the address belongs to the identity, which other services such as a future train.halkyon.eu may share), else on the web server (`profilePageUrl`); served by `profile_page.dart` on the web server (`/*`, one segment, after the connector's literal routes; Caddy forwards `/@…` on the instance's domain and on the account domain, which serves nothing else), in English or German, no JavaScript, `noindex`, every link `rel="me"`; asked for on another host, it redirects to its own address. **Links are verified as Mastodon verifies them**: `profile.checkLinks` (at most 10 an hour; the app asks after a save that brings an unchecked link, and on Check links) fetches each link's page (`SafeLinkFetcher`: HTTPS on 443, public addresses only, checked before connecting and connected to as checked, 3 redirects, 1 MiB, 10 s) and keeps in `verifiedLinks` those linking back with `rel="me"` (`linksBackTo`, pub.dev's `html`); a changed link loses its check, a new handle all of them. 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 in a card of its own after the account's and the inbox's (each part a card, as Settings groups its own), as a form, for people with an identity, that saves as it is written (decided 2026-10-12): a field when it is left or Enter is pressed in it, a link when removed, never twice the same; the handle is chosen in a dialog that asks the instance while it is typed whether it is free, and choosing the first makes the profile with what was written (`PublicProfileBloc` takes saves one at a time, the latest of those waiting next, and a handle chosen holds for every save after it).

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