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:
parent
7574e42415
commit
9accc95df2
16 changed files with 35683 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.'),
|
||||
};
|
||||
|
|
@ -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 {},
|
||||
};
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
116
halkyon_learn_flutter/test/support/fake_feedback_gateways.dart
Normal file
116
halkyon_learn_flutter/test/support/fake_feedback_gateways.dart
Normal 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]!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue