feat(app): studysets, offline first and synced on triggers

The first synced feature. Every person gets their own local database (<app support>/people/<authUserId>/sync.sqlite): PersonScope opens it inside SessionGate's per-person subtree through Dependencies.openPerson, together with its sync and the person's repositories (PersonDependencies), and closes it when the person changes. SessionGate then also closes pushed screens, which live above the person's subtree; a screen's route() hands its bloc the repository it needs.

SyncStudysetGateway reads and writes the studyset table through the sync session, names the payload column in every update and asks for a sync after every write. StudysetRepository encodes and decodes the payload (studyset_payload.dart: UTF-8 JSON with a schema version; a newer schema or garbage is skipped with a log), re-reads after its own writes and after merges, and blanks the payload before deleting, since the engine keeps deleted rows until the space is deleted (ADR 0002, rule 2). A failed read reaches the list as an error, and the next change reads again. SyncService syncs on triggers: at start, 2 s after the last local write, on resume and every 5 minutes, one sync at a time, and lets a running sync finish before the database closes.

Screens: the list, now the home screen in place of the editor playground, creates studysets through a dialog; a studyset's screen edits its title and description, saves on request and asks before deleting.

Tests: the payload codec, the repository against a fake gateway (including a failed read and rows from a newer app), the sync triggers under fakeAsync, both blocs' sequences, and the screens, including that cancelling the delete keeps the studyset. The session tests now settle until the person's data is open. AGENTS.md describes the per-person graph, synced data and the envelope as it now is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 10:11:24 +02:00
commit 9387e0a827
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
26 changed files with 1594 additions and 82 deletions

View file

@ -70,9 +70,12 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Feature-first.** App: `lib/src/core/` holds infrastructure shared by features, `lib/src/feature/<name>/` everything a user would name. Server: `lib/src/<feature>/` holds that feature's `.spy.yaml` models and endpoints. Code moves into `core/` only once a second feature needs it. Why: a feature is added, changed or removed in one place.
- **Four layers per app feature**, dependencies pointing strictly downward: `widget/` (UI; reads bloc state, sends events) → `bloc/` (UI state; orchestrates repositories) → `data/` (repositories and gateways) → `model/` (immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc.
- **Generated protocol classes are not domain models.** They carry the envelope (`payload` as bytes); `model/` holds the decoded content. Mapping between them happens in `data/`.
- **Import `halkyon_learn_client` with a prefix (`as protocol`) or a `show` list.** The generator emits a `<Model>Repository` for every table, and its names clash with the app's own repositories.
- **Gateway / repository.** A gateway is a thin boundary to *one* backend (e.g. the Serverpod client's endpoints) behind an `abstract interface class`. A repository is what features use: it composes gateways and holds the logic. Blocs depend on repositories, never on gateways or the raw client. Why: gateways are the seams tests fake, and adding the offline layer later becomes a new local gateway, not a rewrite of every feature.
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
- **Pushed screens live above that subtree** (the `Navigator` belongs to `MaterialApp`). A screen's static `route(context, …)` reads what it needs from `PersonScope.of(context)` when it is pushed and hands it to the screen's bloc, and `SessionGate` closes every pushed screen when the person changes.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
@ -89,6 +92,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
## Rules the code relies on
@ -98,7 +102,9 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: content tables carry `audience`, `keyId` (null = not yet encrypted), `payload` (`ByteData`) and `nonce`. Content fields live inside `payload`, so the server can't sort or search by them.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded in one place per feature (e.g. `studyset_payload.dart`). Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in.
- **Linux builds need** `libwebkit2gtk-4.1-dev` and `libsecret-1-dev`. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.

View file

@ -3,7 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
import 'package:halkyon_learn_flutter/src/feature/editor_playground/widget/editor_playground_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_list_screen.dart';
import 'package:material_ui/material_ui.dart';
Future<void> main() async {
@ -41,7 +41,7 @@ class const HalkyonLearnApp({super.key}) extends StatelessWidget {
// bridge is deprecated on purpose, as a reminder: remove it once they have moved.
// ignore: deprecated_member_use
builder: (context, child) => MaterialUiCompatibilityBridge(child: child!),
home: const SessionGate(child: EditorPlaygroundScreen()),
home: const SessionGate(child: StudysetListScreen()),
);
}
}

View file

@ -1,8 +1,22 @@
import 'package:halkyon_learn_client/halkyon_learn_client.dart';
import 'dart:io';
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
show Client, UuidValue;
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Opens the dependencies of one signed-in person.
typedef OpenPerson = Future<PersonDependencies> Function(SignedIn person);
/// The app's composition root: everything the features depend on, built once
/// at startup and handed down through `DependenciesScope`.
@ -12,6 +26,9 @@ import 'package:serverpod_flutter/serverpod_flutter.dart';
class Dependencies({
/// Who is signed in, and signing in and out.
required final AuthRepository auth,
/// Opens a signed-in person's local data (see `PersonScope`).
required final OpenPerson openPerson,
final Future<void> Function()? _onDispose,
}) {
/// Builds the production graph around one Serverpod client.
@ -29,6 +46,7 @@ class Dependencies({
final authGateway = ServerpodAuthGateway(client);
return Dependencies(
auth: AuthRepository(authGateway),
openPerson: (person) => _openPerson(client, person),
onDispose: () async {
await authGateway.dispose();
client.close();
@ -41,3 +59,36 @@ class Dependencies({
await _onDispose?.call();
}
}
/// Opens [person]'s own local database (one folder per person, so people
/// sharing a device never share data), its sync, and the repositories.
Future<PersonDependencies> _openPerson(Client client, SignedIn person) async {
final folder = Directory(
p.join(
(await getApplicationSupportDirectory()).path,
'people',
person.authUserId,
),
);
await folder.create(recursive: true);
final db = await client.createSyncSession(
p.join(folder.path, 'sync.sqlite'),
persistentUserId: UuidValue.withValidation(person.authUserId),
);
final sync = SyncService(
({required onMerge}) => client.offlineSync.syncOnce(
db,
onMergeSuccess: (space, changes) => onMerge(),
),
)..start();
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
return PersonDependencies(
studysets: studysets,
onDispose: () async {
await studysets.dispose();
await sync.dispose();
await db.close();
},
);
}

View file

@ -0,0 +1,17 @@
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
/// Everything that belongs to one signed-in person: their local database, its
/// sync, and the repositories over it.
///
/// Opened when they sign in and disposed when the person changes, so nothing
/// of one person's session reaches another's (see `PersonScope`).
class PersonDependencies({
/// The person's studysets.
required final StudysetRepository studysets,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.
Future<void> dispose() async {
await _onDispose?.call();
}
}

View file

@ -0,0 +1,74 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:material_ui/material_ui.dart';
/// Opens [person]'s dependencies, shows [child] with them, and disposes them
/// when it goes away. `SessionGate` gives every person a new `PersonScope`.
class const PersonScope({
/// Whose dependencies these are.
required final SignedIn person,
/// What the person sees.
required final Widget child,
super.key,
}) extends StatefulWidget {
/// The dependencies of the person signed in above [context].
static PersonDependencies of(BuildContext context) {
final scope = context
.getInheritedWidgetOfExactType<_PersonDependenciesScope>();
assert(scope != null, 'No PersonScope above this widget.');
return scope!.dependencies;
}
@override
State<PersonScope> createState() => _PersonScopeState();
}
class _PersonScopeState() extends State<PersonScope> {
Future<PersonDependencies>? _opening;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_opening ??= DependenciesScope.of(context).openPerson(widget.person);
}
@override
void dispose() {
unawaited(
_opening?.then(
(dependencies) => dependencies.dispose(),
// Opening failed and said so on screen; there is nothing to close.
onError: (Object _) {},
),
);
super.dispose();
}
@override
Widget build(BuildContext context) => FutureBuilder(
future: _opening,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final data?) => _PersonDependenciesScope(
dependencies: data,
child: widget.child,
),
AsyncSnapshot(hasError: true) => const Scaffold(
body: Center(child: Text("Couldn't open this device's data.")),
),
_ => const Scaffold(body: Center(child: CircularProgressIndicator())),
},
);
}
class const _PersonDependenciesScope({
required final PersonDependencies dependencies,
required super.child,
}) extends InheritedWidget {
@override
bool updateShouldNotify(_PersonDependenciesScope oldWidget) =>
dependencies != oldWidget.dependencies;
}

View file

@ -0,0 +1,81 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/widgets.dart';
/// Syncs once and calls `onMerge` whenever changes from other devices arrive.
typedef SyncOnce = Future<void> Function({required VoidCallback onMerge});
/// Keeps one person's local database in sync, on triggers: once at start,
/// shortly after local changes, when the app resumes, and every few minutes.
///
/// 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.
class SyncService(
final SyncOnce _syncOnce, {
/// How long after a local change the sync waits for further changes.
final Duration debounce = const Duration(seconds: 2),
/// How often it syncs while nothing else triggers it.
final Duration interval = const Duration(minutes: 5),
}) {
final _merges = StreamController<void>.broadcast();
Timer? _debounceTimer;
Timer? _periodic;
AppLifecycleListener? _lifecycle;
Future<void>? _running;
bool _runAgain = false;
bool _stopping = false;
/// Fires whenever a sync brought changes from other devices.
Stream<void> get merges => _merges.stream;
/// Syncs now, then on the triggers.
void start() {
_periodic = Timer.periodic(interval, (_) => _run());
_lifecycle = AppLifecycleListener(onResume: _run);
_run();
}
/// Syncs shortly, once local changes have settled.
void requestSync() {
_debounceTimer?.cancel();
_debounceTimer = Timer(debounce, _run);
}
/// Stops syncing. A sync in progress finishes first, so the database can be
/// closed once this completes.
Future<void> dispose() async {
_stopping = true;
_debounceTimer?.cancel();
_periodic?.cancel();
_lifecycle?.dispose();
await _running;
await _merges.close();
}
// One sync at a time; a trigger during a sync runs one more afterwards.
void _run() {
if (_stopping) {
return;
}
if (_running != null) {
_runAgain = true;
return;
}
_running = _syncWhileTriggered().whenComplete(() => _running = null);
}
Future<void> _syncWhileTriggered() async {
do {
_runAgain = false;
try {
await _syncOnce(onMerge: () => _merges.add(null));
} on Object catch (error, st) {
log('Sync failed', name: 'SyncService', error: error, stackTrace: st);
}
} while (_runAgain && !_stopping);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter_bloc/flutter_bloc.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/feature/auth/bloc/session_bloc.dart';
import 'package:material_ui/material_ui.dart';
@ -18,7 +19,13 @@ class const SessionGate({
create: (context) =>
SessionBloc(DependenciesScope.of(context).auth)
..add(const SessionEventStarted()),
child: BlocBuilder<SessionBloc, SessionState>(
child: BlocConsumer<SessionBloc, SessionState>(
// Screens pushed for one person belong to their scope: when the person
// changes (or nobody is signed in), they close.
listenWhen: (previous, current) =>
_personOf(previous) != _personOf(current),
listener: (context, state) =>
Navigator.of(context).popUntil((route) => route.isFirst),
builder: (context, state) => switch (state) {
SessionStateLoading() => const Scaffold(
body: Center(child: CircularProgressIndicator()),
@ -26,13 +33,18 @@ class const SessionGate({
SessionStateFailed() => const _SignInFailed(),
SessionStateActive(:final person) => KeyedSubtree(
key: ValueKey(person.authUserId),
child: child,
child: PersonScope(person: person, child: child),
),
},
),
);
}
String? _personOf(SessionState state) => switch (state) {
SessionStateActive(:final person) => person.authUserId,
_ => null,
};
class const _SignInFailed() extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(

View file

@ -1,55 +0,0 @@
import 'dart:convert';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
import 'package:material_ui/material_ui.dart';
/// A placeholder until the first real content screen (step 7): edits a
/// document and shows it again after storing it as JSON and reading it back.
///
/// Keeps its state with setState instead of a bloc because it is temporary;
/// blocs arrive with the first real feature.
class const EditorPlaygroundScreen({super.key}) extends StatefulWidget {
@override
State<EditorPlaygroundScreen> createState() => _EditorPlaygroundScreenState();
}
class _EditorPlaygroundScreenState() extends State<EditorPlaygroundScreen> {
RichContent _stored = RichContent.empty();
void _store(RichContent content) {
final json = jsonEncode(content.toJson());
setState(() {
_stored = RichContent.fromJson(jsonDecode(json) as Map<String, Object?>);
});
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(
title: const Text('Halkyon Learn'),
actions: const [AccountButton()],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Editor', style: textTheme.titleMedium),
SizedBox(
height: 240,
child: RichContentEditor(
initialContent: RichContent.empty(),
onChanged: _store,
),
),
const Divider(height: 32),
Text('Stored as JSON and read back', style: textTheme.titleMedium),
RichContentView(content: _stored),
],
),
);
}
}

View file

@ -0,0 +1,102 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
/// What happens while a studyset is edited.
sealed class const StudysetEditEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The title was changed to [title].
final class const StudysetEditEventTitleChanged(
/// The new title.
final String title,
) extends StudysetEditEvent {
@override
List<Object?> get props => [title];
}
/// The description was changed to [description].
final class const StudysetEditEventDescriptionChanged(
/// The new description.
final RichContent description,
) extends StudysetEditEvent {
@override
List<Object?> get props => [description];
}
/// Store the changes.
final class const StudysetEditEventSaveRequested() extends StudysetEditEvent;
/// Delete the studyset; the person has confirmed it.
final class const StudysetEditEventDeleteRequested() extends StudysetEditEvent;
/// A studyset being edited.
final class const StudysetEditState({
/// The studyset with the changes made so far.
required final Studyset studyset,
/// The studyset as last stored.
required final Studyset saved,
/// Whether the studyset was deleted; the screen closes then.
final bool isDeleted = false,
}) with Equatable {
/// Whether there are changes that aren't stored yet.
bool get hasChanges => studyset != saved;
@override
List<Object?> get props => [studyset, saved, isDeleted];
}
/// Edits one studyset: keeps the changes until they are saved, and deletes it
/// on request.
class StudysetEditBloc(final StudysetRepository _studysets, Studyset studyset)
extends Bloc<StudysetEditEvent, StudysetEditState> {
this : super(StudysetEditState(studyset: studyset, saved: studyset)) {
on<StudysetEditEventTitleChanged>(
(event, emit) => emit(
StudysetEditState(
studyset: state.studyset.copyWith(title: event.title),
saved: state.saved,
),
),
);
on<StudysetEditEventDescriptionChanged>(
(event, emit) => emit(
StudysetEditState(
studyset: state.studyset.copyWith(description: event.description),
saved: state.saved,
),
),
);
on<StudysetEditEventSaveRequested>(_onSaveRequested);
on<StudysetEditEventDeleteRequested>(_onDeleteRequested);
}
Future<void> _onSaveRequested(
StudysetEditEventSaveRequested event,
Emitter<StudysetEditState> emit,
) async {
final studyset = state.studyset;
await _studysets.update(studyset);
emit(StudysetEditState(studyset: studyset, saved: studyset));
}
Future<void> _onDeleteRequested(
StudysetEditEventDeleteRequested event,
Emitter<StudysetEditState> emit,
) async {
await _studysets.delete(state.studyset.id);
emit(
StudysetEditState(
studyset: state.studyset,
saved: state.saved,
isDeleted: true,
),
);
}
}

View file

@ -0,0 +1,72 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
/// What happens to the list of studysets.
sealed class const StudysetListEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The list opens: follow the person's studysets.
final class const StudysetListEventStarted() extends StudysetListEvent;
/// Create a studyset with [title].
final class const StudysetListEventCreateRequested(
/// The new studyset's title.
final String title,
) extends StudysetListEvent {
@override
List<Object?> get props => [title];
}
/// The person's studysets, as the list shows them.
sealed class const StudysetListState() with Equatable;
/// Reading the studysets.
final class const StudysetListStateLoading() extends StudysetListState {
@override
List<Object?> get props => const [];
}
/// The studysets couldn't be read; the next change tries again.
final class const StudysetListStateFailed() extends StudysetListState {
@override
List<Object?> get props => const [];
}
/// The studysets, oldest first.
final class const StudysetListStateLoaded(
/// The studysets on this device.
final List<Studyset> studysets,
) extends StudysetListState {
@override
List<Object?> get props => [studysets];
}
/// Shows the person's studysets and creates new ones; the list follows every
/// change, local or synced.
class StudysetListBloc(final StudysetRepository _studysets)
extends Bloc<StudysetListEvent, StudysetListState> {
this : super(const StudysetListStateLoading()) {
on<StudysetListEventStarted>(_onStarted);
on<StudysetListEventCreateRequested>(_onCreateRequested);
}
Future<void> _onStarted(
StudysetListEventStarted event,
Emitter<StudysetListState> emit,
) => emit.forEach(
_studysets.watchAll(),
onData: StudysetListStateLoaded.new,
onError: (_, _) => const StudysetListStateFailed(),
);
Future<void> _onCreateRequested(
StudysetListEventCreateRequested event,
Emitter<StudysetListState> emit,
) async {
await _studysets.create(title: event.title);
}
}

View file

@ -0,0 +1,23 @@
import 'dart:typed_data';
/// A studyset's row as the device's database stores it.
typedef StudysetRow = ({String id, ByteData payload});
/// The boundary to the studysets in this person's local, synced database.
/// Tests replace it with a fake.
abstract interface class StudysetGateway() {
/// All studysets on this device, oldest first.
Future<List<StudysetRow>> all();
/// Stores a new studyset and returns its id, a UUID v7 made on the device.
Future<String> insert(ByteData payload);
/// Replaces a studyset's payload; the only column that changes.
Future<void> updatePayload(String id, ByteData payload);
/// Deletes a studyset's row.
Future<void> delete(String id);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,56 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
// A studyset's content as its synced row stores it: UTF-8 JSON with a schema
// version. A new content field raises the version here instead of changing
// the synced table, so older app versions keep syncing (ADR 0002, rule 3).
/// The schema this app writes.
const studysetSchemaVersion = 1;
/// The payload a deleted studyset keeps until its row is gone: the sync
/// engine keeps deleted rows until the whole space is deleted, so the content
/// is blanked first (ADR 0002, rule 2).
final redactedStudysetPayload = ByteData(0);
/// A studyset's content.
typedef StudysetContent = ({String title, RichContent description});
/// Encodes a studyset's content into its payload.
ByteData encodeStudysetPayload(StudysetContent content) {
final json = {
'schemaVersion': studysetSchemaVersion,
'title': content.title,
'description': content.description.toJson(),
};
return ByteData.sublistView(utf8.encode(jsonEncode(json)));
}
/// Decodes a studyset's payload.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
StudysetContent decodeStudysetPayload(ByteData payload) {
final Object? json;
try {
json = jsonDecode(utf8.decode(Uint8List.sublistView(payload)));
} on FormatException {
throw const FormatException('Not a studyset payload.');
}
if (json case {
'schemaVersion': final int version,
'title': final String title,
'description': final Map<String, Object?> description,
}) {
if (version > studysetSchemaVersion) {
throw FormatException(
'Studyset in schema $version, but this app only knows schemas up to '
'$studysetSchemaVersion.',
);
}
return (title: title, description: RichContent.fromJson(description));
}
throw const FormatException('Not a studyset payload.');
}

View file

@ -0,0 +1,105 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
/// The person's studysets: read from this device, changed offline, and synced
/// with their other devices in the background.
class StudysetRepository(final StudysetGateway _gateway) {
this {
_remoteChanges = _gateway.remoteChanges.listen(
(_) => unawaited(_refresh()),
);
}
final _studysets = StreamController<List<Studyset>>.broadcast();
late final StreamSubscription<void> _remoteChanges;
Future<void> _lastRefresh = Future.value();
/// All studysets, now and after every change, local or synced. A read that
/// fails arrives as an error; the next change reads again.
Stream<List<Studyset>> watchAll() => Stream.multi((listener) {
final subscription = _studysets.stream.listen(
listener.add,
onError: listener.addError,
);
listener.onCancel = subscription.cancel;
unawaited(_refresh());
});
/// Creates a studyset with an empty description; returns its id.
Future<String> create({required String title}) async {
final id = await _gateway.insert(
encodeStudysetPayload((title: title, description: RichContent.empty())),
);
await _refresh();
return id;
}
/// Stores the studyset's title and description.
Future<void> update(Studyset studyset) async {
await _gateway.updatePayload(
studyset.id,
encodeStudysetPayload((
title: studyset.title,
description: studyset.description,
)),
);
await _refresh();
}
/// Deletes a studyset. Its content is blanked first: the sync engine keeps
/// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(String id) async {
await _gateway.updatePayload(id, redactedStudysetPayload);
await _gateway.delete(id);
await _refresh();
}
/// Stops following remote changes.
Future<void> dispose() async {
await _remoteChanges.cancel();
await _studysets.close();
}
// Refreshes one after another, so an older list never overtakes a newer one.
// A failed read goes to the listeners and never ends the chain: the next
// change refreshes again.
Future<void> _refresh() => _lastRefresh = _lastRefresh.then((_) async {
if (_studysets.isClosed) {
return;
}
try {
final studysets = [
for (final row in await _gateway.all()) ?_decode(row),
];
if (!_studysets.isClosed) {
_studysets.add(studysets);
}
} on Object catch (error, st) {
log('Reading studysets failed', name: 'Studysets', error: error);
if (!_studysets.isClosed) {
_studysets.addError(error, st);
}
}
});
/// The studyset in [row], or null for a row this app can't read: one
/// written by a newer app version, or one being deleted.
Studyset? _decode(StudysetRow row) {
try {
final content = decodeStudysetPayload(row.payload);
return Studyset(
id: row.id,
title: content.title,
description: content.description,
);
} on FormatException catch (error) {
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
return null;
}
}
}

View file

@ -0,0 +1,65 @@
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [StudysetGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncStudysetGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements StudysetGateway {
@override
Future<List<StudysetRow>> all() async => [
// UUID v7 ids are time-ordered: this is the order of creation (ADR 0001,
// rule 7).
for (final studyset in await protocol.Studyset.db.find(
_db,
orderBy: (t) => t.id,
))
(id: studyset.id!.uuid, payload: studyset.payload),
];
@override
Future<String> insert(ByteData payload) async {
final studyset = await protocol.Studyset.db.insertRow(
_db,
protocol.Studyset(payload: payload),
);
_sync.requestSync();
return studyset.id!.uuid;
}
@override
Future<void> updatePayload(String id, ByteData payload) async {
final studyset = await protocol.Studyset.db.findById(
_db,
protocol.UuidValue.withValidation(id),
);
if (studyset == null) {
return;
}
// Only the named column changes, so a concurrent edit of another column
// on another device survives the merge (ADR 0001, rule 1).
await protocol.Studyset.db.updateRow(
_db,
studyset.copyWith(payload: payload),
columns: (t) => [t.payload],
);
_sync.requestSync();
}
@override
Future<void> delete(String id) async {
await protocol.Studyset.db.deleteWhere(
_db,
where: (t) => t.id.equals(protocol.UuidValue.withValidation(id)),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// A studyset: the cards, notes and paths that belong together.
final class const Studyset({
/// A UUID v7, made on the device that created it.
required final String id,
/// The name people see in lists.
required final String title,
/// What the studyset is about, as rich text.
required final RichContent description,
}) with Equatable {
@override
List<Object?> get props => [id, title, description];
/// This studyset with some fields replaced.
Studyset copyWith({String? title, RichContent? description}) => Studyset(
id: id,
title: title ?? this.title,
description: description ?? this.description,
);
}

View file

@ -0,0 +1,110 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:material_ui/material_ui.dart';
/// The person's studysets; the app's home screen.
class const StudysetListScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
StudysetListBloc(PersonScope.of(context).studysets)
..add(const StudysetListEventStarted()),
child: const _StudysetListView(),
);
}
class const _StudysetListView() extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Studysets'),
actions: const [AccountButton()],
),
body: BlocBuilder<StudysetListBloc, StudysetListState>(
builder: (context, state) => switch (state) {
StudysetListStateLoading() => const Center(
child: CircularProgressIndicator(),
),
StudysetListStateFailed() => const Center(
child: Text("Couldn't read your studysets."),
),
StudysetListStateLoaded(studysets: []) => const Center(
child: Text('No studysets yet.'),
),
StudysetListStateLoaded(:final studysets) => ListView(
children: [
for (final studyset in studysets)
ListTile(
title: Text(studyset.title),
onTap: () => Navigator.of(
context,
).push(StudysetScreen.route(context, studyset)),
),
],
),
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('New studyset'),
onPressed: () async {
final bloc = context.read<StudysetListBloc>();
final title = await _askForTitle(context);
if (title != null) {
bloc.add(StudysetListEventCreateRequested(title));
}
},
),
);
}
/// Asks for a new studyset's title; null when cancelled or left empty.
Future<String?> _askForTitle(BuildContext context) async {
final title = await showDialog<String>(
context: context,
builder: (context) => const _NewStudysetDialog(),
);
final trimmed = title?.trim();
return (trimmed == null || trimmed.isEmpty) ? null : trimmed;
}
class const _NewStudysetDialog() extends StatefulWidget {
@override
State<_NewStudysetDialog> createState() => _NewStudysetDialogState();
}
// Owns the field's controller: `showDialog` returns before the dialog's closing
// animation ends, and the field still uses the controller until then.
class _NewStudysetDialogState() extends State<_NewStudysetDialog> {
final _title = TextEditingController();
@override
void dispose() {
_title.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('New studyset'),
content: TextField(
controller: _title,
autofocus: true,
decoration: const InputDecoration(labelText: 'Title'),
onSubmitted: (title) => Navigator.of(context).pop(title),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_title.text),
child: const Text('Create'),
),
],
);
}

View file

@ -0,0 +1,110 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:material_ui/material_ui.dart';
/// Shows and edits one studyset: its title and description, saved on request;
/// deleting it asks first.
class const StudysetScreen({
/// The studyset as the screen opens; the bloc holds later changes.
required final Studyset studyset,
super.key,
}) extends StatelessWidget {
/// The route to [studyset]'s screen. Routes live above the person's scope,
/// so the person's repository is handed over here.
static Route<void> route(BuildContext context, Studyset studyset) {
final studysets = PersonScope.of(context).studysets;
return MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => StudysetEditBloc(studysets, studyset),
child: StudysetScreen(studyset: studyset),
),
);
}
@override
Widget build(BuildContext context) =>
BlocListener<StudysetEditBloc, StudysetEditState>(
listenWhen: (previous, current) => current.isDeleted,
listener: (context, state) => Navigator.of(context).pop(),
child: Scaffold(
appBar: AppBar(
title: const Text('Studyset'),
actions: [
BlocSelector<StudysetEditBloc, StudysetEditState, bool>(
selector: (state) => state.hasChanges,
builder: (context, hasChanges) => IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: 'Save',
onPressed: hasChanges
? () => context.read<StudysetEditBloc>().add(
const StudysetEditEventSaveRequested(),
)
: null,
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete',
onPressed: () async {
final bloc = context.read<StudysetEditBloc>();
if (await _confirmDelete(context)) {
bloc.add(const StudysetEditEventDeleteRequested());
}
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
initialValue: studyset.title,
decoration: const InputDecoration(labelText: 'Title'),
onChanged: (title) => context.read<StudysetEditBloc>().add(
StudysetEditEventTitleChanged(title),
),
),
const SizedBox(height: 24),
Text(
'Description',
style: Theme.of(context).textTheme.titleSmall,
),
SizedBox(
height: 320,
child: RichContentEditor(
initialContent: studyset.description,
onChanged: (description) => context
.read<StudysetEditBloc>()
.add(StudysetEditEventDescriptionChanged(description)),
),
),
],
),
),
);
}
/// Asks before deleting a studyset.
Future<bool> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete this studyset?'),
content: const Text('It is deleted on all your devices.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
return confirmed ?? false;
}

View file

@ -0,0 +1,154 @@
import 'dart:async';
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/sync_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late int syncs;
late Exception? failWith;
late VoidCallback? merge;
// 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;
}
});
setUp(() {
syncs = 0;
failWith = null;
merge = null;
hold = null;
});
test('syncs once at start', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
expect(syncs, 1);
sync.dispose();
});
});
test('syncs once after a burst of local changes has settled', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync
..requestSync()
..requestSync();
async.elapse(const Duration(seconds: 1));
sync.requestSync();
async.elapse(const Duration(seconds: 1));
expect(syncs, 1, reason: 'still within the debounce');
async.elapse(const Duration(seconds: 1));
expect(syncs, 2);
sync.dispose();
});
});
test('syncs every five minutes without other triggers', () {
fakeAsync((async) {
final sync = service()..start();
async.elapse(const Duration(minutes: 10));
expect(syncs, 3);
sync.dispose();
});
});
test('keeps syncing after a failed sync', () {
fakeAsync((async) {
failWith = Exception('No connection');
final sync = service()..start();
async.flushMicrotasks();
failWith = null;
sync.requestSync();
async.elapse(const Duration(seconds: 2));
expect(syncs, 2);
sync.dispose();
});
});
test('reports changes from other devices', () {
fakeAsync((async) {
final sync = service();
var merges = 0;
sync.merges.listen((_) => merges++);
sync.start();
async.flushMicrotasks();
merge!();
async.flushMicrotasks();
expect(merges, 1);
sync.dispose();
});
});
test('a trigger during a sync runs one more sync afterwards', () {
fakeAsync((async) {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
async.elapse(const Duration(seconds: 2));
sync.requestSync();
async.elapse(const Duration(seconds: 2));
expect(syncs, 1, reason: 'the first sync is still running');
running.complete();
async.flushMicrotasks();
expect(syncs, 2);
sync.dispose();
});
});
test('lets a running sync finish before it stops', () {
fakeAsync((async) {
final running = hold = Completer<void>();
final sync = service()..start();
async.flushMicrotasks();
sync.requestSync();
async.elapse(const Duration(seconds: 2));
var stopped = false;
unawaited(sync.dispose().then((_) => stopped = true));
async.flushMicrotasks();
expect(stopped, isFalse, reason: 'the database is still in use');
running.complete();
async.flushMicrotasks();
expect(stopped, isTrue);
expect(syncs, 1, reason: 'no sync starts once it is stopping');
});
});
test('stops syncing when disposed', () {
fakeAsync((async) {
final sync = service()..start();
async.flushMicrotasks();
sync
..requestSync()
..dispose();
async.elapse(const Duration(minutes: 10));
expect(syncs, 1);
});
});
}

