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
4 changed files with 1594 additions and 82 deletions

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

View file

@ -0,0 +1,68 @@
import 'dart:convert';
import 'dart:typed_data';
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/data/studyset_payload.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
final description = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'ひらがな'},
],
},
},
],
},
});
group('The studyset payload', () {
test('survives a round trip, Japanese included', () {
final content = (title: '日本語', description: description);
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
});
test('names its schema version', () {
final payload = encodeStudysetPayload((
title: 'Japanese',
description: RichContent.empty(),
));
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
expect(json['schemaVersion'], studysetSchemaVersion);
});
test('refuses a schema newer than this app knows', () {
final payload = bytes({
'schemaVersion': studysetSchemaVersion + 1,
'title': 'From the future',
'description': RichContent.empty().toJson(),
});
expect(() => decodeStudysetPayload(payload), throwsFormatException);
});
test('refuses what is not a studyset payload', () {
expect(
() => decodeStudysetPayload(redactedStudysetPayload),
throwsFormatException,
);
expect(
() => decodeStudysetPayload(bytes({'title': 'no version'})),
throwsFormatException,
);
});
});
}

View file

@ -0,0 +1,132 @@
import 'dart:convert';
import 'dart:typed_data';
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/data/studyset_payload.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());
Iterable<String> titles(List<Studyset> studysets) =>
studysets.map((studyset) => studyset.title);
test('lists what is stored, then every change', () async {
await repository.create(title: 'Japanese');
final lists = repository.watchAll();
final expectation = expectLater(
lists.map(titles),
emitsInOrder([
['Japanese'],
['Japanese', 'Kanji'],
]),
);
await Future<void>.delayed(Duration.zero);
await repository.create(title: 'Kanji');
await expectation;
});
test('creates studysets with an empty description', () async {
final id = await repository.create(title: 'Japanese');
final studysets = await repository.watchAll().first;
expect(studysets, [
Studyset(id: id, title: 'Japanese', description: RichContent.empty()),
]);
});
test('stores changes to title and description', () async {
final id = await repository.create(title: 'Japanese');
final changed = Studyset(
id: id,
title: '日本語',
description: RichContent({
'document': {'type': 'page'},
'note': 'changed',
}),
);
await repository.update(changed);
expect(await repository.watchAll().first, [changed]);
});
test('blanks the content before deleting', () async {
final id = await repository.create(title: 'Secret');
await repository.delete(id);
expect(gateway.writes.last, (id, redactedStudysetPayload));
expect(gateway.deleted, [id]);
expect(await repository.watchAll().first, isEmpty);
});
test('shows studysets that arrive from other devices', () async {
final lists = repository.watchAll();
final expectation = expectLater(
lists.map(titles),
emitsInOrder([
<String>[],
['From the laptop'],
]),
);
await Future<void>.delayed(Duration.zero);
gateway.receiveRemoteChange(
'laptop-1',
encodeStudysetPayload((
title: 'From the laptop',
description: RichContent.empty(),
)),
);
await expectation;
});
test('reports a failed read and recovers with the next change', () async {
await repository.create(title: 'Japanese');
gateway.failReads = Exception('Disk unavailable');
final lists = repository.watchAll();
final expectation = expectLater(
lists.map(titles),
emitsInOrder([
emitsError(isException),
['Japanese', 'Kanji'],
]),
);
await Future<void>.delayed(Duration.zero);
gateway.failReads = null;
await repository.create(title: 'Kanji');
await expectation;
});
test('skips studysets this app version cannot read', () async {
await repository.create(title: 'Readable');
gateway.receiveRemoteChange(
'future-1',
ByteData.sublistView(
utf8.encode(
jsonEncode({
'schemaVersion': studysetSchemaVersion + 1,
'title': 'From a newer app',
'description': RichContent.empty().toJson(),
}),
),
),
);
expect(titles(await repository.watchAll().first), ['Readable']);
});
}

View file

@ -0,0 +1,117 @@
import 'package:flutter_test/flutter_test.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/studyset/widget/studyset_list_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway gateway;
Future<void> showApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
DependenciesScope(
dependencies: testDependencies(studysetGateway: gateway),
child: const MaterialApp(
home: SessionGate(child: StudysetListScreen()),
),
),
);
// Signs in, opens the person's data and lists the studysets.
await tester.pumpAndSettle();
}
Future<void> create(WidgetTester tester, String title) async {
await tester.tap(find.text('New studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), title);
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
}
Future<void> open(WidgetTester tester, String title) async {
await tester.tap(find.text(title));
await tester.pump();
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
}
setUp(() => gateway = FakeStudysetGateway());
testWidgets('starts with an empty list', (tester) async {
await showApp(tester);
expect(find.text('No studysets yet.'), findsOneWidget);
});
testWidgets('says so when the studysets cannot be read', (tester) async {
gateway.failReads = Exception('Disk unavailable');
await showApp(tester);
expect(find.text("Couldn't read your studysets."), findsOneWidget);
});
testWidgets('creates a studyset', (tester) async {
await showApp(tester);
await create(tester, 'Japanese');
expect(find.text('Japanese'), findsOneWidget);
expect(gateway.writes, hasLength(1));
});
testWidgets('saves a changed title', (tester) async {
await showApp(tester);
await create(tester, 'Japanese');
await open(tester, 'Japanese');
await tester.enterText(
find.widgetWithText(TextFormField, 'Japanese'),
'日本語',
);
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pump();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('日本語'), findsOneWidget);
});
testWidgets('deleting asks first; Cancel keeps the studyset', (tester) async {
await showApp(tester);
await create(tester, 'Japanese');
await open(tester, 'Japanese');
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete this studyset?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(gateway.deleted, isEmpty);
});
testWidgets('a confirmed delete removes the studyset and closes it', (
tester,
) async {
await showApp(tester);
await create(tester, 'Japanese');
await open(tester, 'Japanese');
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(gateway.deleted, hasLength(1));
expect(find.text('No studysets yet.'), findsOneWidget);
});
}