chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
40 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'dart:io';
/// The header of an export of JMdict, as the instance writes it.
Map<String, Object?> exportHeader({Object format = 1, String kind = 'words'}) =>
{
'format': format,
'source': 'jmdict',
'kind': kind,
'language': 'ja',
'glossLanguage': 'en',
'sourceVersion': '3.6.2 (2026-10-05)',
'license': 'CC-BY-SA-4.0',
'credits': [
{
'name': 'JMdict',
'by': 'EDRDG',
'license': 'CC-BY-SA-4.0',
'url': 'https://www.edrdg.org/edrdg/licence.html',
},
],
'entryCount': 0,
};
/// An entry of an export, as the instance writes it.
Map<String, Object?> exportEntry(
String id,
String headword, {
String? reading,
int rank = 100000,
List<String> labels = const [],
List<String> keys = const [],
List<String> glosses = const ['a gloss'],
List<String> pos = const ['n'],
String version = '0123456789abcdef',
}) => {
'id': id,
'headword': headword,
'reading': ?reading,
'rank': rank,
'labels': labels,
'entry': jsonEncode({
'senses': [
{'glosses': glosses, 'pos': pos},
],
}),
'version': version,
'keys': keys.isEmpty ? [headword, ?reading] : keys,
};
/// The export of [entries] after [header], as the instance serves it:
/// gzipped JSON lines.
List<int> exportBytes(
List<Map<String, Object?>> entries, {
Map<String, Object?>? header,
}) => gzip.encode(
utf8.encode(
[
jsonEncode(header ?? exportHeader()),
for (final entry in entries) jsonEncode(entry),
].join('\n'),
),
);
/// Writes the export of [entries] into [file].
File writeExport(
File file,
List<Map<String, Object?>> entries, {
Map<String, Object?>? header,
}) => file..writeAsBytesSync(exportBytes(entries, header: header));

