feat(app): tags, one per name, folded when devices made the same

A tag is its name: names are lowercase, a person has one tag per name,
and two of one name (made offline on two devices, or by renaming) fold
into the one with the smallest id, so every device ends up alike. The
colour lives in the payload. Deleting a card deletes its tags too.

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-01 16:12:17 +02:00
commit c2d4dfc8b6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 944 additions and 13 deletions

View file

@ -46,7 +46,12 @@ revision's payload, and the per-side merge of ADR 0003 becomes per field.
(content, like a studyset's title); `card_tag` links a card and a tag, one row per (content, like a studyset's title); `card_tag` links a card and a tag, one row per
assignment, never updated. Removing a tag deletes one row, and adding it on another assignment, never updated. Removing a tag deletes one row, and adding it on another
device at the same time makes another, so concurrent edits merge without losing device at the same time makes another, so concurrent edits merge without losing
either. A card with the same tag twice counts once. either. A card with the same tag twice counts once. A tag is its name (added
2026-10-01): names are lowercase, and a person has one tag per name. Two devices
creating the same name offline make two rows; the app keeps the one with the
smallest id and moves the other's cards onto it, as with settings (rule 4).
Renaming a tag to another's name merges them the same way. The colour lives in the
payload next to the name.
3. **Reviews say how the card was studied.** `review_log.mode` is `review` (front to 3. **Reviews say how the card was studied.** `review_log.mode` is `review` (front to
back), `reverse` (back to front, with a schedule of its own, as Anki's reverse cards back), `reverse` (back to front, with a schedule of its own, as Anki's reverse cards
have) or `cram` (outside the schedule: it counts for streaks and the activity have) or `cram` (outside the schedule: it counts for streaks and the activity

View file

@ -16,7 +16,8 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
/// A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its /// A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its
/// space owns it. The name is content, so it lives in the payload, like a /// space owns it. The name is content, so it lives in the payload, like a
/// studyset's title. /// studyset's title. A tag is its name: a person has one tag per lowercase name, and
/// two with the same name, made on two devices offline, fold into one.
abstract class Tag abstract class Tag
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization { implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
Tag._({ Tag._({
@ -64,7 +65,7 @@ abstract class Tag
/// The encryption key's id; always null (ADR 0002), as on studysets. /// The encryption key's id; always null (ADR 0002), as on studysets.
String? keyId; String? keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "name"}. /// The content as UTF-8 JSON: {"schemaVersion", "name", "color"}.
_idt.ByteData payload; _idt.ByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.
@ -228,7 +229,7 @@ class TagTable extends _isd.Table<_isc.UuidValue?> {
/// The encryption key's id; always null (ADR 0002), as on studysets. /// The encryption key's id; always null (ADR 0002), as on studysets.
late final _isd.ColumnString keyId; late final _isd.ColumnString keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "name"}. /// The content as UTF-8 JSON: {"schemaVersion", "name", "color"}.
late final _isd.ColumnByteData payload; late final _isd.ColumnByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.

View file

@ -18,6 +18,8 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
@ -123,12 +125,17 @@ Future<PersonDependencies> _openPerson(
final cards = CardRepository( final cards = CardRepository(
SyncCardGateway(db, sync, person.authUserId), SyncCardGateway(db, sync, person.authUserId),
); );
final tags = TagRepository(
SyncTagGateway(db, sync),
cardChanges: cards.changes,
);
final deletions = StudysetDeletions(studysets, cards); final deletions = StudysetDeletions(studysets, cards);
return PersonDependencies( return PersonDependencies(
studysets: studysets, studysets: studysets,
cards: cards, cards: cards,
reviews: ReviewRepository(SyncReviewGateway(db, sync)), reviews: ReviewRepository(SyncReviewGateway(db, sync)),
tags: tags,
deletions: deletions, deletions: deletions,
onDispose: () async { onDispose: () async {
try { try {
@ -136,6 +143,7 @@ Future<PersonDependencies> _openPerson(
// finish while their repositories are open. // finish while their repositories are open.
await deletions.dispose(); await deletions.dispose();
await studysets.dispose(); await studysets.dispose();
await tags.dispose();
await cards.dispose(); await cards.dispose();
await sync.dispose(); await sync.dispose();
await db.close(); await db.close();

View file

@ -2,6 +2,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// Everything that belongs to one signed-in person: their local database, its /// Everything that belongs to one signed-in person: their local database, its
/// sync, and the repositories over it. /// sync, and the repositories over it.
@ -18,6 +19,9 @@ class PersonDependencies({
/// The person's reviews. /// The person's reviews.
required final ReviewRepository reviews, required final ReviewRepository reviews,
/// The person's tags and which cards have them.
required final TagRepository tags,
/// The studyset deletions that can still be undone. /// The studyset deletions that can still be undone.
required final StudysetDeletions deletions, required final StudysetDeletions deletions,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,

View file

@ -40,7 +40,7 @@ abstract interface class CardGateway() {
/// Blanks the payload of every revision of the card [cardId]. /// Blanks the payload of every revision of the card [cardId].
Future<void> blankRevisions(String cardId); Future<void> blankRevisions(String cardId);
/// Deletes the card [cardId], its revisions and its reviews. /// Deletes the card [cardId], its revisions, its reviews and its tags.
Future<void> deleteCard(String cardId); Future<void> deleteCard(String cardId);
/// Fires when a sync brought changes from the person's other devices. /// Fires when a sync brought changes from the person's other devices.

View file

@ -133,6 +133,10 @@ class SyncCardGateway(
_db, _db,
where: (t) => t.cardId.equals(id), where: (t) => t.cardId.equals(id),
); );
await protocol.CardTag.db.deleteWhere(
_db,
where: (t) => t.cardId.equals(id),
);
await protocol.CardRevision.db.deleteWhere( await protocol.CardRevision.db.deleteWhere(
_db, _db,
where: (t) => t.cardId.equals(id), where: (t) => t.cardId.equals(id),

View file

@ -0,0 +1,103 @@
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [TagGateway] on the person's local database, which the sync engine keeps
/// in step with their other devices.
class SyncTagGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements TagGateway {
@override
Future<List<TagRow>> tags() async => [
for (final tag in await protocol.Tag.db.find(_db))
(id: tag.id!.uuid, payload: tag.payload),
];
@override
Future<List<CardTagRow>> cardTags() async => [
for (final cardTag in await protocol.CardTag.db.find(_db))
(
id: cardTag.id!.uuid,
cardId: cardTag.cardId.uuid,
tagId: cardTag.tagId.uuid,
),
];
@override
Future<String> insertTag(ByteData payload) async {
final tag = await protocol.Tag.db.insertRow(
_db,
protocol.Tag(payload: payload),
);
_sync.requestSync();
return tag.id!.uuid;
}
@override
Future<void> updateTagPayload(String id, ByteData payload) async {
final tag = await protocol.Tag.db.findById(_db, _uuid(id));
if (tag == null) {
return;
}
// Only the named column changes (ADR 0001, rule 1).
await protocol.Tag.db.updateRow(
_db,
tag.copyWith(payload: payload),
columns: (t) => [t.payload],
);
_sync.requestSync();
}
@override
Future<void> deleteTag(String id) async {
await protocol.CardTag.db.deleteWhere(
_db,
where: (t) => t.tagId.equals(_uuid(id)),
);
await protocol.Tag.db.deleteWhere(
_db,
where: (t) => t.id.equals(_uuid(id)),
);
_sync.requestSync();
}
@override
Future<void> insertCardTags(
Iterable<({String cardId, String tagId})> assignments,
) async {
final rows = [
for (final assignment in assignments)
protocol.CardTag(
cardId: _uuid(assignment.cardId),
tagId: _uuid(assignment.tagId),
),
];
if (rows.isEmpty) {
return;
}
await protocol.CardTag.db.insert(_db, rows);
_sync.requestSync();
}
@override
Future<void> deleteCardTags(Set<String> ids) async {
if (ids.isEmpty) {
return;
}
await protocol.CardTag.db.deleteWhere(
_db,
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
/// The id [id] as the database stores it.
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
}

View file

@ -0,0 +1,38 @@
import 'dart:typed_data';
/// A tag's row as the device's database stores it.
typedef TagRow = ({String id, ByteData payload});
/// A card having a tag, as the device's database stores it: one row per
/// assignment, never updated (ADR 0004, rule 2).
typedef CardTagRow = ({String id, String cardId, String tagId});
/// The boundary to the tags in this person's local, synced database. Tests
/// replace it with a fake.
abstract interface class TagGateway() {
/// All tags on this device, unordered.
Future<List<TagRow>> tags();
/// Every card's tags on this device, unordered.
Future<List<CardTagRow>> cardTags();
/// Stores a new tag and returns its id, a UUID v7 made on the device.
Future<String> insertTag(ByteData payload);
/// Replaces a tag's payload.
Future<void> updateTagPayload(String id, ByteData payload);
/// Deletes the tag [id] and every card's assignment of it.
Future<void> deleteTag(String id);
/// Gives each card in [assignments] its tag.
Future<void> insertCardTags(
Iterable<({String cardId, String tagId})> assignments,
);
/// Deletes the assignments [ids].
Future<void> deleteCardTags(Set<String> ids);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,38 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
// A tag's content as its synced row stores it (see core/content/payload).
//
// Schema 1 holds the name and the colour.
/// The schema this app writes.
const tagSchemaVersion = 1;
/// A tag's content: its name and colour.
typedef TagContent = ({String name, String colorName});
/// Encodes a tag's [content] into its payload, the name as a
/// [canonicalTagName].
ByteData encodeTagPayload(TagContent content) =>
encodePayload(tagSchemaVersion, {
'name': canonicalTagName(content.name),
'color': content.colorName,
});
/// Decodes a tag's payload, the name as a [canonicalTagName] whoever wrote
/// it.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
TagContent decodeTagPayload(ByteData payload) {
final json = decodePayload(payload, newestSchema: tagSchemaVersion);
if (json case {'name': final String name, 'color': final String color}) {
final canonical = canonicalTagName(name);
if (canonical.isNotEmpty) {
return (name: canonical, colorName: color);
}
}
throw const FormatException('Not a tag payload.');
}

View file

@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// A deleted tag, as much of it as undoing the deletion brings back.
typedef DeletedTag = ({String name, String colorName, Set<String> cardIds});
/// The person's tags and which cards have them: read from this device,
/// changed offline, and synced with their other devices in the background.
///
/// A tag is its name (ADR 0004, rule 2). Two tags of one name, made on two
/// devices offline or by renaming one, fold into the one with the smallest
/// id: it gets the other's cards, and the other is deleted. Every device picks
/// the same one, so they all end up alike.
class TagRepository(
final TagGateway _gateway, {
/// Fires when cards change; a deleted card takes its tags along.
Stream<void> cardChanges = const Stream.empty(),
}) {
this {
_subscriptions = [
_gateway.remoteChanges.listen((_) => unawaited(_refresh())),
cardChanges.listen((_) => unawaited(_refresh())),
];
}
final _tagging = StreamController<Tagging>.broadcast();
late final List<StreamSubscription<void>> _subscriptions;
// Every read and write waits for the one before, so a fold never meets a
// half-done change, and an older tagging never overtakes a newer one.
Future<void> _queue = Future.value();
/// The tagging now and after every change, local or synced. A read that
/// fails arrives as an error; the next change reads again.
Stream<Tagging> watch() => Stream.multi((listener) {
final subscription = _tagging.stream.listen(
listener.add,
onError: listener.addError,
);
listener.onCancel = subscription.cancel;
unawaited(_refresh());
});
/// The tagging on this device right now, read once; [watch] follows every
/// change.
Future<Tagging> read() => _serially(_read);
/// The id of the tag named [name] (any case): an existing one, or a new
/// one in [colorName]. Throws an [ArgumentError] for a blank name.
Future<String> create(
String name, {
String colorName = defaultTagColorName,
}) async {
if (canonicalTagName(name).isEmpty) {
throw ArgumentError.value(name, 'name', 'is blank');
}
final id = await _serially(() async {
if ((await _read()).named(name) case final existing?) {
return existing.id;
}
return await _gateway.insertTag(
encodeTagPayload((name: name, colorName: colorName)),
);
});
await _refresh();
return id;
}
/// Renames and recolours the tag [id]. A name another tag has merges the
/// two (see [TagRepository]). Throws an [ArgumentError] for a blank name.
Future<void> update(
String id, {
required String name,
required String colorName,
}) async {
if (canonicalTagName(name).isEmpty) {
throw ArgumentError.value(name, 'name', 'is blank');
}
await _serially(
() => _gateway.updateTagPayload(
id,
encodeTagPayload((name: name, colorName: colorName)),
),
);
await _refresh();
}
/// Gives the card [cardId] exactly the tags [tagIds]: the missing ones
/// are added, the others taken away. Tags gone in the meantime are left
/// out.
Future<void> setTagsOf(String cardId, Set<String> tagIds) async {
await _serially(() async {
final tagging = await _read();
final rows = [
for (final row in await _gateway.cardTags())
if (row.cardId == cardId) row,
];
final has = {for (final row in rows) row.tagId};
await _gateway.deleteCardTags({
for (final row in rows)
if (!tagIds.contains(row.tagId)) row.id,
});
await _gateway.insertCardTags([
for (final tagId in tagIds)
if (!has.contains(tagId) && tagging.byId(tagId) != null)
(cardId: cardId, tagId: tagId),
]);
});
await _refresh();
}
/// Deletes the tag [id] from every card and then itself; returns what
/// [restore] needs to undo that. Its name is blanked first: the sync
/// engine keeps deleted rows until the whole space is deleted (ADR 0002,
/// rule 2).
Future<DeletedTag> delete(String id) async {
final deleted = await _serially(() async {
final tagging = await _read();
final tag = tagging.byId(id);
if (tag == null) {
return (name: '', colorName: defaultTagColorName, cardIds: <String>{});
}
await _deleteTag(id);
return (
name: tag.name,
colorName: tag.colorName,
cardIds: tagging.cardsWith(id),
);
});
await _refresh();
return deleted;
}
/// Undoes deleting a tag: a tag of its name and colour, on its cards.
Future<void> restore(DeletedTag deleted) async {
if (deleted.name.isEmpty) {
return;
}
final id = await create(deleted.name, colorName: deleted.colorName);
await _serially(() async {
final has = (await _read()).cardsWith(id);
await _gateway.insertCardTags([
for (final cardId in deleted.cardIds)
if (!has.contains(cardId)) (cardId: cardId, tagId: id),
]);
});
await _refresh();
}
/// Stops following changes.
Future<void> dispose() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await _tagging.close();
}
/// Runs [action] once everything before it is done.
Future<T> _serially<T>(Future<T> Function() action) {
final result = _queue.then((_) => action());
_queue = result.then<void>((_) {}, onError: (_) {});
return result;
}
// A failed read goes to the listeners and never ends the queue: the next
// change refreshes again.
Future<void> _refresh() => _serially(() async {
if (_tagging.isClosed) {
return;
}
try {
final tagging = await _read();
if (!_tagging.isClosed) {
_tagging.add(tagging);
}
} on Object catch (error, st) {
log('Reading tags failed', name: 'Tags', error: error);
if (!_tagging.isClosed) {
_tagging.addError(error, st);
}
}
});
/// Reads the tagging, folding tags of one name first; call it only in
/// [_serially].
Future<Tagging> _read() async {
final tags = [for (final row in await _gateway.tags()) ?_decode(row)];
final rows = await _gateway.cardTags();
if (await _fold(tags, rows)) {
return await _read();
}
final known = {for (final tag in tags) tag.id};
final tagIdsByCard = <String, Set<String>>{};
for (final row in rows) {
if (known.contains(row.tagId)) {
(tagIdsByCard[row.cardId] ??= {}).add(row.tagId);
}
}
return Tagging(
tags..sort((a, b) => a.name.compareTo(b.name)),
tagIdsByCard,
);
}
/// Folds every name's tags into the one with the smallest id; returns
/// whether there were any to fold.
Future<bool> _fold(List<Tag> tags, List<CardTagRow> rows) async {
final byName = <String, List<Tag>>{};
for (final tag in tags) {
(byName[tag.name] ??= []).add(tag);
}
var folded = false;
for (final sameName in byName.values.where((tags) => tags.length > 1)) {
final keeper = sameName.reduce(
(a, b) => a.id.compareTo(b.id) <= 0 ? a : b,
);
final others = {
for (final tag in sameName)
if (tag.id != keeper.id) tag.id,
};
final kept = {
for (final row in rows)
if (row.tagId == keeper.id) row.cardId,
};
await _gateway.insertCardTags([
for (final cardId in {
for (final row in rows)
if (others.contains(row.tagId)) row.cardId,
})
if (!kept.contains(cardId)) (cardId: cardId, tagId: keeper.id),
]);
for (final other in others) {
await _deleteTag(other);
}
folded = true;
}
return folded;
}
/// Blanks the tag [id], then deletes it from every card and itself.
Future<void> _deleteTag(String id) async {
await _gateway.updateTagPayload(id, redactedPayload);
await _gateway.deleteTag(id);
}
/// The tag in [row], or null for a row this app can't read: one written
/// by a newer app version, or one being deleted.
Tag? _decode(TagRow row) {
try {
final (:name, :colorName) = decodeTagPayload(row.payload);
return Tag(id: row.id, name: name, colorName: colorName);
} on FormatException catch (error) {
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');
return null;
}
}
}

View file

@ -0,0 +1,29 @@
import 'package:equatable/equatable.dart';
/// The colour a new tag gets, a `PaletteColor`'s name.
const defaultTagColorName = 'cobalt';
/// A tag cards can have, to study and filter by.
///
/// A tag is its name (ADR 0004, rule 2): names are [canonicalTagName]s, and a
/// person has one tag per name. Content moving between people later will
/// match tags by name too.
final class const Tag({
/// A UUID v7, made on the device that created it.
required final String id,
/// The name, a [canonicalTagName].
required final String name,
/// The colour, a `PaletteColor`'s name.
required final String colorName,
}) with Equatable {
@override
List<Object?> get props => [id, name, colorName];
}
/// [name] as tags are named: in lowercase, without spaces around it, and with
/// one space where it had several. Two names are the same tag when this makes
/// them equal.
String canonicalTagName(String name) =>
name.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();

View file

@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// The person's tagging: their tags and the cards that have them, as read at
/// one moment.
final class const Tagging(
/// Every tag, by name.
final List<Tag> all,
/// The ids of the tags each card has, by the card's id.
final Map<String, Set<String>> _tagIdsByCard,
) with Equatable {
/// No tags at all.
static const empty = Tagging([], {});
/// The tag [id], or null when there is none.
Tag? byId(String id) => all.where((tag) => tag.id == id).firstOrNull;
/// The tag named [name], compared as a [canonicalTagName], or null when
/// there is none.
Tag? named(String name) {
final canonical = canonicalTagName(name);
return all.where((tag) => tag.name == canonical).firstOrNull;
}
/// The tags the card [cardId] has, by name.
List<Tag> of(String cardId) {
final ids = _tagIdsByCard[cardId] ?? const {};
return [
for (final tag in all)
if (ids.contains(tag.id)) tag,
];
}
/// The cards that have the tag [tagId].
Set<String> cardsWith(String tagId) => {
for (final MapEntry(key: card, value: ids) in _tagIdsByCard.entries)
if (ids.contains(tagId)) card,
};
@override
List<Object?> get props => [all, _tagIdsByCard];
}

View file

@ -7,11 +7,13 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart'; import '../../support/fake_card_gateway.dart';
import '../../support/fake_review_gateway.dart'; import '../../support/fake_review_gateway.dart';
import '../../support/fake_studyset_gateway.dart'; import '../../support/fake_studyset_gateway.dart';
import '../../support/fake_tag_gateway.dart';
import '../../support/test_dependencies.dart'; import '../../support/test_dependencies.dart';
/// A person's dependencies on fakes, closing with [onDispose]. /// A person's dependencies on fakes, closing with [onDispose].
@ -22,6 +24,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
studysets: studysets, studysets: studysets,
cards: cards, cards: cards,
reviews: ReviewRepository(FakeReviewGateway()), reviews: ReviewRepository(FakeReviewGateway()),
tags: TagRepository(FakeTagGateway()),
deletions: StudysetDeletions(studysets, cards), deletions: StudysetDeletions(studysets, cards),
onDispose: onDispose, onDispose: onDispose,
); );

View file

@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
void main() {
group('The tag payload', () {
test('survives a round trip, Japanese included', () {
const content = (name: '動詞', colorName: 'fern');
expect(decodeTagPayload(encodeTagPayload(content)), content);
});
test('stores the name in lowercase, without spaces around it', () {
final payload = encodeTagPayload((name: ' JLPT N5 ', colorName: 'fern'));
expect(decodePayload(payload, newestSchema: tagSchemaVersion), {
'schemaVersion': 1,
'name': 'jlpt n5',
'color': 'fern',
});
});
test('reads a name another app version wrote in capitals in '
'lowercase', () {
final payload = encodePayload(1, {'name': 'Verb', 'color': 'fern'});
expect(decodeTagPayload(payload).name, 'verb');
});
test('refuses a blank name, a newer schema and a blanked tag', () {
for (final payload in [
encodePayload(1, {'name': ' ', 'color': 'fern'}),
encodePayload(2, {'name': 'verb', 'color': 'fern'}),
redactedPayload,
]) {
expect(() => decodeTagPayload(payload), throwsFormatException);
}
});
});
}

View file

@ -0,0 +1,245 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
import '../../../support/fake_tag_gateway.dart';
void main() {
late FakeTagGateway gateway;
late StreamController<void> cardChanges;
late TagRepository repository;
setUp(() {
gateway = FakeTagGateway();
cardChanges = StreamController<void>.broadcast();
repository = TagRepository(gateway, cardChanges: cardChanges.stream);
});
tearDown(() async {
await repository.dispose();
await cardChanges.close();
});
Iterable<String> names(Tagging tagging) => tagging.all.map((tag) => tag.name);
group('Creating', () {
test('stores the name in lowercase with its colour', () async {
final id = await repository.create(' Verb ', colorName: 'fern');
expect((await repository.read()).all, [
Tag(id: id, name: 'verb', colorName: 'fern'),
]);
});
test('gives a new tag the default colour', () async {
await repository.create('verb');
expect((await repository.read()).all.single.colorName, 'cobalt');
});
test('a name that exists already is that tag: nothing new is '
'stored', () async {
final id = await repository.create('verb');
expect(await repository.create('VERB'), id);
expect(gateway.writes, hasLength(1));
});
test('refuses a blank name', () async {
await expectLater(repository.create(' '), throwsArgumentError);
expect(gateway.writes, isEmpty);
});
});
test('lists the tags by name', () async {
await repository.create('verb');
await repository.create('adjective');
await repository.create('noun');
expect(names(await repository.read()), ['adjective', 'noun', 'verb']);
});
test('gives a card the tags chosen and takes away the others', () async {
final verb = await repository.create('verb');
final noun = await repository.create('noun');
final common = await repository.create('common');
await repository.setTagsOf('card-1', {verb, common});
await repository.setTagsOf('card-1', {noun, common});
final tags = await repository.read();
expect(tags.of('card-1').map((tag) => tag.id), [common, noun]);
expect(tags.cardsWith(verb), isEmpty);
});
test('renames and recolours a tag', () async {
final id = await repository.create('verb');
await repository.update(id, name: 'Verbs', colorName: 'plum');
expect((await repository.read()).all, [
Tag(id: id, name: 'verbs', colorName: 'plum'),
]);
});
group('Same names', () {
test('renaming a tag to the name of another merges them into the older '
'one, which keeps its colour', () async {
final verb = await repository.create('verb', colorName: 'fern');
final verbs = await repository.create('verbs', colorName: 'plum');
await repository.setTagsOf('card-1', {verb});
await repository.setTagsOf('card-2', {verbs});
await repository.setTagsOf('card-3', {verb, verbs});
await repository.update(verbs, name: 'verb', colorName: 'plum');
final tags = await repository.read();
expect(tags.all, [Tag(id: verb, name: 'verb', colorName: 'fern')]);
expect(tags.cardsWith(verb), {'card-1', 'card-2', 'card-3'});
expect(gateway.deleted, [verbs]);
});
test('two tags of one name from two devices fold into the one with the '
'smaller id, on every device alike', () async {
final mine = await repository.create('verb');
await repository.setTagsOf('card-1', {mine});
// Made on the other device a moment earlier: its id is smaller.
gateway
..receiveRemoteTag(
'tag-000',
encodeTagPayload((name: 'verb', colorName: 'plum')),
)
..receiveRemoteCardTag('remote-1', 'card-2', 'tag-000');
final tags = await repository.read();
expect(tags.all, [
const Tag(id: 'tag-000', name: 'verb', colorName: 'plum'),
]);
expect(tags.cardsWith('tag-000'), {'card-1', 'card-2'});
expect(gateway.deleted, [mine]);
});
test('a folded tag is blanked before it is deleted', () async {
final mine = await repository.create('verb');
gateway.receiveRemoteTag(
'tag-000',
encodeTagPayload((name: 'verb', colorName: 'plum')),
);
await repository.read();
expect(gateway.writes.last, (mine, redactedPayload));
});
});
group('Deleting', () {
test('blanks the tag first, then deletes it from every card', () async {
final verb = await repository.create('verb');
await repository.setTagsOf('card-1', {verb});
await repository.delete(verb);
expect(gateway.writes.last, (verb, redactedPayload));
expect(gateway.deleted, [verb]);
final tags = await repository.read();
expect(tags.all, isEmpty);
expect(tags.of('card-1'), isEmpty);
});
test('can be undone: the name, colour and cards come back', () async {
final verb = await repository.create('verb', colorName: 'fern');
await repository.setTagsOf('card-1', {verb});
await repository.setTagsOf('card-2', {verb});
final deleted = await repository.delete(verb);
await repository.restore(deleted);
final tags = await repository.read();
final restored = tags.all.single;
expect((restored.name, restored.colorName), ('verb', 'fern'));
expect(tags.cardsWith(restored.id), {'card-1', 'card-2'});
});
});
test('skips a tag this app cannot read, and never folds it', () async {
gateway.receiveRemoteTag(
'tag-000',
encodePayload(tagSchemaVersion + 1, {'name': 'verb', 'color': 'fern'}),
);
await repository.create('verb');
expect(names(await repository.read()), ['verb']);
expect(gateway.deleted, isEmpty);
});
group('Watching', () {
test('follows changes on this device and from others', () async {
final watched = repository.watch().map(names);
final expectation = expectLater(
watched,
emitsInOrder([
<String>[],
['verb'],
['noun', 'verb'],
]),
);
await Future<void>.delayed(Duration.zero);
await repository.create('verb');
gateway.receiveRemoteTag(
'remote',
encodeTagPayload((name: 'noun', colorName: 'fern')),
);
await expectation;
});
test('reads again when cards change, as a deleted card takes its tags '
'along', () async {
final verb = await repository.create('verb');
await repository.setTagsOf('card-1', {verb});
final watched = repository.watch();
final expectation = expectLater(
watched.map((tags) => tags.cardsWith(verb)),
emitsInOrder([
{'card-1'},
<String>{},
]),
);
await Future<void>.delayed(Duration.zero);
// As the card gateway does when it deletes the card.
await gateway.deleteCardTags({
for (final row in await gateway.cardTags()) row.id,
});
cardChanges.add(null);
await expectation;
});
test('a failed read arrives as an error; the next change reads '
'again', () async {
gateway.failReads = Exception('disk');
final watched = repository.watch();
final expectation = expectLater(
watched.map(names),
emitsInOrder([
emitsError(isException),
['verb'],
]),
);
await Future<void>.delayed(Duration.zero);
gateway
..failReads = null
..receiveRemoteTag(
'remote',
encodeTagPayload((name: 'verb', colorName: 'fern')),
);
await expectation;
});
});
}

View file

@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
/// A [TagGateway] in memory: ids are `tag-001`, `tag-002`, … and
/// `card-tag-001`, …, in the order of creation as UUID v7s are, and the
/// `receiveRemote…` methods play a sync that brought changes.
class FakeTagGateway() implements TagGateway {
final _tags = <String, ByteData>{};
final _cardTags = <String, CardTagRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextTag = 1;
var _nextCardTag = 1;
/// Every tag payload written, in order, as (id, payload).
final writes = <(String, ByteData)>[];
/// The ids of the deleted tags, in order.
final deleted = <String>[];
/// When set, reading the tags fails with it.
Exception? failReads;
/// Stores the tag [id] as if another device had written it and a sync had
/// brought it here.
void receiveRemoteTag(String id, ByteData payload) {
_tags[id] = payload;
_remoteChanges.add(null);
}
/// Gives the card [cardId] the tag [tagId] as if another device had done
/// it and a sync had brought it here.
void receiveRemoteCardTag(String id, String cardId, String tagId) {
_cardTags[id] = (id: id, cardId: cardId, tagId: tagId);
_remoteChanges.add(null);
}
@override
Future<List<TagRow>> tags() async {
if (failReads case final error?) {
throw error;
}
return [
for (final MapEntry(key: id, value: payload) in _tags.entries)
(id: id, payload: payload),
];
}
@override
Future<List<CardTagRow>> cardTags() async => [..._cardTags.values];
@override
Future<String> insertTag(ByteData payload) async {
final id = 'tag-${_padded(_nextTag++)}';
_tags[id] = payload;
writes.add((id, payload));
return id;
}
@override
Future<void> updateTagPayload(String id, ByteData payload) async {
if (_tags.containsKey(id)) {
_tags[id] = payload;
writes.add((id, payload));
}
}
@override
Future<void> deleteTag(String id) async {
_cardTags.removeWhere((_, row) => row.tagId == id);
_tags.remove(id);
deleted.add(id);
}
@override
Future<void> insertCardTags(
Iterable<({String cardId, String tagId})> assignments,
) async {
for (final (:cardId, :tagId) in assignments) {
final id = 'card-tag-${_padded(_nextCardTag++)}';
_cardTags[id] = (id: id, cardId: cardId, tagId: tagId);
}
}
@override
Future<void> deleteCardTags(Set<String> ids) async {
_cardTags.removeWhere((id, _) => ids.contains(id));
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _padded(int number) => '$number'.padLeft(3, '0');
}

View file

@ -11,24 +11,28 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'fake_about_gateway.dart'; import 'fake_about_gateway.dart';
import 'fake_auth_gateway.dart'; import 'fake_auth_gateway.dart';
import 'fake_card_gateway.dart'; import 'fake_card_gateway.dart';
import 'fake_review_gateway.dart'; import 'fake_review_gateway.dart';
import 'fake_studyset_gateway.dart'; import 'fake_studyset_gateway.dart';
import 'fake_tag_gateway.dart';
/// The app's dependencies with fakes instead of the server and the device's /// The app's dependencies with fakes instead of the server and the device's
/// database; each test passes in only the fakes it looks at. /// database; each test passes in only the fakes it looks at.
/// ///
/// Every person gets [studysetGateway] and [cardGateway] (or fresh fakes of /// Every person gets [studysetGateway], [cardGateway] and [tagGateway] (or
/// their own), unless [openPerson] replaces how a person's dependencies are /// fresh fakes of their own), unless [openPerson] replaces how a person's
/// opened. /// dependencies are opened.
Dependencies testDependencies({ Dependencies testDependencies({
AuthGateway? authGateway, AuthGateway? authGateway,
StudysetGateway? studysetGateway, StudysetGateway? studysetGateway,
CardGateway? cardGateway, CardGateway? cardGateway,
ReviewGateway? reviewGateway, ReviewGateway? reviewGateway,
TagGateway? tagGateway,
AboutGateway? aboutGateway, AboutGateway? aboutGateway,
OpenPerson? openPerson, OpenPerson? openPerson,
}) => Dependencies( }) => Dependencies(
@ -41,15 +45,21 @@ Dependencies testDependencies({
studysetGateway ?? FakeStudysetGateway(), studysetGateway ?? FakeStudysetGateway(),
); );
final cards = CardRepository(cardGateway ?? FakeCardGateway()); final cards = CardRepository(cardGateway ?? FakeCardGateway());
final tags = TagRepository(
tagGateway ?? FakeTagGateway(),
cardChanges: cards.changes,
);
final deletions = StudysetDeletions(studysets, cards); final deletions = StudysetDeletions(studysets, cards);
return PersonDependencies( return PersonDependencies(
studysets: studysets, studysets: studysets,
cards: cards, cards: cards,
reviews: ReviewRepository(reviewGateway ?? FakeReviewGateway()), reviews: ReviewRepository(reviewGateway ?? FakeReviewGateway()),
tags: tags,
deletions: deletions, deletions: deletions,
onDispose: () async { onDispose: () async {
await deletions.dispose(); await deletions.dispose();
await studysets.dispose(); await studysets.dispose();
await tags.dispose();
await cards.dispose(); await cards.dispose();
}, },
); );

View file

@ -15,7 +15,8 @@ import 'package:serverpod/serverpod.dart' as _is;
/// A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its /// A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its
/// space owns it. The name is content, so it lives in the payload, like a /// space owns it. The name is content, so it lives in the payload, like a
/// studyset's title. /// studyset's title. A tag is its name: a person has one tag per lowercase name, and
/// two with the same name, made on two devices offline, fold into one.
abstract class Tag abstract class Tag
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization { implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {
Tag._({ Tag._({
@ -61,7 +62,7 @@ abstract class Tag
/// The encryption key's id; always null (ADR 0002), as on studysets. /// The encryption key's id; always null (ADR 0002), as on studysets.
String? keyId; String? keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "name"}. /// The content as UTF-8 JSON: {"schemaVersion", "name", "color"}.
_idt.ByteData payload; _idt.ByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.
@ -225,7 +226,7 @@ class TagTable extends _is.Table<_is.UuidValue?> {
/// The encryption key's id; always null (ADR 0002), as on studysets. /// The encryption key's id; always null (ADR 0002), as on studysets.
late final _is.ColumnString keyId; late final _is.ColumnString keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "name"}. /// The content as UTF-8 JSON: {"schemaVersion", "name", "color"}.
late final _is.ColumnByteData payload; late final _is.ColumnByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.

View file

@ -1,13 +1,14 @@
### A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its ### A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its
### space owns it. The name is content, so it lives in the payload, like a ### space owns it. The name is content, so it lives in the payload, like a
### studyset's title. ### studyset's title. A tag is its name: a person has one tag per lowercase name, and
### two with the same name, made on two devices offline, fold into one.
class: Tag class: Tag
table: tag table: tag
database: sync database: sync
fields: fields:
### The encryption key's id; always null (ADR 0002), as on studysets. ### The encryption key's id; always null (ADR 0002), as on studysets.
keyId: String? keyId: String?
### The content as UTF-8 JSON: {"schemaVersion", "name"}. ### The content as UTF-8 JSON: {"schemaVersion", "name", "color"}.
payload: ByteData payload: ByteData
### The encryption nonce; always null, like keyId. ### The encryption nonce; always null, like keyId.
nonce: ByteData? nonce: ByteData?