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:
parent
11abd9afce
commit
649499be23
40 changed files with 0 additions and 0 deletions
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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]!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
324
apps/learn/halkyon_learn_flutter/test/support/fake_jellyfin.dart
Normal file
324
apps/learn/halkyon_learn_flutter/test/support/fake_jellyfin.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue