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