feat(feedback): reports and suggestions on published studysets, gathered by the instance

Stage 4 of the public tier (ADR 0005, rule 6), its server and the app's
data. A learner writes a report or a suggestion on a published studyset,
a card, note or step of it, in their own synced space, so it works
offline; replies and the author's decisions (closed, hidden, accepted,
declined) likewise, in the writer's or the author's space. The instance
gathers them: public to everyone signed in, but for what the author
hides; for the author's inbox and the writer's. What counts is decided
as rows are read, since a modified app could write anything: threads
from people with an identity and a public profile the author hasn't
blocked, replies from the writer and the author alone, decisions from
the author alone, no suggestion under "no derivatives". The author gets
the files a suggestion names from its writer's, and the next release
credits the writers of the suggestions accepted since the last
(Co-authored-by). Deleting a person deletes their blocks and what they
read; what they wrote goes with their space.

In the app, the repository writes reports and suggestions (a card, note
or step as the learner has it, without what is theirs alone, with the
files it names) and takes an accepted suggestion into the author's own
card, note or step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 01:04:17 +02:00
commit 9accc95df2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 35683 additions and 1 deletions

View file

@ -33,6 +33,9 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/serverpod_feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/sync_feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
@ -314,6 +317,14 @@ Future<PersonDependencies> _openPerson(
CopyUpdater(studysets, cards, notes, paths, pdfSources, tags, assets),
studysets,
),
feedback: FeedbackRepository(
SyncFeedbackGateway(db, sync),
ServerpodFeedbackGateway(client),
cards,
notes,
paths,
assets,
),
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
@ -87,6 +88,9 @@ class PersonDependencies({
/// What others published, and the person's copies of it.
required final ExploreRepository explore,
/// Reports and suggestions on published studysets.
required final FeedbackRepository feedback,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -125,6 +125,16 @@ class CardRepository(final CardGateway _gateway) {
/// Every card of the person on this device: its id, its studyset, when it
/// was made, and whether it was imported (its first revision says where
/// from).
/// The card [id] on this device now; null when there is none.
Future<Card?> byId(String id) async {
final stamp = (await allCards()).where((card) => card.id == id).firstOrNull;
return stamp == null
? null
: (await cardsOf(
stamp.studysetId,
)).where((card) => card.id == id).firstOrNull;
}
Future<List<CardStamp>> allCards() async {
final imported = <String>{};
for (final revision in await _gateway.allRevisions()) {

View file

@ -0,0 +1,48 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// The person's own feedback rows, in their synced database: what they
/// write works offline and reaches the instance with the next sync. Tests
/// replace it with a fake.
abstract interface class FeedbackWriteGateway() {
/// Stores a thread of [kind] on the published studyset [publicationId].
Future<String> insertThread({
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
});
/// Stores a reply in the thread [threadId].
Future<void> insertMessage(String threadId, ByteData payload);
/// Stores an author's [decision] on the thread [threadId].
Future<void> insertDecision(String threadId, FeedbackDecision decision);
}
/// The feedback the instance gathers from everyone (ADR 0005, rule 6).
/// Tests replace it with a fake.
abstract interface class FeedbackServerGateway() {
/// The threads on the published studyset [publicId] the person may see.
Future<List<FeedbackThread>> threadsOf(String publicId);
/// The threads on the person's published studysets and those they wrote.
Future<List<FeedbackThread>> inbox();
/// How many threads of the inbox have something new.
Future<int> unseen();
/// Records that the person has read the thread [threadId].
Future<void> markSeen(String threadId);
/// Keeps the person with [handle] from writing on the person's published
/// studysets, or lets them again.
Future<void> block(String handle);
Future<void> unblock(String handle);
/// The handles of the people the person blocked.
Future<List<String>> blockedPeople();
/// The file [sha256] the suggestion [threadId] names, from its writer.
Future<Uint8List> file(String threadId, String sha256);
}

View file

@ -0,0 +1,122 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
// A thread's and a reply's content as their synced rows store them (see
// core/content/payload). A thread's, schema 1: what it is about (`target`:
// {kind, item, version}), a report's `category` (as cards' reports name
// them), what its writer says (`body`), and a suggestion's `proposed`
// content (the item's payload JSON as the writer has it) with the `files`
// it names ([{assetId, sha256, mimeType, fileName}]). A reply's, schema 1:
// its `body`.
/// The schema this app writes.
const feedbackSchemaVersion = 1;
/// A thread's payload.
ByteData encodeThreadPayload({
required FeedbackTarget target,
required String body,
ReportCategory? category,
Map<String, Object?>? proposed,
List<FeedbackFile> files = const [],
}) => encodePayload(feedbackSchemaVersion, {
'target': {
'kind': target.kind.name,
'item': target.item,
'version': target.version,
},
'body': body,
'category': ?category?.name,
'proposed': ?proposed,
if (files.isNotEmpty)
'files': [
for (final file in files)
{
'assetId': file.assetId,
'sha256': file.sha256,
'mimeType': file.mimeType,
'fileName': file.fileName,
},
],
});
/// A reply's payload.
ByteData encodeMessagePayload(String body) =>
encodePayload(feedbackSchemaVersion, {'body': body});
/// What a thread's [payload] (its JSON as written) says; throws a
/// [FormatException] for one this app can't read.
({
FeedbackTarget target,
String body,
ReportCategory? category,
Map<String, Object?>? proposed,
List<FeedbackFile> files,
})
decodeThreadPayload(String payload) {
final json = decodePayload(
ByteData.sublistView(utf8.encode(payload)),
newestSchema: feedbackSchemaVersion,
);
if (json case {
'target': {
'kind': final String kind,
'item': final String item,
'version': final int version,
},
}) {
return (
target: FeedbackTarget(
kind:
FeedbackTargetKind.values.asNameMap()[kind] ??
(throw const FormatException('A target of a kind not known.')),
item: item,
version: version,
),
body: switch (json['body']) {
final String body => body,
_ => '',
},
category: switch (json['category']) {
final String name =>
ReportCategory.values.asNameMap()[name] ?? ReportCategory.other,
_ => null,
},
proposed: switch (json['proposed']) {
final Map<String, Object?> proposed => proposed,
_ => null,
},
files: [
for (final file in json['files'] as List<Object?>? ?? const [])
if (file case {
'assetId': final String assetId,
'sha256': final String sha256,
'mimeType': final String mimeType,
})
FeedbackFile(
assetId: assetId,
sha256: sha256,
mimeType: mimeType,
fileName: switch (file['fileName']) {
final String name => name,
_ => '',
},
),
],
);
}
throw const FormatException('Not a feedback thread.');
}
/// What a reply's [payload] (its JSON as written) says.
String decodeMessagePayload(String payload) => switch (decodePayload(
ByteData.sublistView(utf8.encode(payload)),
newestSchema: feedbackSchemaVersion,
)) {
{'body': final String body} => body,
_ => throw const FormatException('Not a feedback reply.'),
};

View file

@ -0,0 +1,248 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
/// A suggestion the author can't take: what it is about isn't in their
/// studyset any more, or its content can't be read by this app.
final class SuggestionNotTaken() implements Exception {
@override
String toString() => 'SuggestionNotTaken';
}
/// Reports and suggestions on published studysets (ADR 0005, rule 6): the
/// person's own are written in their synced space, so writing them works
/// offline; what everyone wrote is read from the instance, which gathers
/// it. Reports and suggestions are public to everyone learning the
/// studyset, unless its author hides them.
class FeedbackRepository(
final FeedbackWriteGateway _write,
final FeedbackServerGateway _server,
final CardRepository _cards,
final NoteRepository _notes,
final LearningPathRepository _paths,
final AssetRepository _assets,
) {
/// Reports what is wrong with the item [origin] names (a copy's card,
/// note or step, or with [kind] studyset the studyset itself) to the
/// author of the published studyset it came from.
Future<void> report(
PublishedRef origin, {
required FeedbackTargetKind kind,
required ReportCategory category,
required String body,
}) async {
await _write.insertThread(
publicationId: origin.publication,
kind: FeedbackKind.report,
payload: encodeThreadPayload(
target: FeedbackTarget(
kind: kind,
item: origin.item,
version: origin.version,
),
category: category,
body: body.trim(),
),
);
}
/// Suggests a copy's card, as the person has it ([content]), to the
/// author of the published studyset it came from, with [body] saying
/// why. Throws an [ArgumentError] for a card of the person's own.
Future<void> suggestCard(CardContent content, {required String body}) =>
_suggest(
content.published,
FeedbackTargetKind.card,
_json(
encodeCardPayload(
content
.withField(CardField.published, null)
.withField(CardField.report, null),
),
),
body,
);
/// Suggests a copy's note, as with [suggestCard].
Future<void> suggestNote(NoteContent content, {required String body}) =>
_suggest(
content.published,
FeedbackTargetKind.note,
_json(
encodeNotePayload(content.withField(NoteField.published, null)),
),
body,
);
/// Suggests a copy's step, as with [suggestCard].
Future<void> suggestStep(StepContent content, {required String body}) =>
_suggest(
content.published,
FeedbackTargetKind.step,
_json(
encodeStepPayload(content.withField(StepField.published, null)),
),
body,
);
Future<void> _suggest(
PublishedRef? origin,
FeedbackTargetKind kind,
Map<String, Object?> proposed,
String body,
) async {
if (origin == null) {
throw ArgumentError.value(origin, 'origin', 'is not a copy');
}
// The files its content names, for the author to take into theirs.
final files = <FeedbackFile>[];
for (final assetId in _assetIdsIn(proposed)) {
if (await _assets.fileOf(assetId) case final file?) {
files.add(
FeedbackFile(
assetId: assetId,
sha256: sha256.convert(file.bytes).toString(),
mimeType: file.mimeType,
fileName: file.fileName,
),
);
}
}
await _write.insertThread(
publicationId: origin.publication,
kind: FeedbackKind.suggestion,
payload: encodeThreadPayload(
target: FeedbackTarget(
kind: kind,
item: origin.item,
version: origin.version,
),
body: body.trim(),
proposed: proposed,
files: files,
),
);
}
/// Replies [body] in [thread].
Future<void> reply(FeedbackThread thread, String body) =>
_write.insertMessage(thread.threadId, encodeMessagePayload(body.trim()));
/// Records the author's [decision] on [thread].
Future<void> decide(FeedbackThread thread, FeedbackDecision decision) =>
_write.insertDecision(thread.threadId, decision);
/// Takes the suggestion [thread] into the author's own studyset: its
/// content as a revision of their card, note or step (which they edit
/// freely afterwards), the files it names among their files; and records
/// it accepted, so that the next release credits its writer. Throws
/// [SuggestionNotTaken] when what it is about is gone or can't be read.
Future<void> accept(FeedbackThread thread) async {
final proposed = thread.proposed;
if (proposed == null) {
throw SuggestionNotTaken();
}
// The writer's files as the author's own.
var text = jsonEncode(proposed);
for (final file in thread.files) {
final own =
await _assets.idOf(file.sha256) ??
await _assets.add(
await _server.file(thread.threadId, file.sha256),
fileName: file.fileName.isEmpty ? file.sha256 : file.fileName,
mimeType: file.mimeType,
);
text = text.replaceAll(file.assetId, own);
}
final payload = ByteData.sublistView(utf8.encode(text));
final item = thread.target.item;
try {
switch (thread.target.kind) {
case FeedbackTargetKind.card:
final card = await _cards.byId(item) ?? (throw SuggestionNotTaken());
await _cards.saveEdit(
card.id,
decodeCardPayload(
payload,
).withField(CardField.report, card.content.report),
basedOn: card.heads,
);
case FeedbackTargetKind.note:
final note = await _notes.byId(item) ?? (throw SuggestionNotTaken());
await _notes.saveEdit(
note.id,
decodeNotePayload(payload),
basedOn: note.heads,
);
case FeedbackTargetKind.step:
final (:step, pathId: _) =
await _paths.stepById(item) ?? (throw SuggestionNotTaken());
await _paths.saveStep(
step.id,
decodeStepPayload(payload, step.kind),
basedOn: step.heads,
);
case FeedbackTargetKind.studyset:
throw SuggestionNotTaken();
}
} on FormatException {
throw SuggestionNotTaken();
}
await decide(thread, FeedbackDecision.accepted);
}
/// The threads on the published studyset [publicId] the person may see.
Future<List<FeedbackThread>> threadsOf(String publicId) =>
_server.threadsOf(publicId);
/// The person's inbox: threads on their published studysets and those
/// they wrote, the most recently active first.
Future<List<FeedbackThread>> inbox() => _server.inbox();
/// How many threads of the inbox have something new.
Future<int> unseen() => _server.unseen();
/// Records that the person has read [thread].
Future<void> markSeen(FeedbackThread thread) =>
_server.markSeen(thread.threadId);
/// Keeps [person] from writing on the person's published studysets.
Future<void> block(FeedbackPerson person) => _server.block(person.handle);
/// Lets the person with [handle] write on them again.
Future<void> unblock(String handle) => _server.unblock(handle);
/// The handles of the people the person blocked.
Future<List<String>> blockedPeople() => _server.blockedPeople();
static Map<String, Object?> _json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
}
/// The person's files [json] (content's JSON) names: the `url` of every
/// block that shows or plays one, as [RichContent.assetIds] finds them.
Set<String> _assetIdsIn(Object? json) => switch (json) {
final Map<String, Object?> map => {
?assetIdOf(map['url']),
for (final value in map.values) ..._assetIdsIn(value),
},
final List<Object?> list => {for (final value in list) ..._assetIdsIn(value)},
_ => const {},
};

View file

@ -0,0 +1,113 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// [FeedbackServerGateway] asking the instance through the Serverpod
/// client. A thread or reply this app can't read is left out.
class ServerpodFeedbackGateway(final protocol.Client _client)
implements FeedbackServerGateway {
@override
Future<List<FeedbackThread>> threadsOf(String publicId) async => _threads(
await _client.feedback.threadsOf(
protocol.UuidValue.withValidation(publicId),
),
);
@override
Future<List<FeedbackThread>> inbox() async =>
_threads(await _client.inbox.inbox());
@override
Future<int> unseen() => _client.inbox.unseen();
@override
Future<void> markSeen(String threadId) =>
_client.inbox.markSeen(protocol.UuidValue.withValidation(threadId));
@override
Future<void> block(String handle) => _client.inbox.block(handle);
@override
Future<void> unblock(String handle) => _client.inbox.unblock(handle);
@override
Future<List<String>> blockedPeople() => _client.inbox.blockedPeople();
@override
Future<Uint8List> file(String threadId, String sha256) async =>
Uint8List.sublistView(
await _client.inbox.file(
protocol.UuidValue.withValidation(threadId),
sha256,
),
);
List<FeedbackThread> _threads(List<protocol.FeedbackView> views) => [
for (final view in views) ?_thread(view),
];
FeedbackThread? _thread(protocol.FeedbackView view) {
try {
final (:target, :body, :category, :proposed, :files) =
decodeThreadPayload(view.payload);
return FeedbackThread(
threadId: view.threadId.uuid,
publicId: view.publicId.uuid,
studysetTitle: view.studysetTitle,
location: view.location,
kind:
FeedbackKind.values.asNameMap()[view.kind] ??
(throw const FormatException('A thread of a kind not known.')),
writer: _person(view.writer),
target: target,
createdAt: view.createdAt,
category: category,
body: body,
proposed: proposed,
files: files,
messages: [
for (final message in view.messages)
if (_body(message.payload) case final body?)
FeedbackMessage(
id: message.id.uuid,
by: _person(message.by),
fromAuthor: message.fromAuthor,
body: body,
createdAt: message.createdAt,
),
],
status:
FeedbackStatus.values.asNameMap()[view.status] ??
FeedbackStatus.open,
hidden: view.hidden,
mine: view.mine,
forMe: view.forMe,
unseen: view.unseen,
);
} on FormatException catch (error) {
log(
'Skipped thread ${view.threadId}: ${error.message}',
name: 'Feedback',
);
return null;
}
}
String? _body(String payload) {
try {
return decodeMessagePayload(payload);
} on FormatException {
return null;
}
}
FeedbackPerson _person(protocol.FeedbackPerson person) => FeedbackPerson(
handle: person.handle,
displayName: person.displayName,
avatarUrl: person.avatarUrl,
);
}

View file

@ -0,0 +1,59 @@
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/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [FeedbackWriteGateway] on the person's local database, which the sync
/// engine takes to the instance.
class SyncFeedbackGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements FeedbackWriteGateway {
@override
Future<String> insertThread({
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
}) async {
final thread = await protocol.FeedbackThread.db.insertRow(
_db,
protocol.FeedbackThread(
publicationId: protocol.UuidValue.withValidation(publicationId),
kind: kind.name,
payload: payload,
),
);
_sync.requestSync();
return thread.id!.uuid;
}
@override
Future<void> insertMessage(String threadId, ByteData payload) async {
await protocol.FeedbackMessage.db.insertRow(
_db,
protocol.FeedbackMessage(
threadId: protocol.UuidValue.withValidation(threadId),
payload: payload,
),
);
_sync.requestSync();
}
@override
Future<void> insertDecision(
String threadId,
FeedbackDecision decision,
) async {
await protocol.FeedbackDecision.db.insertRow(
_db,
protocol.FeedbackDecision(
threadId: protocol.UuidValue.withValidation(threadId),
decision: decision.value,
),
);
_sync.requestSync();
}
}

View file

@ -0,0 +1,147 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
/// What a thread of feedback on a published studyset is (ADR 0005, rule 6).
enum FeedbackKind() {
/// Something wrong, pointed out.
report,
/// A change proposed: the content as its writer would have it.
suggestion,
}
/// Where a thread stands, as the author decided.
enum FeedbackStatus() {
open,
closed,
/// A suggestion the author took into their studyset.
accepted,
/// A suggestion the author turned down.
declined,
}
/// What kind of item feedback is about.
enum FeedbackTargetKind() {
studyset,
card,
note,
step,
}
/// What feedback is about: the published studyset itself, or one of its
/// cards, notes or steps, by its id in the release, as of release
/// [version].
final class const FeedbackTarget({
required final FeedbackTargetKind kind,
required final String item,
required final int version,
}) with Equatable {
@override
List<Object?> get props => [kind, item, version];
}
/// Someone in a thread, as their public profile shows them.
final class const FeedbackPerson({
required final String handle,
final String displayName = '',
final String? avatarUrl,
}) with Equatable {
/// How they are named: their display name, else their handle.
String get name => displayName.isEmpty ? '@$handle' : displayName;
@override
List<Object?> get props => [handle, displayName, avatarUrl];
}
/// A file the content a suggestion proposes names: the writer's asset row,
/// and what the author needs to take it into their own files.
final class const FeedbackFile({
required final String assetId,
required final String sha256,
required final String mimeType,
final String fileName = '',
}) with Equatable {
@override
List<Object?> get props => [assetId, sha256, mimeType, fileName];
}
/// A reply in a thread: by its writer or by the studyset's author.
final class const FeedbackMessage({
required final String id,
required final FeedbackPerson by,
required final bool fromAuthor,
required final String body,
required final DateTime createdAt,
}) with Equatable {
@override
List<Object?> get props => [id, by, fromAuthor, body, createdAt];
}
/// A thread of feedback on a published studyset: a report or a suggestion,
/// with its replies; public to everyone learning the studyset unless the
/// author hid it.
final class const FeedbackThread({
required final String threadId,
required final String publicId,
required final String studysetTitle,
required final String location,
required final FeedbackKind kind,
required final FeedbackPerson writer,
required final FeedbackTarget target,
required final DateTime createdAt,
/// What a report says is wrong; null for a suggestion.
final ReportCategory? category,
/// What its writer says.
final String body = '',
/// A suggestion's content, its payload's JSON as the writer has it; null
/// for a report.
final Map<String, Object?>? proposed,
final List<FeedbackFile> files = const [],
final List<FeedbackMessage> messages = const [],
final FeedbackStatus status = FeedbackStatus.open,
final bool hidden = false,
/// Whether the person wrote it; whether they are the studyset's author.
final bool mine = false,
final bool forMe = false,
/// Whether something in it is new to the person.
final bool unseen = false,
}) with Equatable {
@override
List<Object?> get props => [
threadId,
publicId,
studysetTitle,
location,
kind,
writer,
target,
createdAt,
category,
body,
proposed,
files,
messages,
status,
hidden,
mine,
forMe,
unseen,
];
}
/// An author's decision on a thread, as stored.
enum FeedbackDecision(final String value) {
closed('closed'),
reopened('reopened'),
hidden('hidden'),
shown('shown'),
accepted('accepted'),
declined('declined'),
}

View file

@ -149,6 +149,22 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
}
/// The step [id] on this device now, with the path it is in; null when
/// there is none.
Future<({LearningPathStep step, String pathId})?> stepById(String id) async {
final paths = {for (final path in await _gateway.paths()) path.id};
final row = (await _gateway.steps(
paths,
)).where((step) => step.id == id).firstOrNull;
if (row == null) {
return null;
}
final step = (await stepsOf(
row.pathId,
)).where((step) => step.id == id).firstOrNull;
return step == null ? null : (step: step, pathId: row.pathId);
}
/// Creates a step of [kind] with [content] at the end of [pathId]; returns
/// its id and its first revision's, for the next edit to build on.
Future<({String stepId, String revisionId})> createStep(

View file

@ -89,6 +89,18 @@ class NoteRepository(final NoteGateway _gateway) {
);
}
/// The note [id] on this device now; null when there is none.
Future<Note?> byId(String id) async {
final stamp = (await _gateway.allNotes())
.where((note) => note.id == id)
.firstOrNull;
return stamp == null
? null
: (await notesOf(
stamp.studysetId,
)).where((note) => note.id == id).firstOrNull;
}
/// How many notes each studyset holds, by the studyset's id; studysets
/// without notes are left out.
Future<Map<String, int>> countsByStudyset() async {

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
@ -31,6 +32,7 @@ import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_explore_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_path_answer_gateway.dart';
@ -85,6 +87,14 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
CopyUpdater(studysets, cards, notes, paths, pdfSources, tags, assets),
studysets,
),
feedback: FeedbackRepository(
FakeFeedbackWriteGateway(),
FakeFeedbackServerGateway(),
cards,
notes,
paths,
assets,
),
onDispose: onDispose,
);
}

View file

@ -144,6 +144,21 @@ Map<String, int> _fillSyncedTables(Database db) {
"VALUES (?, 1, ?, 0, 'reached', 1)",
[_id(), pdf],
),
'feedback_thread': (
'INSERT INTO feedback_thread (id, spaceId, publicationId, kind, '
"payload) VALUES (?, 1, ?, 'report', x'00')",
[_id(), _id()],
),
'feedback_message': (
'INSERT INTO feedback_message (id, spaceId, threadId, payload) '
"VALUES (?, 1, ?, x'00')",
[_id(), _id()],
),
'feedback_decision': (
'INSERT INTO feedback_decision (id, spaceId, threadId, decision) '
"VALUES (?, 1, ?, 'closed')",
[_id(), _id()],
),
};
final synced = {

View file

@ -0,0 +1,216 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
const origin = PublishedRef(
publication: 'publication-1',
item: 'card-of-alice',
version: 3,
);
final image = Uint8List.fromList(utf8.encode('an image'));
final imageHash = sha256.convert(image).toString();
void main() {
late FakeFeedbackWriteGateway written;
late FakeFeedbackServerGateway server;
late CardRepository cards;
late NoteRepository notes;
late AssetRepository assets;
late FeedbackRepository feedback;
setUp(() {
written = FakeFeedbackWriteGateway();
server = FakeFeedbackServerGateway(files: {imageHash: image});
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
feedback = FeedbackRepository(
written,
server,
cards,
notes,
LearningPathRepository(FakeLearningPathGateway()),
assets,
);
});
test('a report names what it is about, as of which release', () async {
await feedback.report(
origin,
kind: FeedbackTargetKind.card,
category: ReportCategory.typo,
body: ' 猫 is misspelt. ',
);
final thread = written.threads.single;
expect(thread.publicationId, 'publication-1');
expect(thread.kind, FeedbackKind.report);
expect(thread.json['target'], {
'kind': 'card',
'item': 'card-of-alice',
'version': 3,
});
expect(thread.json['category'], 'typo');
expect(thread.json['body'], '猫 is misspelt.');
});
test('a suggestion proposes the card as the learner has it, without what '
'is theirs alone, and describes the files it names', () async {
final asset = await assets.add(
image,
fileName: 'cat.png',
mimeType: 'image/png',
);
await feedback.suggestCard(
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': 'asset:$asset'},
},
],
},
}),
back: RichContent.plain('a cat'),
published: origin,
report: CardReport(
category: ReportCategory.typo,
at: DateTime.utc(2026, 10, 9),
),
),
body: 'A picture helps.',
);
final thread = written.threads.single;
expect(thread.kind, FeedbackKind.suggestion);
final proposed = thread.json['proposed']! as Map<String, Object?>;
expect(proposed.containsKey('published'), isFalse);
expect(proposed.containsKey('report'), isFalse);
expect(thread.json['files'], [
{
'assetId': asset,
'sha256': imageHash,
'mimeType': 'image/png',
'fileName': 'cat.png',
},
]);
});
test("an accepted suggestion becomes the author's own card, its files "
'theirs, and is recorded so', () async {
final id = await cards.create(
'japanese',
CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
report: CardReport(
category: ReportCategory.other,
note: 'Mine.',
at: DateTime.utc(2026, 10, 9),
),
),
);
const learnersAsset = '0192aaaa-0000-7000-8000-00000000abcd';
await feedback.accept(
FeedbackThread(
threadId: 'thread-1',
publicId: 'publication-1',
studysetTitle: 'Japanese',
location: 'halkyon.eu/@alice/japanese',
kind: FeedbackKind.suggestion,
writer: const FeedbackPerson(handle: 'bob'),
target: FeedbackTarget(
kind: FeedbackTargetKind.card,
item: id,
version: 3,
),
createdAt: DateTime.utc(2026, 10, 9),
proposed: {
'schemaVersion': 9,
'type': 'frontBack',
'front': RichContent.plain('asset:$learnersAsset').toJson(),
'back': RichContent.plain('a cat').toJson(),
},
files: [
FeedbackFile(
assetId: learnersAsset,
sha256: imageHash,
mimeType: 'image/png',
),
],
),
);
final card = (await cards.byId(id))!;
expect(card.content.back.plainText, 'a cat');
final front = card.content.front.plainText;
expect(front, isNot(contains(learnersAsset)));
expect(await assets.idOf(imageHash), front.substring('asset:'.length));
expect(card.content.report?.note, 'Mine.');
expect(written.decisions.single, (
threadId: 'thread-1',
decision: FeedbackDecision.accepted,
));
});
test('a suggestion on something gone is not taken, and nothing is '
'recorded', () async {
await expectLater(
feedback.accept(
FeedbackThread(
threadId: 'thread-1',
publicId: 'publication-1',
studysetTitle: 'Japanese',
location: 'halkyon.eu/@alice/japanese',
kind: FeedbackKind.suggestion,
writer: const FeedbackPerson(handle: 'bob'),
target: const FeedbackTarget(
kind: FeedbackTargetKind.note,
item: 'gone',
version: 3,
),
createdAt: DateTime.utc(2026, 10, 9),
proposed: {
'schemaVersion': 3,
'title': 'Particles',
'body': RichContent.plain('は').toJson(),
},
),
),
throwsA(isA<SuggestionNotTaken>()),
);
expect(written.decisions, isEmpty);
expect(await notes.notesOf('japanese'), isEmpty);
expect(NoteContent.empty().published, isNull);
});
}

View file

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

View file

@ -30,6 +30,8 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
@ -67,6 +69,7 @@ import 'fake_auth_gateway.dart';
import 'fake_card_gateway.dart';
import 'fake_dictionary_gateway.dart';
import 'fake_explore_gateway.dart';
import 'fake_feedback_gateways.dart';
import 'fake_file_picker.dart';
import 'fake_file_saver.dart';
import 'fake_learning_path_gateway.dart';
@ -132,6 +135,8 @@ Dependencies testDependencies({
ProfileGateway? profileGateway,
PublishGateway? publishGateway,
ExploreGateway? exploreGateway,
FeedbackWriteGateway? feedbackWriteGateway,
FeedbackServerGateway? feedbackServerGateway,
SyncOnce? syncOnce,
OpenPerson? openPerson,
}) => Dependencies(
@ -253,6 +258,14 @@ Dependencies testDependencies({
),
studysets,
),
feedback: FeedbackRepository(
feedbackWriteGateway ?? FakeFeedbackWriteGateway(),
feedbackServerGateway ?? FakeFeedbackServerGateway(),
cards,
notes,
paths,
assets,
),
onDispose: () async {
await deletions.dispose();
await noteDeletions.dispose();