View file

@ -25,7 +25,8 @@ void main() {
),
),
);
await tester.pump();
// Signs in and opens the person's data.
await tester.pumpAndSettle();
}
setUp(() => gateway = FakeAuthGateway());
@ -44,7 +45,7 @@ void main() {
gateway.signInError = null;
await tester.tap(find.text('Try again'));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Home'), findsOneWidget);
});

View file

@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import '../../../support/fake_studyset_gateway.dart';
void main() {
late FakeStudysetGateway gateway;
late StudysetRepository repository;
setUp(() {
gateway = FakeStudysetGateway();
repository = StudysetRepository(gateway);
});
tearDown(() => repository.dispose());
group('StudysetListBloc', () {
test('lists the studysets and follows newly created ones', () async {
final bloc = StudysetListBloc(repository)
..add(const StudysetListEventStarted());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(const StudysetListStateLoaded([])),
);
bloc.add(const StudysetListEventCreateRequested('Japanese'));
await expectLater(
bloc.stream,
emits(
isA<StudysetListStateLoaded>().having(
(state) => state.studysets.map((studyset) => studyset.title),
'titles',
['Japanese'],
),
),
);
});
test('says so when the studysets cannot be read, then recovers', () async {
gateway.failReads = Exception('Disk unavailable');
final bloc = StudysetListBloc(repository)
..add(const StudysetListEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(const StudysetListStateFailed()));
gateway.failReads = null;
bloc.add(const StudysetListEventCreateRequested('Japanese'));
await expectLater(
bloc.stream,
emits(isA<StudysetListStateLoaded>()),
);
});
});
group('StudysetEditBloc', () {
late Studyset studyset;
late StudysetEditBloc bloc;
setUp(() async {
final id = await repository.create(title: 'Japanese');
studyset = Studyset(
id: id,
title: 'Japanese',
description: RichContent.empty(),
);
bloc = StudysetEditBloc(repository, studyset);
});
tearDown(() => bloc.close());
test('keeps changes unsaved until asked to save', () async {
bloc.add(const StudysetEditEventTitleChanged('日本語'));
await expectLater(
bloc.stream,
emits(
isA<StudysetEditState>().having(
(s) => s.hasChanges,
'has changes',
true,
),
),
);
expect((await repository.watchAll().first).single.title, 'Japanese');
bloc.add(const StudysetEditEventSaveRequested());
await expectLater(
bloc.stream,
emits(
isA<StudysetEditState>().having(
(s) => s.hasChanges,
'has changes',
false,
),
),
);
expect((await repository.watchAll().first).single.title, '日本語');
});
test('deletes the studyset and says so', () async {
bloc.add(const StudysetEditEventDeleteRequested());
await expectLater(
bloc.stream,
emits(
isA<StudysetEditState>().having((s) => s.isDeleted, 'deleted', true),
),
);
expect(await repository.watchAll().first, isEmpty);
});
});
}

Some files were not shown because too many files have changed in this diff Show more