View file

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
/// Waits until [condition] holds, checking every few milliseconds, and fails
/// after [timeout]: for what follows a real timer, without betting on how
/// fast a busy machine runs.
Future<void> eventually(
bool Function() condition, {
Duration timeout = const Duration(seconds: 10),
}) async {
final stopwatch = Stopwatch()..start();
while (!condition()) {
if (stopwatch.elapsed > timeout) {
fail('Still not so after $timeout.');
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}

View file

@ -0,0 +1,19 @@
import 'package:halkyon_learn_flutter/src/feature/about/data/about_gateway.dart';
/// An [AboutGateway] without a server, naming [sourceCodeUrl]; [unreachable]
/// makes it fail as when the instance can't be reached.
class FakeAboutGateway({
/// Where the instance says its source is.
final String sourceCodeUrl = 'https://example.org/halkyon-learn',
}) implements AboutGateway {
/// Thrown by the next calls while set.
Exception? unreachable;
@override
Future<String> sourceCode() async {
if (unreachable case final error?) {
throw error;
}
return sourceCodeUrl;
}
}

View file

@ -0,0 +1,52 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
/// An [AssetGateway] in memory: ids are `asset-001`, `asset-002`, … in the
/// order of creation.
class FakeAssetGateway() implements AssetGateway {
final _rows = <String, AssetRow>{};
var _next = 1;
/// The rows, in the order they were stored.
List<AssetRow> get rows => [..._rows.values];
/// The rows deleted, in order.
final deleted = <String>[];
/// Stores [row] as if another device had written it and a sync had
/// brought it here.
void receiveRemote(AssetRow row) => _rows[row.id] = row;
@override
Future<AssetRow?> byId(String id) async => _rows[id];
@override
Future<AssetRow?> bySha256(String sha256) async =>
_rows.values.where((row) => row.sha256 == sha256).firstOrNull;
@override
Future<String> insert({
required String sha256,
required String mimeType,
required int byteSize,
required ByteData payload,
}) async {
final id = 'asset-${'${_next++}'.padLeft(3, '0')}';
_rows[id] = (
id: id,
sha256: sha256,
mimeType: mimeType,
byteSize: byteSize,
payload: payload,
);
return id;
}
@override
Future<void> delete(String id) async {
if (_rows.remove(id) != null) {
deleted.add(id);
}
}
}

View file

@ -0,0 +1,96 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
/// An [AssetServerGateway] in memory, holding files by hash as the server
/// does, and asset rows' files by id for downloads.
class FakeAssetServerGateway() implements AssetServerGateway {
/// The files the server keeps, by hash.
final files = <String, Uint8List>{};
/// The hash of each asset row the server knows, by id: what a download
/// serves.
final rows = <String, String>{};
/// What was uploaded and not finished yet, by hash.
final _uploaded = <String, Uint8List>{};
/// The hashes uploads were started for, in order.
final started = <String>[];
/// The ids downloads were asked for, in order.
final downloads = <String>[];
/// When set, every call fails with it, as without a connection.
Exception? failCalls;
/// When true, uploads don't arrive.
bool failUploads = false;
/// When set, uploads arrive with this content instead of what was sent.
Uint8List? corruptUploads;
/// What the person may keep, in bytes; past it, uploads are refused.
int limitBytes = 1 << 30;
int get _usedBytes =>
files.values.fold(0, (sum, bytes) => sum + bytes.length);
@override
Future<String?> startUpload(String sha256, int byteSize) async {
_check();
started.add(sha256);
if (files.containsKey(sha256)) {
return null;
}
if (_usedBytes + byteSize > limitBytes) {
throw FileLimitReached(instanceFull: false, limitBytes: limitBytes);
}
return 'upload:$sha256';
}
@override
Future<bool> upload(String description, Uint8List bytes) async {
_check();
if (failUploads) {
return false;
}
_uploaded[description.substring('upload:'.length)] =
corruptUploads ?? bytes;
return true;
}
@override
Future<bool> finishUpload(String sha256) async {
_check();
final bytes = _uploaded.remove(sha256);
if (bytes == null || sha256Of(bytes) != sha256) {
return false;
}
files[sha256] = bytes;
return true;
}
@override
Future<FileRoom> room() async {
_check();
return (usedBytes: _usedBytes, limitBytes: limitBytes);
}
@override
Future<Uint8List?> download(String id) async {
_check();
downloads.add(id);
return files[rows[id]];
}
void _check() {
if (failCalls case final error?) {
throw error;
}
}
}
/// The SHA-256 of [bytes] in lowercase hex.
String sha256Of(List<int> bytes) => sha256.convert(bytes).toString();

View file

@ -0,0 +1,53 @@
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
/// An instance offering the connector at [url] (none when null), with
/// [initially] connected; [unreachable] makes every call fail as offline.
class FakeAssistantGateway({
/// Where the connector is; none when null.
final String? url = 'https://learn.example.org/mcp',
/// The assistants connected at first.
final List<ConnectedAssistant> initially = const [],
}) implements AssistantGateway {
/// Whether the instance can't be reached, failing every call while set.
bool unreachable = false;
/// The assistants connected.
late final List<ConnectedAssistant> connected = [...initially];
/// The codes given out, the last one valid.
final codes = <String>[];
void _reach() {
if (unreachable) {
throw Exception('offline');
}
}
@override
Future<String?> connectorUrl() async {
_reach();
return url;
}
@override
Future<ConnectCode> connectCode() async {
_reach();
final code = 'K7QX-M2P${codes.length}';
codes.add(code);
return (code: code, expiresAt: DateTime.utc(2026, 10, 10, 12, 10));
}
@override
Future<List<ConnectedAssistant>> connections() async {
_reach();
return [...connected];
}
@override
Future<void> disconnect(int id) async {
_reach();
connected.removeWhere((assistant) => assistant.id == id);
}
}

View file

@ -0,0 +1,186 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
/// A [CardGateway] in memory. Ids sort by creation, as UUID v7 ids do: cards
/// are `card-0001`, `card-0002`, …, revisions `rev-0001`, …;
/// [receiveRemoteRevision] plays a sync that brought another device's edit.
class FakeCardGateway() implements CardGateway {
final _cards =
<
String,
({
String studysetId,
String position,
DateTime? buriedAt,
DateTime createdAt,
})
>{};
final _revisions = <String, CardRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every revision written on this device, in order.
final written = <CardRevisionRow>[];
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
final deletions = <String>[];
/// When set, storing a card or a revision fails with it.
Exception? failWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// Stores a revision as if another device had written it and a sync had
/// brought it here.
void receiveRemoteRevision({
required String id,
required String cardId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
cardId: cardId,
parents: parents,
payload: payload,
);
_remoteChanges.add(null);
}
@override
Future<List<CardRow>> cardsOf(String studysetId) async => [
for (final MapEntry(key: id, value: card) in _cards.entries)
if (card.studysetId == studysetId)
(id: id, position: card.position, buriedAt: card.buriedAt),
];
@override
Future<List<CardStampRow>> allCards() async => [
for (final MapEntry(key: id, value: card) in _cards.entries)
(id: id, studysetId: card.studysetId, createdAt: card.createdAt),
];
@override
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async => [
for (final revision in _revisions.values)
if (cardIds.contains(revision.cardId)) revision,
];
@override
Future<List<CardRevisionRow>> allRevisions() async => [..._revisions.values];
@override
Future<List<String>> insertCards(
String studysetId,
List<NewCardRow> cards,
) async => [
for (final card in cards)
await insertCard(
studysetId: studysetId,
position: card.position,
payload: card.payload,
),
];
@override
Future<String> insertCard({
required String studysetId,
required String position,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final id = _newId('card');
_cards[id] = (
studysetId: studysetId,
position: position,
buriedAt: null,
createdAt: clock.now().toUtc(),
);
await insertRevision(cardId: id, parents: const {}, payload: payload);
return id;
}
@override
Future<String> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final revision = (
id: _newId('rev'),
cardId: cardId,
parents: parents,
payload: payload,
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override
Future<void> updateStudyset(
String cardId,
String studysetId,
String position,
) async {
if (_cards[cardId] case final card?) {
_cards[cardId] = (
studysetId: studysetId,
position: position,
buriedAt: card.buriedAt,
createdAt: card.createdAt,
);
}
}
@override
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
if (_cards[cardId] case final card?) {
_cards[cardId] = (
studysetId: card.studysetId,
position: card.position,
buriedAt: buriedAt?.toUtc(),
createdAt: card.createdAt,
);
}
}
@override
Future<void> blankRevisions(String cardId) async {
deletions.add('blank $cardId');
for (final revision in [..._revisions.values]) {
if (revision.cardId == cardId) {
_revisions[revision.id] = (
id: revision.id,
cardId: cardId,
parents: revision.parents,
payload: redactedPayload,
);
}
}
}
@override
Future<void> deleteCard(String cardId) async {
deletions.add('delete $cardId');
_cards.remove(cardId);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}

View file

@ -0,0 +1,137 @@
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionaries.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/japanese_analyser.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
/// An instance offering [offered] for devices, each file's bytes by its
/// address in [bytes]; offering none by default.
class FakeDeviceDictionaryGateway({
var List<DeviceFile> offered = const [],
final Map<Uri, List<int>> bytes = const {},
}) implements DeviceDictionaryGateway {
/// The addresses downloaded, in order.
final downloaded = <Uri>[];
/// How many times the files were asked for.
int asked = 0;
/// Thrown by [files] while set: the instance can't be reached.
Exception? fail;
@override
Future<List<DeviceFile>> files(String language) async {
asked++;
if (fail case final error?) {
throw error;
}
return language == 'ja' ? offered : const [];
}
@override
Stream<List<int>> download(Uri url) async* {
downloaded.add(url);
final all = bytes[url] ?? (throw HttpException('Not found', uri: url));
// In chunks, as a download comes.
for (var at = 0; at < all.length; at += 4096) {
yield all.sublist(at, at + 4096 > all.length ? all.length : at + 4096);
}
}
}
/// Cuts each sentence into the parts [cut] gives, and looks a stretch up
/// as [stretch] gives it; records what it was asked.
class FakeJapaneseAnalyser({
final List<SentencePart> Function(String sentence)? cut,
final SentencePart Function(String text, int start)? stretch,
}) implements JapaneseAnalyser {
/// The sentences asked for, each call's.
final asked = <List<String>>[];
/// The folders and databases asked to read.
final read = <String>[];
@override
Future<List<List<SentencePart>>> wordsOf({
required String ipadic,
required String database,
required List<String> sentences,
}) async {
asked.add(sentences);
read
..add(ipadic)
..add(database);
return [
for (final sentence in sentences)
cut?.call(sentence) ?? [SentencePart(text: sentence, start: 0)],
];
}
@override
Future<SentencePart> wordOfStretch({
required String database,
required String text,
required int start,
}) async {
read.add(database);
return stretch?.call(text, start) ?? SentencePart(text: text, start: start);
}
}
/// Dictionaries on a device that holds none, nor is offered any: lines are
/// cut through the instance.
DeviceDictionaries noDeviceDictionaries() => DeviceDictionaries(
FakeDeviceDictionaryGateway(),
FakeJapaneseAnalyser(),
folder: Directory('${Directory.systemTemp.path}/halkyon-no-dictionaries'),
);
/// The files an instance offers devices for Japanese, IPADIC and JMdict, as
/// large as learn.halkyon.eu's would be: 34.0 MB together.
List<DeviceFile> offeredJapanese() => [
DeviceFile(
name: 'mecab-ipadic',
url: Uri.parse('https://learn.example.org/device-dictionaries/ipadic'),
sha256: '11' * 32,
size: 13953908,
),
DeviceFile(
name: 'jmdict-en',
url: Uri.parse('https://learn.example.org/device-dictionaries/jmdict'),
sha256: '22' * 32,
size: 21749430,
),
];
/// What a device holds once it set them up: 150.0 MB, checked just now.
DeviceInstallation installedJapanese() => DeviceInstallation(
ipadic: 'ipadic-1111111111111111',
database: 'jmdict-en-2222222222222222.sqlite',
source: 'jmdict',
glossLanguage: 'en',
files: {'mecab-ipadic': '11' * 32, 'jmdict-en': '22' * 32},
bytes: 150 * 1024 * 1024,
installedAt: clock.now().toUtc(),
checkedAt: clock.now().toUtc(),
);
/// Dictionaries on a device, kept in a folder of their own that goes after
/// the test, offered by [gateway] and cut with [analyser].
DeviceDictionaries deviceDictionariesIn(
FakeDeviceDictionaryGateway gateway, {
FakeJapaneseAnalyser? analyser,
DeviceDictionaryStatus initial = const DeviceDictionaryStatus(),
}) {
final folder = Directory.systemTemp.createTempSync('dictionaries');
addTearDown(() => folder.deleteSync(recursive: true));
return DeviceDictionaries(
gateway,
analyser ?? FakeJapaneseAnalyser(),
folder: folder,
initial: initial,
);
}

View file

@ -0,0 +1,356 @@
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
/// JMdict in English, as an instance that loaded it lists it.
const jmdict = Dictionary(
source: 'jmdict',
sourceVersion: '3.6.2 (2026-10-05)',
kind: DictionaryKind.words,
language: 'ja',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [
(
name: 'JMdict',
by: 'Electronic Dictionary Research and Development Group (EDRDG)',
license: 'CC-BY-SA-4.0',
url: 'https://www.edrdg.org/edrdg/licence.html',
),
],
entryCount: 3,
);
/// KANJIDIC2 in English, with the kana, as an instance that loaded it lists
/// it.
const kanjidic = Dictionary(
source: 'kanjidic',
sourceVersion: '2026-282',
kind: DictionaryKind.characters,
language: 'ja',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [
(
name: 'KANJIDIC2',
by: 'Electronic Dictionary Research and Development Group (EDRDG)',
license: 'CC-BY-SA-4.0',
url: 'https://www.edrdg.org/edrdg/licence.html',
),
],
entryCount: 2,
);
/// German words with English meanings from Wiktionary, as an instance that
/// loaded them lists them.
const wiktionaryDe = Dictionary(
source: 'wiktionary-de',
sourceVersion: '2026-10-03',
kind: DictionaryKind.words,
language: 'de',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [
(
name: 'Wiktionary',
by: 'Wiktionary contributors',
license: 'CC-BY-SA-4.0',
url: 'https://en.wiktionary.org/wiki/Wiktionary:Copyrights',
),
],
entryCount: 4,
);
/// Chinese words of CC-CEDICT in simplified characters.
const ccCedictHans = Dictionary(
source: 'cc-cedict-hans',
sourceVersion: '2026-10-14',
kind: DictionaryKind.words,
language: 'zh-Hans',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [
(
name: 'CC-CEDICT',
by: 'MDBG',
license: 'CC-BY-SA-4.0',
url: 'https://www.mdbg.net/chinese/dictionary?page=cc-cedict',
),
],
entryCount: 0,
);
/// Chinese characters of Unihan in simplified writing.
const unihanHans = Dictionary(
source: 'unihan-hans',
sourceVersion: '18.0.0',
kind: DictionaryKind.characters,
language: 'zh-Hans',
glossLanguage: 'en',
license: 'Unicode-3.0',
credits: [
(
name: 'Unihan Database',
by: 'Unicode, Inc.',
license: 'Unicode-3.0',
url: 'https://www.unicode.org/reports/tr38/',
),
],
entryCount: 1,
);
/// Greek words with English meanings from Wiktionary.
const wiktionaryEl = Dictionary(
source: 'wiktionary-el',
sourceVersion: '2026-10-03',
kind: DictionaryKind.words,
language: 'el',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [
(
name: 'Wiktionary',
by: 'Wiktionary contributors',
license: 'CC-BY-SA-4.0',
url: 'https://en.wiktionary.org/wiki/Wiktionary:Copyrights',
),
],
entryCount: 0,
);
/// The Greek alphabet the curator writes.
const greekAlphabet = Dictionary(
source: 'greek-alphabet',
sourceVersion: '1',
kind: DictionaryKind.characters,
language: 'el',
glossLanguage: 'en',
license: 'CC0-1.0',
credits: [
(
name: 'Alphabets (Hangul, Greek)',
by: 'Halkyon Learn contributors',
license: 'CC0-1.0',
url: 'https://code.halkyon.app/project-halkyon/halkyon_learn',
),
],
entryCount: 1,
);
/// A German word of Wiktionary: [headword], its part of speech [pos] in
/// full, meaning [glosses], its [genders] and the [forms] that find it.
DictionaryWord germanWord(
String headword, {
required String pos,
List<String> glosses = const [],
List<String> genders = const [],
List<String> forms = const [],
List<String> labels = const [],
}) => DictionaryWord(
ref: (source: 'wiktionary-de', id: '$headword/$pos'),
headword: headword,
reading: null,
labels: labels,
senses: [
VocabularySense(glosses: glosses, pos: [pos]),
],
partOfSpeech: pos,
genders: genders,
inflections: [for (final form in forms) WordInflection(form, const [])],
language: 'de',
);
/// A JMdict word: [headword] read [reading], meaning [glosses], [labels]
/// and the parts of speech [pos] on its one sense.
DictionaryWord word(
String id,
String headword, {
String? reading,
List<String> glosses = const [],
List<String> labels = const [],
List<String> pos = const [],
String? version,
}) => DictionaryWord(
ref: (source: 'jmdict', id: id),
headword: headword,
reading: reading,
labels: labels,
senses: [VocabularySense(glosses: glosses, pos: pos)],
version: version,
);
/// A KANJIDIC2 character: [headword] meaning [meanings], read [on] and
/// [kun], with [labels]; a kana with its [romaji]; or a character [of]
/// another dictionary of characters.
DictionaryCharacter character(
String headword, {
List<String> meanings = const [],
List<String> on = const [],
List<String> kun = const [],
List<String> labels = const [],
String? romaji,
String? name,
int? grade,
int? strokes,
String? version,
Dictionary of = kanjidic,
}) => DictionaryCharacter(
ref: (source: of.source, id: headword),
name: name,
language: of.language,
headword: headword,
labels: labels,
meanings: meanings,
onReadings: on,
kunReadings: kun,
romaji: romaji,
grade: grade,
strokes: strokes,
version: version,
);
/// A [DictionaryGateway] in memory: an instance with the dictionaries
/// [available], holding the [words] (and characters) of each source in
/// their order. A search finds the entries whose headword, reading, a
/// meaning or a reading starts with the query.
class FakeDictionaryGateway({
final List<Dictionary> available = const [jmdict],
final List<DictionaryItem> words = const [],
}) implements DictionaryGateway {
/// When set, every request fails with it.
Exception? fail;
/// How often the dictionaries were asked for.
int asked = 0;
@override
Future<List<Dictionary>> dictionaries() async {
asked++;
if (fail case final error?) {
throw error;
}
return available;
}
/// The entries of [dictionary].
Iterable<DictionaryItem> _of(Dictionary dictionary) =>
words.where((word) => word.ref.source == dictionary.source);
@override
Future<List<DictionaryItem>> search(
Dictionary dictionary,
String query,
) async {
if (fail case final error?) {
throw error;
}
final typed = query.trim().toLowerCase();
return [
for (final item in _of(dictionary))
if (switch (item) {
DictionaryWord() => [
item.headword,
?item.reading,
for (final sense in item.senses) ...sense.glosses,
],
DictionaryCharacter() => [
item.headword,
...item.meaningsShown,
...item.onReadings,
for (final reading in item.kunReadings) reading.replaceAll('.', ''),
],
}.any((key) => key.toLowerCase().startsWith(typed)))
item,
];
}
@override
Future<List<DictionaryItem>> entries(
Dictionary dictionary,
List<String> ids,
) async {
if (fail case final error?) {
throw error;
}
final byId = {for (final item in _of(dictionary)) item.ref.id: item};
return [for (final id in ids) ?byId[id]];
}
/// How many keys [withKeys] was asked for each time.
final keysAsked = <int>[];
/// By its headword and its reading, as typed: the curator's keys hold
/// both, the reading also in hiragana; a word of Wiktionary also by its
/// headword and inflections in lower case, as its text keys hold them.
@override
Future<Map<String, List<DictionaryItem>>> withKeys(
Dictionary dictionary,
List<String> keys,
) async {
if (fail case final error?) {
throw error;
}
keysAsked.add(keys.length);
final asked = keys.toSet();
final byKey = <String, List<DictionaryItem>>{};
for (final item in _of(dictionary)) {
for (final key in {
item.headword,
if (item case DictionaryWord(:final reading?)) reading,
if (item case DictionaryWord(partOfSpeech: _?, :final inflections)) ...[
item.headword.toLowerCase(),
for (final form in inflections) form.text.toLowerCase(),
],
}) {
if (asked.contains(key)) {
(byKey[key] ??= []).add(item);
}
}
}
return byKey;
}
/// How often [changed] was asked, and with how many entries each time.
final changedAsked = <int>[];
@override
Future<List<DictionaryItem>> changed(
Dictionary dictionary,
Map<String, String> versions,
) async {
if (fail case final error?) {
throw error;
}
changedAsked.add(versions.length);
return [
for (final item in _of(dictionary))
if (versions[item.ref.id] case final kept?)
if (item.version != null && item.version != kept) item,
];
}
@override
Future<int> countLabelled(Dictionary dictionary, String label) async {
if (fail case final error?) {
throw error;
}
return _of(dictionary).where((word) => word.labels.contains(label)).length;
}
@override
Future<List<DictionaryItem>> labelled(
Dictionary dictionary,
String label, {
required int offset,
required int limit,
}) async {
if (fail case final error?) {
throw error;
}
return _of(dictionary)
.where((word) => word.labels.contains(label))
.skip(offset)
.take(limit)
.toList();
}
}

View file

@ -0,0 +1,305 @@
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
/// The instance's discussions in memory, as the person [me] (the
/// studyset's author when [author]) sees them; every question on a studyset
/// titled [studysetTitle]. Records what was written.
class FakeDiscussionGateway({
final PublicPerson me = const PublicPerson(handle: 'me'),
final bool author = false,
final String studysetTitle = 'Japanese',
/// Why the person may not write; null when they may.
var DiscussionRefusal? refusal,
}) implements DiscussionGateway {
/// Thrown by every call while set: the instance can't be reached.
Exception? fail;
/// The questions, by id, with what is in them.
final questions = <String, Discussion>{};
/// The topics each question is within.
final topicsOf = <String, Set<String>>{};
/// The discussions marked read, in order.
final seen = <String>[];
/// The handles blocked.
final blocked = <String>[];
var _next = 0;
/// Adds a question by [by] about [target], with [answers]; returns it.
Discussion addQuestion(
String title, {
String publicId = 'pub-1',
PublicPerson by = const PublicPerson(handle: 'bob'),
DiscussionTarget target = const DiscussionTarget(
kind: DiscussionTargetKind.studyset,
item: 'root',
version: 1,
),
Set<String> topics = const {'root'},
List<DiscussionPost> answers = const [],
List<DiscussionPost> comments = const [],
String? acceptedAnswerId,
String text = 'Why?',
}) {
final id = 'q${_next++}';
final discussion = Discussion(
question: DiscussionPost(
id: id,
kind: DiscussionPostKind.question,
createdAt: DateTime.utc(2026, 10, 15, 12),
by: by,
title: title,
content: RichContent.plain(text),
mine: by == me,
),
publicId: publicId,
studysetTitle: studysetTitle,
location: 'halkyon.eu/@alice/japanese',
target: target,
answers: answers,
comments: comments,
acceptedAnswerId: acceptedAnswerId,
asker: by == me,
author: author,
refusal: refusal,
);
questions[id] = discussion;
topicsOf[id] = topics;
return discussion;
}
/// An answer by [by].
DiscussionPost answerBy(
String text, {
PublicPerson by = const PublicPerson(handle: 'carol'),
String? id,
}) => DiscussionPost(
id: id ?? 'a${_next++}',
kind: DiscussionPostKind.answer,
createdAt: DateTime.utc(2026, 10, 15, 13),
by: by,
content: RichContent.plain(text),
mine: by == me,
);
void _failing() {
if (fail case final error?) {
throw error;
}
}
void _writing() {
_failing();
if (refusal case final refusal?) {
throw DiscussionRefusedException(refusal);
}
}
DiscussionSummary _summary(Discussion discussion) => DiscussionSummary(
questionId: discussion.question.id,
publicId: discussion.publicId,
studysetTitle: discussion.studysetTitle,
title: discussion.question.title,
asker: discussion.question.by,
askerIsAuthor: discussion.question.fromAuthor,
target: discussion.target,
createdAt: discussion.question.createdAt,
activeAt: discussion.question.createdAt,
answers: discussion.answers.length,
accepted: discussion.acceptedAnswerId != null,
mine: discussion.asker,
unseen: !seen.contains(discussion.question.id),
);
@override
Future<List<DiscussionSummary>> list(
String publicId, {
String? topic,
DiscussionTarget? item,
String? query,
int page = 0,
}) async {
_failing();
return [
for (final MapEntry(key: id, value: discussion) in questions.entries)
if (discussion.publicId == publicId &&
(topic == null || topicsOf[id]!.contains(topic)) &&
(item == null || discussion.target == item) &&
(query == null ||
discussion.question.title.toLowerCase().contains(
query.toLowerCase(),
)))
_summary(discussion),
].reversed.toList();
}
@override
Future<Discussion> read(String questionId) async {
_failing();
return questions[questionId] ?? (throw DiscussionMissing());
}
@override
Future<DiscussionRefusal?> askRefusal(String publicId) async {
_failing();
return refusal;
}
@override
Future<String> ask(
String publicId,
DiscussionTarget target,
String title,
RichContent content,
) async {
_writing();
return addQuestion(
title,
publicId: publicId,
by: me,
target: target,
topics: {target.item},
text: content.plainText,
).question.id;
}
@override
Future<String> answer(String questionId, RichContent content) async {
_writing();
final discussion = questions[questionId] ?? (throw DiscussionMissing());
final answer = DiscussionPost(
id: 'a${_next++}',
kind: DiscussionPostKind.answer,
createdAt: DateTime.utc(2026, 10, 15, 14),
by: me,
fromAuthor: author,
content: content,
mine: true,
);
questions[questionId] = _with(
discussion,
answers: [
...discussion.answers,
answer,
],
);
return answer.id;
}
@override
Future<String> comment(String onId, String text) async {
_writing();
final MapEntry(key: id, value: discussion) = questions.entries.firstWhere(
(entry) =>
entry.key == onId ||
entry.value.answers.any((answer) => answer.id == onId),
orElse: () => throw DiscussionMissing(),
);
final comment = DiscussionPost(
id: 'c${_next++}',
kind: DiscussionPostKind.comment,
createdAt: DateTime.utc(2026, 10, 15, 15),
parentId: onId,
by: me,
text: text,
mine: true,
);
questions[id] = _with(
discussion,
comments: [
...discussion.comments,
comment,
],
);
return comment.id;
}
@override
Future<void> edit(String postId, RichContent content, {String? title}) async {
_writing();
}
@override
Future<void> editComment(String commentId, String text) async {
_writing();
}
@override
Future<void> delete(String postId) async {
_failing();
questions.remove(postId);
}
@override
Future<void> accept(String questionId, String? answerId) async {
_failing();
final discussion = questions[questionId] ?? (throw DiscussionMissing());
questions[questionId] = _with(
discussion,
acceptedAnswerId: answerId,
unaccept: answerId == null,
answers: [
...discussion.answers.where((answer) => answer.id == answerId),
...discussion.answers.where((answer) => answer.id != answerId),
],
);
}
@override
Future<void> hide(String postId, {required bool hidden}) async {
_failing();
}
@override
Future<List<DiscussionSummary>> inbox() async {
_failing();
return [
for (final discussion in questions.values)
if (discussion.asker || discussion.author) _summary(discussion),
];
}
@override
Future<int> unseen() async =>
(await inbox()).where((summary) => summary.unseen).length;
@override
Future<void> markSeen(String questionId) async {
_failing();
seen.add(questionId);
}
@override
Future<void> block(String handle) async {
_failing();
blocked.add(handle);
}
Discussion _with(
Discussion discussion, {
List<DiscussionPost>? answers,
List<DiscussionPost>? comments,
String? acceptedAnswerId,
bool unaccept = false,
}) => Discussion(
question: discussion.question,
publicId: discussion.publicId,
studysetTitle: discussion.studysetTitle,
location: discussion.location,
target: discussion.target,
answers: answers ?? discussion.answers,
comments: comments ?? discussion.comments,
acceptedAnswerId: unaccept
? null
: acceptedAnswerId ?? discussion.acceptedAnswerId,
asker: discussion.asker,
author: discussion.author,
refusal: discussion.refusal,
);
}

View file

@ -0,0 +1,96 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
/// An [ExploreGateway] in memory: [published] as the instance lists them,
/// newest first, two to a page; their [releases] (the latest of each
/// published studyset given out unless another is asked for) and the
/// [files] those name, by SHA-256.
class FakeExploreGateway({
var List<ExploreStudyset> published = const [],
final List<PublishedRelease> releases = const [],
final Map<String, Uint8List> files = const {},
}) implements ExploreGateway {
@override
int get pageSize => 2;
/// What each listing was asked for.
final asked = <({ExploreFilter filter, int offset})>[];
/// When set, every request fails with it.
Exception? fail;
/// When set, giving out a release fails with it, as from the instance.
Exception? failRelease;
@override
Future<List<ExploreStudyset>> list(
ExploreFilter filter, {
int offset = 0,
}) async {
if (fail case final error?) {
throw error;
}
asked.add((filter: filter, offset: offset));
final text = filter.text.trim().toLowerCase();
bool speaks(String? language, String? asked) =>
asked == null ||
language == asked ||
(language?.startsWith('$asked-') ?? false);
return [
for (final studyset in published)
if ((text.isEmpty ||
studyset.title.toLowerCase().contains(text) ||
studyset.author.toLowerCase().contains(text)) &&
speaks(studyset.frontLanguage, filter.frontLanguage) &&
speaks(studyset.backLanguage, filter.backLanguage) &&
(!filter.kinds.contains(ContentKind.cards) || studyset.cards > 0) &&
(!filter.kinds.contains(ContentKind.notes) ||
studyset.noteCount > 0) &&
(!filter.kinds.contains(ContentKind.paths) || studyset.paths > 0) &&
(!filter.kinds.contains(ContentKind.pdfs) || studyset.pdfs > 0))
studyset,
].skip(offset).take(pageSize).toList();
}
@override
Future<List<ExploreStudyset>> entries(List<String> publicIds) async {
if (fail case final error?) {
throw error;
}
return [
for (final studyset in published)
if (publicIds.contains(studyset.publicId)) studyset,
];
}
@override
Future<PublishedRelease> release(
String publicId, {
int? version,
void Function(int fetched, int total)? onFetched,
}) async {
if (failRelease ?? fail case final error?) {
throw error;
}
final of = [
for (final release in releases)
if (release.publicId == publicId) release,
]..sort((a, b) => b.version.compareTo(a.version));
final release = version == null
? of.first
: of.firstWhere((release) => release.version == version);
// In one part.
onFetched?.call(release.items.length, release.items.length);
return release;
}
@override
Future<Uint8List> file(String publicId, int version, String sha256) async {
if (fail case final error?) {
throw error;
}
return files[sha256]!;
}
}

View file

@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// A [FeedbackWriteGateway] in memory: what was written, in order.
class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
/// The threads written: their published studyset, kind and payload JSON.
final threads =
<
({String publicationId, FeedbackKind kind, Map<String, Object?> json})
>[];
/// The replies written, by thread.
final messages = <({String threadId, Map<String, Object?> json})>[];
/// The decisions written, by thread.
final decisions = <({String threadId, FeedbackDecision decision})>[];
@override
Future<String> insertThread({
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
}) async {
threads.add((
publicationId: publicationId,
kind: kind,
json: _json(payload),
));
return 'thread-${threads.length}';
}
@override
Future<void> insertMessage(String threadId, ByteData payload) async =>
messages.add((threadId: threadId, json: _json(payload)));
@override
Future<void> insertDecision(
String threadId,
FeedbackDecision decision,
) async => decisions.add((threadId: threadId, decision: decision));
static Map<String, Object?> _json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
}
/// A [FeedbackServerGateway] in memory: [threads] as the instance gathers
/// them (all public, for the inbox too), [files] by SHA-256.
class FakeFeedbackServerGateway({
final List<FeedbackThread> threads = const [],
final Map<String, Uint8List> files = const {},
}) implements FeedbackServerGateway {
late var _threads = [...threads];
/// The threads read, and the people blocked.
final seen = <String>[];
final blocked = <String>[];
/// When set, every request fails with it.
Exception? fail;
/// Replaces what the instance gathers.
set gathered(List<FeedbackThread> threads) => _threads = [...threads];
Future<void> _maybeFail() async {
if (fail case final error?) {
throw error;
}
}
@override
Future<List<FeedbackThread>> threadsOf(String publicId) async {
await _maybeFail();
return [
for (final thread in _threads)
if (thread.publicId == publicId) thread,
];
}
@override
Future<List<FeedbackThread>> inbox() async {
await _maybeFail();
return [
for (final thread in _threads)
if (thread.mine || thread.forMe) thread,
];
}
@override
Future<int> unseen() async =>
(await inbox()).where((thread) => thread.unseen).length;
@override
Future<void> markSeen(String threadId) async {
await _maybeFail();
seen.add(threadId);
}
@override
Future<void> block(String handle) async => blocked.add(handle);
@override
Future<void> unblock(String handle) async => blocked.remove(handle);
@override
Future<List<String>> blockedPeople() async => [...blocked];
@override
Future<Uint8List> file(String threadId, String sha256) async {
await _maybeFail();
return files[sha256]!;
}
}

View file

@ -0,0 +1,32 @@
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
/// A [FilePicker] that "picks" [next], if set, and notes what it was asked
/// for.
class FakeFilePicker() implements FilePicker {
/// What the next pick gives; null as if the person cancelled.
PickedFile? next;
/// What the next pick of several gives; [next] alone if null.
List<PickedFile>? nextMany;
/// The extensions of each pick asked for, in order.
final asked = <List<String>>[];
@override
Future<PickedFile?> pick({
required String label,
required List<String> extensions,
}) async {
asked.add(extensions);
return next;
}
@override
Future<List<PickedFile>> pickMany({
required String label,
required List<String> extensions,
}) async {
asked.add(extensions);
return nextMany ?? [?next];
}
}

View file

@ -0,0 +1,26 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
/// A [FileSaver] that keeps what it is given, unless [cancel] says the
/// person cancelled.
class FakeFileSaver() implements FileSaver {
/// Whether the next saves are cancelled.
bool cancel = false;
/// What was saved, in order.
final saved = <({String name, Uint8List bytes, String mimeType})>[];
@override
Future<bool> save({
required String name,
required Uint8List bytes,
required String mimeType,
}) async {
if (cancel) {
return false;
}
saved.add((name: name, bytes: bytes, mimeType: mimeType));
return true;
}
}

View file

@ -0,0 +1,324 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:material_ui/material_ui.dart';
/// The server of [FakeJellyfinGateway].
final fakeJellyfinServer = JellyfinServer(
address: Uri.parse('https://jellyfin.local:8920'),
id: 'server-1',
name: 'Living room',
version: '12.2.0',
);
/// A [JellyfinGateway] in memory: [fakeJellyfinServer] at
/// `jellyfin.local:8920`, the person `mika` with the password `secret`,
/// the libraries and what each item holds ([tree], by parent id; the
/// libraries under `''`), the videos' playback and subtitles ([vtt], by
/// item id and track index).
class FakeJellyfinGateway({
final Map<String, List<JellyfinItem>> tree = const {},
final Map<String, JellyfinPlayback> playbacks = const {},
final Map<(String, int), String> vtt = const {},
/// Each song's lyrics, by its id.
final Map<String, List<({String text, Duration? start})>> lyricsOf = const {},
}) implements JellyfinGateway {
/// When set, every request after signing in fails with it.
JellyfinProblem? fail;
/// Whether the Quick Connect code was approved.
bool approved = false;
/// The people the server shows on its login screen; those named in
/// [withoutPassword] sign in with none.
List<JellyfinPublicUser> shownUsers = const [];
/// Names of accounts without a password, as a demo's.
Set<String> withoutPassword = const {};
/// What was reported of playbacks: their positions, in order.
final reports = <({Duration position, bool started, bool stopped})>[];
/// The tokens signed out.
final signedOut = <String>[];
void _check(JellyfinAccount account) {
if (fail case final problem?) {
throw JellyfinException(problem: problem);
}
if (signedOut.contains(account.token)) {
throw JellyfinException(problem: JellyfinProblem.signedOut);
}
}
JellyfinAccount _account(
String token, {
String userId = 'user-1',
String userName = 'mika',
}) => JellyfinAccount(
server: fakeJellyfinServer,
userId: userId,
userName: userName,
token: token,
);
@override
Future<JellyfinServer> server(Uri address) async {
if (address.host != fakeJellyfinServer.address.host ||
address.port != fakeJellyfinServer.address.port) {
throw JellyfinException(problem: JellyfinProblem.unreachable);
}
return JellyfinServer(
address: address,
id: fakeJellyfinServer.id,
name: fakeJellyfinServer.name,
version: fakeJellyfinServer.version,
);
}
@override
Future<bool> quickConnectEnabled(JellyfinServer server) async => true;
@override
Future<List<JellyfinPublicUser>> publicUsers(JellyfinServer server) async =>
shownUsers;
@override
Future<JellyfinAccount> signIn(
JellyfinServer server,
String user,
String password,
) async {
for (final public in shownUsers) {
if (public.name == user &&
withoutPassword.contains(user) &&
password.isEmpty) {
return _account('token-3', userId: public.id, userName: public.name);
}
}
if (user != 'mika' || password != 'secret') {
throw JellyfinException(problem: JellyfinProblem.wrongCredentials);
}
return _account('token-1');
}
@override
Future<({String code, String secret})> startQuickConnect(
JellyfinServer server,
) async => (code: '123456', secret: 'secret-1');
@override
Future<bool> quickConnectApproved(
JellyfinServer server,
String secret,
) async => approved;
@override
Future<JellyfinAccount> finishQuickConnect(
JellyfinServer server,
String secret,
) async => _account('token-2');
@override
Future<void> signOut(JellyfinAccount account) async =>
signedOut.add(account.token);
@override
Future<List<JellyfinItem>> libraries(JellyfinAccount account) async {
_check(account);
return tree[''] ?? const [];
}
@override
Future<List<JellyfinItem>> children(
JellyfinAccount account,
String parentId,
) async {
_check(account);
return tree[parentId] ?? const [];
}
@override
Future<List<JellyfinItem>> search(
JellyfinAccount account,
String term,
) async {
_check(account);
return [
for (final items in tree.values)
for (final item in items)
if (item.kind != JellyfinItemKind.library &&
item.name.toLowerCase().contains(term.toLowerCase()))
item,
];
}
@override
Future<JellyfinItem> item(JellyfinAccount account, String id) async {
_check(account);
// A server answers 404 for an item it no longer has.
return [
for (final items in tree.values) ...items,
].where((item) => item.id == id).firstOrNull ??
(throw JellyfinException(problem: JellyfinProblem.failed));
}
@override
Future<JellyfinPlayback> playback(
JellyfinAccount account,
String itemId,
) async {
_check(account);
return playbacks[itemId] ??
(throw JellyfinException(problem: JellyfinProblem.failed));
}
@override
Uri streamOf(JellyfinAccount account, JellyfinPlayback playback) =>
fakeJellyfinServer.address.replace(
path: '/Videos/${playback.itemId}/stream',
);
@override
Map<String, String> headersOf(JellyfinAccount account) => {
'Authorization': 'MediaBrowser Token="${account.token}"',
};
@override
Future<String> subtitles(
JellyfinAccount account,
JellyfinPlayback playback,
JellyfinSubtitleTrack track,
) async {
_check(account);
return vtt[(playback.itemId, track.index)] ?? 'WEBVTT\n';
}
@override
Future<List<({String text, Duration? start})>> lyrics(
JellyfinAccount account,
String itemId,
) async {
_check(account);
return lyricsOf[itemId] ?? const [];
}
@override
Uri imageOf(JellyfinAccount account, JellyfinItem item, {int height = 360}) =>
fakeJellyfinServer.address.replace(path: '/Items/${item.id}/Images');
@override
Future<void> report(
JellyfinAccount account,
JellyfinPlayback playback,
Duration position, {
bool paused = false,
bool started = false,
bool stopped = false,
}) async => reports.add((
position: position,
started: started,
stopped: stopped,
));
}
/// A [JellyfinStore] in memory, with what [kept] holds to start with.
class MemoryJellyfinStore({JellyfinState? kept}) implements JellyfinStore {
JellyfinState? kept = kept;
@override
Future<JellyfinState> read() async =>
kept ?? const JellyfinState(deviceId: 'device-1');
@override
Future<void> write(JellyfinState state) async => kept = state;
}
/// [MediaPlayers] that play nothing: a playback stands where it is put
/// ([FakeMediaPlayback.moveTo]), and its picture is [frame], or a dark box.
class FakeMediaPlayers({
/// What the picture shows.
final Widget? frame,
/// The audio tracks of every video, as its player lists them.
final List<MediaAudioTrack> audio = const [],
@override final bool available = true,
}) implements MediaPlayers {
/// The videos opened, in order.
final opened = <({Uri url, Map<String, String> headers, Duration start})>[];
/// The playback opened last.
FakeMediaPlayback? last;
@override
Future<MediaPlayback> open(
Uri url, {
Map<String, String> headers = const {},
Duration start = Duration.zero,
}) async {
opened.add((url: url, headers: headers, start: start));
return last = FakeMediaPlayback(start: start, frame: frame, audio: audio);
}
}
/// A [MediaPlayback] standing still until moved.
class FakeMediaPlayback({
Duration start = Duration.zero,
final Widget? frame,
final List<MediaAudioTrack> audio = const [],
}) implements MediaPlayback {
final ValueNotifier<MediaState> _state = ValueNotifier(
MediaState(position: start, duration: const Duration(minutes: 24)),
);
/// Whether it was disposed.
bool disposed = false;
/// The audio track chosen, if one was.
MediaAudioTrack? selectedAudio;
@override
Future<List<MediaAudioTrack>> audioTracks() async => audio;
@override
Future<void> selectAudio(MediaAudioTrack track) async =>
selectedAudio = track;
@override
ValueListenable<MediaState> get state => _state;
/// Puts it at [position], as playing would.
void moveTo(Duration position) => _state.value = MediaState(
position: position,
duration: _state.value.duration,
playing: _state.value.playing,
);
@override
Future<void> play() async => _state.value = MediaState(
position: _state.value.position,
duration: _state.value.duration,
playing: true,
);
@override
Future<void> pause() async => _state.value = MediaState(
position: _state.value.position,
duration: _state.value.duration,
);
@override
Future<void> seekTo(Duration position) async => moveTo(position);
@override
Widget view() => AspectRatio(
aspectRatio: 16 / 9,
child: frame ?? const ColoredBox(color: Color(0xFF101418)),
);
@override
Future<void> dispose() async => disposed = true;
}

View file

@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
/// A [LearningPathGateway] in memory. Ids sort by creation, as UUID v7 ids
/// do: paths are `path-0001`, …, steps `step-0002`, …, revisions
/// `srev-0003`, …; [receiveRemoteRevision] plays a sync that brought another
/// device's edit.
class FakeLearningPathGateway() implements LearningPathGateway {
final _paths = <String, PathRow>{};
final _steps = <String, StepRow>{};
final _revisions = <String, StepRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every step revision written on this device, in order.
final written = <StepRevisionRow>[];
/// While set, a new step waits for it to complete before it is stored, as
/// on a slow device: what shows while steps come in can be seen.
Completer<void>? holdSteps;
/// The deletion steps, in order: `blank step <id>`, `blank path <id>`,
/// `delete step <id>`, `delete path <id>`.
final deletions = <String>[];
/// The payload each step revision holds now, by revision id.
Map<String, ByteData> get revisionPayloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// The payload the path [id] holds now; null once deleted.
ByteData? pathPayload(String id) => _paths[id]?.payload;
/// Stores a step revision as if another device had written it and a sync
/// had brought it here.
void receiveRemoteRevision({
required String id,
required String stepId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
stepId: stepId,
parents: parents,
payload: payload,
);
_remoteChanges.add(null);
}
@override
Future<List<PathRow>> paths({String? studysetId}) async => [
for (final path in _paths.values)
if (studysetId == null || path.studysetId == studysetId) path,
];
@override
Future<String> insertPath({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final id = _newId('path');
_paths[id] = (
id: id,
studysetId: studysetId,
position: position,
payload: payload,
);
return id;
}
@override
Future<void> updatePathPayload(String id, ByteData payload) async {
if (_paths[id] case final path?) {
_paths[id] = (
id: id,
studysetId: path.studysetId,
position: path.position,
payload: payload,
);
}
}
@override
Future<void> updatePathPosition(String id, String position) async {
if (_paths[id] case final path?) {
_paths[id] = (
id: id,
studysetId: path.studysetId,
position: position,
payload: path.payload,
);
}
}
@override
Future<void> deletePath(String id) async {
deletions.add('delete path $id');
_paths.remove(id);
}
@override
Future<List<StepRow>> steps(Set<String> pathIds) async => [
for (final step in _steps.values)
if (pathIds.contains(step.pathId)) step,
];
@override
Future<List<StepRevisionRow>> revisions(Set<String> stepIds) async => [
for (final revision in _revisions.values)
if (stepIds.contains(revision.stepId)) revision,
];
@override
Future<({String stepId, String revisionId})> insertStep({
required String pathId,
required String position,
required String kind,
required ByteData payload,
}) async {
await holdSteps?.future;
final id = _newId('step');
_steps[id] = (id: id, pathId: pathId, position: position, kind: kind);
final revisionId = await insertRevision(
stepId: id,
parents: const {},
payload: payload,
);
return (stepId: id, revisionId: revisionId);
}
@override
Future<List<String>> insertSteps(
String pathId,
List<NewStepRow> steps,
) async => [
for (final step in steps)
(await insertStep(
pathId: pathId,
position: step.position,
kind: step.kind,
payload: step.payload,
)).stepId,
];
@override
Future<String> insertRevision({
required String stepId,
required Set<String> parents,
required ByteData payload,
}) async {
final revision = (
id: _newId('srev'),
stepId: stepId,
parents: parents,
payload: payload,
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override
Future<void> updateStepPosition(String id, String position) async {
if (_steps[id] case final step?) {
_steps[id] = (
id: id,
pathId: step.pathId,
position: position,
kind: step.kind,
);
}
}
@override
Future<void> blank({String? stepId, String? pathId}) async {
if (stepId != null) {
deletions.add('blank step $stepId');
for (final revision in [..._revisions.values]) {
if (revision.stepId == stepId) {
_revisions[revision.id] = (
id: revision.id,
stepId: stepId,
parents: revision.parents,
payload: redactedPayload,
);
}
}
}
if (pathId != null) {
deletions.add('blank path $pathId');
await updatePathPayload(pathId, redactedPayload);
}
}
@override
Future<void> deleteStep(String id) async {
deletions.add('delete step $id');
_steps.remove(id);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}

View file

@ -0,0 +1,149 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
/// A [NoteGateway] in memory. Ids sort by creation, as UUID v7 ids do: notes
/// are `note-0001`, `note-0002`, …, revisions `nrev-0001`, …, each written
/// at the clock's time; [receiveRemoteRevision] plays a sync that brought
/// another device's edit.
class FakeNoteGateway() implements NoteGateway {
final _notes = <String, ({String studysetId, String position})>{};
final _revisions = <String, NoteRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every revision written on this device, in order.
final written = <NoteRevisionRow>[];
/// The deletion steps, in order: `blank <note id>`, `delete <note id>`.
final deletions = <String>[];
/// When set, storing a note or a revision fails with it.
Exception? failWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// Stores a revision as if another device had written it and a sync had
/// brought it here.
void receiveRemoteRevision({
required String id,
required String noteId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
noteId: noteId,
parents: parents,
payload: payload,
writtenAt: clock.now().toUtc(),
);
_remoteChanges.add(null);
}
/// Deletes the note [noteId] as if another device had done it and a sync
/// had brought it here.
void receiveRemoteDeletion(String noteId) {
_notes.remove(noteId);
_remoteChanges.add(null);
}
@override
Future<List<NoteRow>> notesOf(String studysetId) async => [
for (final MapEntry(key: id, value: note) in _notes.entries)
if (note.studysetId == studysetId) (id: id, position: note.position),
];
@override
Future<List<NoteStampRow>> allNotes() async => [
for (final MapEntry(key: id, value: note) in _notes.entries)
(id: id, studysetId: note.studysetId),
];
@override
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds) async => [
for (final revision in _revisions.values)
if (noteIds.contains(revision.noteId)) revision,
];
@override
Future<({String noteId, String revisionId})> insertNote({
required String studysetId,
required String position,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final id = _newId('note');
_notes[id] = (studysetId: studysetId, position: position);
final revisionId = await insertRevision(
noteId: id,
parents: const {},
payload: payload,
);
return (noteId: id, revisionId: revisionId);
}
@override
Future<String> insertRevision({
required String noteId,
required Set<String> parents,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final revision = (
id: _newId('nrev'),
noteId: noteId,
parents: parents,
payload: payload,
writtenAt: clock.now().toUtc(),
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override
Future<void> updatePosition(String noteId, String position) async {
if (_notes[noteId] case final note?) {
_notes[noteId] = (studysetId: note.studysetId, position: position);
}
}
@override
Future<void> blankRevisions(String noteId) async {
deletions.add('blank $noteId');
for (final revision in [..._revisions.values]) {
if (revision.noteId == noteId) {
_revisions[revision.id] = (
id: revision.id,
noteId: noteId,
parents: revision.parents,
payload: redactedPayload,
writtenAt: revision.writtenAt,
);
}
}
}
@override
Future<void> deleteNote(String noteId) async {
deletions.add('delete $noteId');
_notes.remove(noteId);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}

View file

@ -0,0 +1,51 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_answer_gateway.dart';
/// A [PathAnswerGateway] in memory; answers are `answer-0001`, … in the
/// order of writing, as UUID v7s are, and [receiveRemote] plays a sync that
/// brought another device's answer.
class FakePathAnswerGateway() implements PathAnswerGateway {
final _answers = <AnswerRow>[];
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Stores [answer] as if another device had written it and a sync had
/// brought it here.
void receiveRemote(AnswerRow answer) {
_answers.add(answer);
_remoteChanges.add(null);
}
@override
Future<List<AnswerRow>> answers({Set<String>? stepIds}) async => [
for (final answer in _answers)
if (stepIds == null || stepIds.contains(answer.stepId)) answer,
];
@override
Future<void> insert(List<LoggedAnswer> answers) async {
for (final answer in answers) {
_answers.add((
id: 'answer-${(_nextId++).toString().padLeft(4, '0')}',
stepId: answer.stepId,
answeredAt: answer.answeredAt,
outcome: answer.outcome,
answer: answer.answer,
));
}
}
@override
Future<void> deleteResets(Set<String> stepIds, DateTime at) async {
_answers.removeWhere(
(answer) =>
answer.outcome == 'reset' &&
stepIds.contains(answer.stepId) &&
answer.answeredAt == at,
);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
}

View file

@ -0,0 +1,171 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
import 'package:material_ui/material_ui.dart';
/// A [PdfEngine] without PDFium, which `flutter test` can't load. A PDF's
/// bytes say what it holds: `pages:12` its page count (10 without), and
/// `chapter:<page>:<title>;` each chapter. Its viewer shows "Page N of M"
/// and does what it is asked at once; [select] plays a selection.
class FakePdfEngine() implements PdfEngine {
/// The viewers built, the last one showing.
final viewers = <FakePdfViewer>[];
/// The regions drawn, in order.
final rendered = <({int page, Rect region})>[];
/// What [PdfDocumentView.renderRegion] gives: a PNG's bytes.
Uint8List image = Uint8List.fromList(const [137, 80, 78, 71, 1, 2, 3]);
/// Whether the next PDF opened can't be read.
bool failNext = false;
/// Selects [text] in the viewer showing.
void select(String text) =>
viewers.last.controller.report(selectedText: text);
@override
Future<PdfDocumentView> open(Uint8List bytes) async {
final text = latin1.decode(bytes, allowInvalid: true);
if (failNext || !text.startsWith('%PDF-')) {
failNext = false;
throw const FormatException('Not a PDF.');
}
final pages =
int.tryParse(
RegExp(r'pages:(\d+)').firstMatch(text)?.group(1) ?? '',
) ??
10;
return _FakeDocument(this, pages, [
for (final match in RegExp(r'chapter:(\d+):([^;]+);').allMatches(text))
(title: match[2]!, page: int.parse(match[1]!)),
]);
}
}
class _FakeDocument(
final FakePdfEngine _engine,
final int _pages,
final List<PdfChapter> _chapters,
) implements PdfDocumentView {
@override
int get pageCount => _pages;
@override
Future<List<PdfChapter>> chapters() async => _chapters;
@override
Future<Uint8List?> renderRegion(int page, Rect region) async {
_engine.rendered.add((page: page, region: region));
return _engine.image;
}
final _viewers = <PdfReaderController, FakePdfViewer>{};
@override
Widget viewer(PdfReaderController controller) {
// One viewer per controller, however often the reader is built.
final viewer = _viewers.putIfAbsent(controller, () {
final viewer = FakePdfViewer(controller, _pages);
_engine.viewers.add(viewer);
return viewer;
});
return _FakeViewerWidget(viewer);
}
@override
Future<void> dispose() async {}
}
/// The fake viewer's handle: pages 1000 high, 700 wide, one below the other.
class FakePdfViewer(final PdfReaderController controller, final int pages)
implements PdfViewerHandle {
/// The texts searched for, in order.
final searched = <String>[];
@override
Future<void> goToPage(int page) async =>
controller.report(page: page.clamp(1, pages));
@override
Future<void> zoomIn() async =>
controller.report(zoom: controller.zoom * 1.25);
@override
Future<void> zoomOut() async =>
controller.report(zoom: controller.zoom / 1.25);
@override
Future<void> fitWidth() async => controller.report(zoom: 1);
@override
Future<void> fitHeight() async => controller.report(zoom: 0.5);
@override
Future<void> setZoom(double zoom) async => controller.report(zoom: zoom);
@override
void search(String text) {
searched.add(text);
controller.report(matchCount: 3, currentMatch: 0);
}
@override
Future<void> nextMatch() async => controller.report(
currentMatch: ((controller.currentMatch ?? -1) + 1) % 3,
);
@override
Future<void> previousMatch() async => controller.report(
currentMatch: ((controller.currentMatch ?? 1) + 2) % 3,
);
@override
void clearSearch() => controller.report(matchCount: 0, noMatch: true);
@override
Rect? pageRect(int page) =>
Rect.fromLTWH(0, (page - controller.page) * 1000.0, 700, 1000);
}
class const _FakeViewerWidget(final FakePdfViewer _viewer)
extends StatefulWidget {
@override
State<_FakeViewerWidget> createState() => _FakeViewerWidgetState();
}
class _FakeViewerWidgetState() extends State<_FakeViewerWidget> {
@override
void initState() {
super.initState();
widget._viewer.controller.addListener(_changed);
// As pdfrx's viewer, ready once laid out.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
widget._viewer.controller.attach(widget._viewer);
}
});
}
@override
void dispose() {
widget._viewer.controller
..removeListener(_changed)
..attach(null);
super.dispose();
}
void _changed() => setState(() {});
@override
Widget build(BuildContext context) => ColoredBox(
color: const Color(0xffdddddd),
child: Center(
child: Text(
'Page ${widget._viewer.controller.page} of ${widget._viewer.pages}',
),
),
);
}

View file

@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart';
/// A [PdfSourceGateway] in memory. Ids sort by creation, as UUID v7 ids do:
/// PDFs are `pdf-0001`, …, reading entries `read-0002`, …;
/// [receiveRemote] plays a sync that brought another device's PDF.
class FakePdfSourceGateway() implements PdfSourceGateway {
final _sources = <String, PdfSourceRow>{};
final _readings = <String, PdfReadingRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// The deletion steps, in order: `blank <id>`, `delete <id>`.
final deletions = <String>[];
/// The reading log, in the order it was written.
List<PdfReadingRow> get readingRows => [..._readings.values];
/// Stores a PDF as if another device had written it and a sync had brought
/// it here.
void receiveRemote({
required String id,
required String studysetId,
required ByteData payload,
String position = 'a0',
}) {
_sources[id] = (
id: id,
studysetId: studysetId,
position: position,
payload: payload,
);
_remoteChanges.add(null);
}
@override
Future<List<PdfSourceRow>> sources({String? studysetId}) async => [
for (final source in _sources.values)
if (studysetId == null || source.studysetId == studysetId) source,
];
@override
Future<PdfSourceRow?> byId(String id) async => _sources[id];
@override
Future<String> insert({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final id = _newId('pdf');
_sources[id] = (
id: id,
studysetId: studysetId,
position: position,
payload: payload,
);
return id;
}
@override
Future<void> updatePosition(String id, String position) async {
if (_sources[id] case final source?) {
_sources[id] = (
id: id,
studysetId: source.studysetId,
position: position,
payload: source.payload,
);
}
}
@override
Future<void> updatePayload(String id, ByteData payload) async {
if (_sources[id] case final source?) {
if (payload.lengthInBytes == 0) {
deletions.add('blank $id');
}
_sources[id] = (
id: id,
studysetId: source.studysetId,
position: source.position,
payload: payload,
);
}
}
@override
Future<void> delete(String id) async {
deletions.add('delete $id');
_readings.removeWhere((_, reading) => reading.pdfSourceId == id);
_sources.remove(id);
}
@override
Future<List<PdfReadingRow>> readings({Set<String>? pdfIds}) async => [
for (final reading in _readings.values)
if (pdfIds == null || pdfIds.contains(reading.pdfSourceId)) reading,
];
@override
Future<void> insertReading({
required String pdfSourceId,
required DateTime readAt,
required String kind,
required int page,
}) async {
final id = _newId('read');
_readings[id] = (
id: id,
pdfSourceId: pdfSourceId,
readAt: readAt,
kind: kind,
page: page,
);
}
@override
Future<void> deleteResets(Set<String> pdfIds, DateTime at) async =>
_readings.removeWhere(
(_, reading) =>
reading.kind == 'reset' &&
pdfIds.contains(reading.pdfSourceId) &&
reading.readAt == at,
);
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String prefix) => '$prefix-${'${_nextId++}'.padLeft(4, '0')}';
}

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