feat(base,learn): suggest changes within a shared studyset

ADR 0044's step 6b (D44, D46 to D48): a Viewer, and an agent, suggest
changes within a shared studyset, which its Owner and Editors accept or
decline.

A suggestion or report is written as Explore's are, in its writer's own
synced space, with kinds of its own (space-suggestion, space-report)
and the space's id in feedback_thread's publicationId, so no synced
table changes and older apps, skipping kinds they don't know, keep
syncing. Where the studyset is private it is sealed with its key, bound
to the space; a reply to it is sealed for its thread.

Learn's server gathers a space's threads for its members alone
(feedback.threadsInSpace, NotFound for anyone else): only threads and
replies by members now, decisions only from rows in the space itself,
where the engine lets its Owner and Editors alone write; sealed
payloads pass in base64 with their key and nonce. The inbox adds the
threads its caller decides on or wrote. The module tells a host a
person's spaces and each space's members (sharedSpacesOf,
membersOfSpaces). No migration: no table changes.

In the app, a Viewer's card and note offer Suggest a change: the fields
write into the suggestion alone, sent with a message. A shared
studyset's menu lists its Suggestions, where its Owner and Editors
accept a change as a revision, or a new card or note into the
studyset, and decide in the space. A suggestion naming a file that
isn't the studyset's, or bringing files of its own, isn't taken.

Whats-New: In a studyset shared with you as a viewer, suggest a change to a card or note; its owner and editors find your suggestions under Suggestions in its menu and accept or decline them.
Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du Änderungen an Karteikarten und Notizen vor; Besitzer:in und Bearbeiter:innen finden sie im Menü unter „Vorschläge“ und nehmen sie an oder lehnen sie ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 20:34:06 +01:00
commit 82c777b1a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 2949 additions and 427 deletions

View file

@ -2310,5 +2310,13 @@
"inboxSharedOpen": "Öffnen",
"studysetNew": "Neu",
"shareRefusedAgentViewer": "Ein Agent kann nur Betrachter:in sein.",
"shareAgent": "Agent"
"shareAgent": "Agent",
"feedbackOnNewCard": "Eine neue Karteikarte",
"feedbackOnNewNote": "Eine neue Notiz",
"feedbackDecider": "Besitzer:in oder Bearbeiter:in",
"studysetSuggestions": "Vorschläge",
"suggestInSpace": "Änderung vorschlagen",
"suggestInSpaceIntro": "Besitzer:in und Bearbeiter:innen dieses Lernsets sehen es neben ihrer Fassung und übernehmen es oder nicht. Alle darin lesen es, mit deinem öffentlichen Profil.",
"suggestingNote": "Du schlägst eine Änderung vor: Nichts ändert sich, bis Besitzer:in oder Bearbeiter:innen sie übernehmen.",
"suggestSend": "Vorschlag senden"
}

View file

@ -2950,5 +2950,21 @@
"shareRefusedAgentViewer": "An agent can only be a Viewer.",
"@shareRefusedAgentViewer": {"description": "An agent invited or set to another role than Viewer, refused"},
"shareAgent": "Agent",
"@shareAgent": {"description": "Marks a member that is an agent, a program acting for its owner"}
"@shareAgent": {"description": "Marks a member that is an agent, a program acting for its owner"},
"feedbackOnNewCard": "A new flashcard",
"@feedbackOnNewCard": {"description": "What a suggestion within a shared studyset proposes: a new flashcard"},
"feedbackOnNewNote": "A new note",
"@feedbackOnNewNote": {"description": "What a suggestion within a shared studyset proposes: a new note"},
"feedbackDecider": "Owner or editor",
"@feedbackDecider": {"description": "Marks a reply in a shared studyset by someone who decides on suggestions"},
"studysetSuggestions": "Suggestions",
"@studysetSuggestions": {"description": "The menu entry and sheet of a shared studyset's suggestions and reports"},
"suggestInSpace": "Suggest a change",
"@suggestInSpace": {"description": "A Viewer's button: change a card or note as a suggestion to the studyset's owner and editors"},
"suggestInSpaceIntro": "The owner and editors of this studyset see it beside theirs, and take it in or not. Everyone in it reads it, with your public profile.",
"@suggestInSpaceIntro": {"description": "Above the message of a suggestion within a shared studyset"},
"suggestingNote": "You're suggesting a change: nothing changes until the owner or an editor takes it in.",
"@suggestingNote": {"description": "The line above a card or note a Viewer changes as a suggestion"},
"suggestSend": "Send suggestion",
"@suggestSend": {"description": "Sends a Viewer's changed card or note as a suggestion"}
}

View file

@ -13645,6 +13645,54 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Agent'**
String get shareAgent;
/// What a suggestion within a shared studyset proposes: a new flashcard
///
/// In en, this message translates to:
/// **'A new flashcard'**
String get feedbackOnNewCard;
/// What a suggestion within a shared studyset proposes: a new note
///
/// In en, this message translates to:
/// **'A new note'**
String get feedbackOnNewNote;
/// Marks a reply in a shared studyset by someone who decides on suggestions
///
/// In en, this message translates to:
/// **'Owner or editor'**
String get feedbackDecider;
/// The menu entry and sheet of a shared studyset's suggestions and reports
///
/// In en, this message translates to:
/// **'Suggestions'**
String get studysetSuggestions;
/// A Viewer's button: change a card or note as a suggestion to the studyset's owner and editors
///
/// In en, this message translates to:
/// **'Suggest a change'**
String get suggestInSpace;
/// Above the message of a suggestion within a shared studyset
///
/// In en, this message translates to:
/// **'The owner and editors of this studyset see it beside theirs, and take it in or not. Everyone in it reads it, with your public profile.'**
String get suggestInSpaceIntro;
/// The line above a card or note a Viewer changes as a suggestion
///
/// In en, this message translates to:
/// **'You\'re suggesting a change: nothing changes until the owner or an editor takes it in.'**
String get suggestingNote;
/// Sends a Viewer's changed card or note as a suggestion
///
/// In en, this message translates to:
/// **'Send suggestion'**
String get suggestSend;
}
class _AppLocalizationsDelegate

View file

@ -8721,4 +8721,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get shareAgent => 'Agent';
@override
String get feedbackOnNewCard => 'Eine neue Karteikarte';
@override
String get feedbackOnNewNote => 'Eine neue Notiz';
@override
String get feedbackDecider => 'Besitzer:in oder Bearbeiter:in';
@override
String get studysetSuggestions => 'Vorschläge';
@override
String get suggestInSpace => 'Änderung vorschlagen';
@override
String get suggestInSpaceIntro =>
'Besitzer:in und Bearbeiter:innen dieses Lernsets sehen es neben ihrer Fassung und übernehmen es oder nicht. Alle darin lesen es, mit deinem öffentlichen Profil.';
@override
String get suggestingNote =>
'Du schlägst eine Änderung vor: Nichts ändert sich, bis Besitzer:in oder Bearbeiter:innen sie übernehmen.';
@override
String get suggestSend => 'Vorschlag senden';
}

View file

@ -8642,6 +8642,32 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get shareAgent => 'Agent';
@override
String get feedbackOnNewCard => 'A new flashcard';
@override
String get feedbackOnNewNote => 'A new note';
@override
String get feedbackDecider => 'Owner or editor';
@override
String get studysetSuggestions => 'Suggestions';
@override
String get suggestInSpace => 'Suggest a change';
@override
String get suggestInSpaceIntro =>
'The owner and editors of this studyset see it beside theirs, and take it in or not. Everyone in it reads it, with your public profile.';
@override
String get suggestingNote =>
'You\'re suggesting a change: nothing changes until the owner or an editor takes it in.';
@override
String get suggestSend => 'Send suggestion';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -49,6 +49,7 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/learning_counting
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/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_envelopes.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';
@ -610,6 +611,11 @@ Future<PersonDependencies> _openPersonData(
reviews: reviews,
settings: settings,
);
final feedbackEnvelopes = PrivacyFeedbackEnvelopes(
privacy,
spaces,
studysets,
);
final noteDeletions = NoteDeletions(notes);
final cardDeletions = CardDeletions(cards);
final pathDeletions = LearningPathDeletions(paths);
@ -690,12 +696,13 @@ Future<PersonDependencies> _openPersonData(
studysets,
),
feedback: FeedbackRepository(
SyncFeedbackGateway(db, sync),
ServerpodFeedbackGateway(client),
SyncFeedbackGateway(db, sync, spaces),
ServerpodFeedbackGateway(client, feedbackEnvelopes),
cards,
notes,
paths,
assets,
feedbackEnvelopes,
),
discussions: discussions,
grammar: GrammarChecks(

View file

@ -37,6 +37,13 @@ class SpaceRouter(final SpaceLookup _lookup) {
/// Studysets placed before they are written, by their ids.
final _placed = <String, String>{};
/// Other rows placed before they are written, by their ids.
final _placedRows = <String, String>{};
/// Places the row [id], about to be written, in the shared [space]: a
/// decision on a thread within it (D46), which names no studyset.
void placeRow(String id, String space) => _placedRows[id] = space;
/// Places the studyset [studysetId], about to be written without a
/// parent, in the shared [space]: the top of a studyset shared (D14).
void placeIn(String studysetId, String space) => _placed[studysetId] = space;
@ -67,6 +74,10 @@ class SpaceRouter(final SpaceLookup _lookup) {
protocol.LearningPathStep(:final pathId) =>
_lookup.studysetOfPath(pathId.uuid).then(_ofStudyset),
protocol.LearningPathStepRevision(:final stepId) => _ofStep(stepId.uuid),
// A decision on a thread within a shared space goes in that space.
protocol.FeedbackDecision(:final id?) => Future.value(
_placedRows.remove(id.uuid),
),
// A file goes where its key is for: a shared space's key, or none.
protocol.Asset(:final keyId) => Future.value(
SpaceKeyId.parse(keyId)?.space,

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
@ -20,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabular
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_lookup_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
@ -153,6 +156,16 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
late var _sent = widget.draft.content;
var _revision = 0;
/// A Viewer's card as they would have it, while they suggest a change
/// (Halkyon's ADR 0044, D44): it goes nowhere until sent as a suggestion.
CardContent? _suggested;
/// Whether a Viewer is suggesting a change.
var _suggesting = false;
/// The card as this pane shows it.
CardContent get _current => _suggested ?? _sent;
@override
void didUpdateWidget(CardDraftEditor oldWidget) {
super.didUpdateWidget(oldWidget);
@ -164,8 +177,12 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
}
void _change(CardContent content) {
// A Viewer's changes go nowhere (Halkyon's ADR 0044, D35).
// A Viewer's changes go nowhere (Halkyon's ADR 0044, D35), but into
// the suggestion they write (D44).
if (StudysetAccess.viewOnlyNow(context)) {
if (_suggesting) {
setState(() => _suggested = content);
}
return;
}
_sent = content;
@ -265,20 +282,48 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
}
void _addHint() {
final hints = _sent.hints;
final hints = _current.hints;
if (hints.length >= CardContent.maxHints) {
return;
}
setState(() => _newHint = hints.length);
_change(_sent.copyWith(hints: [...hints, RichContent.empty()]));
_change(_current.copyWith(hints: [...hints, RichContent.empty()]));
}
void _addExplanation() {
if (_sent.explanation != null) {
if (_current.explanation != null) {
return;
}
setState(() => _newExplanation = true);
_change(_sent.withField(CardField.explanation, RichContent.empty()));
_change(_current.withField(CardField.explanation, RichContent.empty()));
}
/// Leaves suggesting, the fields as the card is.
void _stopSuggesting() => setState(() {
_suggesting = false;
_suggested = null;
_revision++;
});
/// Sends the card as the Viewer would have it as a suggestion, asking
/// why; leaves suggesting once it went.
Future<void> _sendSuggestion() async {
final studysetId = context.read<CardComposerBloc>().studysetId;
final cardId = widget.draft.cardId;
final content = _current;
if (await suggestInSpace(
context,
send: (feedback, body) => feedback.suggestCardInSpace(
content.normalized,
studysetId: studysetId,
cardId: cardId,
body: body,
),
)) {
if (mounted) {
_stopSuggesting();
}
}
}
Key _key(String field) => ValueKey('${widget.draft.id}-$field-$_revision');
@ -288,8 +333,9 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final draft = widget.draft;
final content = draft.content;
final content = _suggested ?? draft.content;
final kind = CardKind.of(content);
final access = StudysetAccess.of(context);
final chosenTags = [
for (final tag in widget.tagging.all)
if (draft.tagIds.contains(tag.id)) tag,
@ -304,304 +350,318 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
CardKind.map => l10n.composerLabelQuestion,
CardKind.character => l10n.composerLabelCharacter,
};
return Padding(
padding: widget.padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (StudysetAccess.of(context).viewOnly) const ViewOnlyNote(),
Row(
children: [
TagsPill(
hasTags: chosenTags.isNotEmpty,
pinned: widget.pins.contains(CardPin.tags),
onTagsPressed: () => showTagPicker(
context,
selected: draft.tagIds,
onChanged: (tagIds) =>
_send(CardComposerEventTagsChanged(draft.id, tagIds)),
),
onPinPressed: () =>
_send(const CardComposerEventPinToggled(CardPin.tags)),
// While a Viewer suggests a change, the fields below write as for an
// editor, into the suggestion alone ([_change]).
return StudysetAccess(
shared: access.shared,
viewOnly: access.viewOnly && !_suggesting,
child: Padding(
padding: widget.padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (access.viewOnly)
SuggestBar(
suggesting: _suggesting,
onStart: () => setState(() => _suggesting = true),
onCancel: _stopSuggesting,
onSend: () => unawaited(_sendSuggestion()),
),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
for (final tag in chosenTags)
TagChip(key: ValueKey(tag.name), tag: tag),
],
Row(
children: [
TagsPill(
hasTags: chosenTags.isNotEmpty,
pinned: widget.pins.contains(CardPin.tags),
onTagsPressed: () => showTagPicker(
context,
selected: draft.tagIds,
onChanged: (tagIds) =>
_send(CardComposerEventTagsChanged(draft.id, tagIds)),
),
onPinPressed: () =>
_send(const CardComposerEventPinToggled(CardPin.tags)),
),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
for (final tag in chosenTags)
TagChip(key: ValueKey(tag.name), tag: tag),
],
),
),
),
),
const SizedBox(width: 8),
if (!StudysetAccess.of(context).viewOnly)
_Actions(
onAddHint: content.hints.length < CardContent.maxHints
? _addHint
: null,
onAddExplanation: content.explanation == null
? _addExplanation
: null,
onDuplicate: widget.onDuplicate,
onClear: () => _send(CardComposerEventCleared(draft.id)),
onDelete: widget.onDelete,
onSuggest: widget.onSuggest,
),
],
),
const SizedBox(height: 16),
// Chips rather than segments, which would break their names on a
// phone; one row, scrolling sideways where it doesn't fit.
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
for (final (choice, name) in [
(CardKind.flashcard, l10n.composerKindFlashcard),
(CardKind.vocabulary, l10n.composerKindVocabulary),
(CardKind.matching, l10n.composerKindMatching),
(CardKind.map, l10n.composerKindMap),
(CardKind.character, l10n.composerKindCharacter),
])
ChoiceChip(
label: Text(name),
selected: choice == kind,
showCheckmark: false,
onSelected: (_) {
if (choice != kind) {
_change(choice.apply(content));
}
},
const SizedBox(width: 8),
if (!StudysetAccess.of(context).viewOnly)
_Actions(
onAddHint: content.hints.length < CardContent.maxHints
? _addHint
: null,
onAddExplanation: content.explanation == null
? _addExplanation
: null,
onDuplicate: widget.onDuplicate,
onClear: () => _send(CardComposerEventCleared(draft.id)),
onDelete: widget.onDelete,
onSuggest: widget.onSuggest,
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 16),
// Chips rather than segments, which would break their names on a
// phone; one row, scrolling sideways where it doesn't fit.
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
if (draft.unresolved.isNotEmpty) ...[
_Conflicts(draft: draft),
const SizedBox(height: 24),
],
CardFieldLabel(
label: frontLabel,
pinned: widget.pins.contains(CardPin.front),
onPinPressed: () => _send(
const CardComposerEventPinToggled(CardPin.front),
),
// A word or a character, from the dictionary of the
// studyset's language where the instance has one.
action:
kind == CardKind.vocabulary ||
kind == CardKind.character
? StudysetDictionary(
kind: kind == CardKind.character
? DictionaryKind.characters
: DictionaryKind.words,
builder: (context, dictionary) =>
switch (dictionary) {
null => const SizedBox.shrink(),
final dictionary => IconButton(
tooltip: l10n.dictionaryLookUp,
icon: const Icon(
LucideIcons.bookSearch,
size: 18,
),
visualDensity: VisualDensity.compact,
onPressed: () => _lookUp(dictionary),
),
},
)
: null,
),
const SizedBox(height: 8),
CardFace(
key: _key('front'),
content: content.front,
placeholder: switch (kind) {
CardKind.flashcard => l10n.composerPlaceholderFront,
CardKind.vocabulary => l10n.composerPlaceholderExpression,
CardKind.matching => l10n.composerPlaceholderPrompt,
CardKind.map => l10n.composerPlaceholderQuestion,
CardKind.character => switch (primaryLanguageOf(
ContentLanguages.of(context).front,
)) {
'zh' => l10n.composerPlaceholderCharacterChinese,
'ko' => l10n.composerPlaceholderCharacterKorean,
'el' => l10n.composerPlaceholderCharacterGreek,
_ => l10n.composerPlaceholderCharacter,
for (final (choice, name) in [
(CardKind.flashcard, l10n.composerKindFlashcard),
(CardKind.vocabulary, l10n.composerKindVocabulary),
(CardKind.matching, l10n.composerKindMatching),
(CardKind.map, l10n.composerKindMap),
(CardKind.character, l10n.composerKindCharacter),
])
ChoiceChip(
label: Text(name),
selected: choice == kind,
showCheckmark: false,
onSelected: (_) {
if (choice != kind) {
_change(choice.apply(content));
}
},
},
startExpanded: widget.focusFront,
// A character alone is no text to check.
grammar: kind == CardKind.character
? null
: GrammarLanguage.front,
onChanged: (front) => _change(_sent.copyWith(front: front)),
),
if (content.hints.isNotEmpty) ...[
const SizedBox(height: 24),
_Hints(
hints: content.hints,
fieldKey: (index) => _key('hint-$index'),
opened: _newHint,
onChanged: (hints) =>
_change(_sent.copyWith(hints: hints)),
onRemove: (index) {
// The fields are by place: they start afresh.
setState(() {
_revision++;
_newHint = null;
});
_change(
_sent.copyWith(
hints: [..._sent.hints]..removeAt(index),
),
);
},
onAdd: content.hints.length < CardContent.maxHints
? _addHint
: null,
),
],
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 24),
if (kind == CardKind.flashcard)
CardFieldLabel(
label: answersOf(content).length > 1
? l10n.composerLabelAnswers
: l10n.composerLabelBack,
pinned: widget.pins.contains(CardPin.answer),
onPinPressed: () => _send(
const CardComposerEventPinToggled(CardPin.answer),
),
),
const SizedBox(height: 8),
switch (kind) {
CardKind.flashcard => AnswersEditor(
key: _key('answers'),
answers: answersOf(content),
onChanged: (answers) =>
_change(withAnswers(_sent, answers)),
),
CardKind.vocabulary => VocabularyFields(
key: _key('vocabulary'),
reading: content.reading,
meanings: content.meanings,
onReadingChanged: (reading) =>
_change(_sent.copyWith(reading: reading)),
onMeaningsChanged: (meanings) =>
_change(_sent.copyWith(meanings: meanings)),
senses: content.senses,
onSensesChanged: (senses) =>
_change(_sent.copyWith(senses: senses)),
),
CardKind.character => CharacterFields(
key: _key('character'),
meanings: content.meanings,
onReadings: content.onReadings,
kunReadings: content.kunReadings,
onMeaningsChanged: (meanings) =>
_change(_sent.copyWith(meanings: meanings)),
onOnReadingsChanged: (readings) =>
_change(_sent.copyWith(onReadings: readings)),
onKunReadingsChanged: (readings) =>
_change(_sent.copyWith(kunReadings: readings)),
),
CardKind.matching => MatchingFields(
key: _key('matching'),
matching: content.matching,
onChanged: (matching) =>
_change(_sent.copyWith(matching: matching)),
),
CardKind.map => _MapAnswer(
content: content,
around: widget.mapRegions,
onPicked: (map, region) => _change(
_sent.copyWith(
map: map.id,
answers: [
CardAnswer(
content: RichContent.plain(
regionName(context, region),
),
correct: true,
region: region.code,
),
],
),
),
),
},
if (content.explanation case final explanation?) ...[
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Text(
l10n.cardExplanation,
style: theme.textTheme.labelLarge,
),
),
IconButton(
tooltip: l10n.cardRemoveExplanation,
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () => _change(
_sent.withField(CardField.explanation, null),
),
),
],
),
const SizedBox(height: 4),
CardFace(
key: _key('explanation'),
content: explanation,
placeholder: l10n.cardAddExplanation,
startExpanded: _newExplanation,
minEditorHeight: extraEditorHeight,
// Written in either of the studyset's languages.
grammar: GrammarLanguage.recognised,
onChanged: (explanation) => _change(
_sent.withField(CardField.explanation, explanation),
),
),
],
],
),
),
),
if (draft.status == CardDraftStatus.failed) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
l10n.composerSaveFailedInline,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (draft.unresolved.isNotEmpty) ...[
_Conflicts(draft: draft),
const SizedBox(height: 24),
],
CardFieldLabel(
label: frontLabel,
pinned: widget.pins.contains(CardPin.front),
onPinPressed: () => _send(
const CardComposerEventPinToggled(CardPin.front),
),
// A word or a character, from the dictionary of the
// studyset's language where the instance has one.
action:
kind == CardKind.vocabulary ||
kind == CardKind.character
? StudysetDictionary(
kind: kind == CardKind.character
? DictionaryKind.characters
: DictionaryKind.words,
builder: (context, dictionary) =>
switch (dictionary) {
null => const SizedBox.shrink(),
final dictionary => IconButton(
tooltip: l10n.dictionaryLookUp,
icon: const Icon(
LucideIcons.bookSearch,
size: 18,
),
visualDensity: VisualDensity.compact,
onPressed: () => _lookUp(dictionary),
),
},
)
: null,
),
const SizedBox(height: 8),
CardFace(
key: _key('front'),
content: content.front,
placeholder: switch (kind) {
CardKind.flashcard => l10n.composerPlaceholderFront,
CardKind.vocabulary =>
l10n.composerPlaceholderExpression,
CardKind.matching => l10n.composerPlaceholderPrompt,
CardKind.map => l10n.composerPlaceholderQuestion,
CardKind.character => switch (primaryLanguageOf(
ContentLanguages.of(context).front,
)) {
'zh' => l10n.composerPlaceholderCharacterChinese,
'ko' => l10n.composerPlaceholderCharacterKorean,
'el' => l10n.composerPlaceholderCharacterGreek,
_ => l10n.composerPlaceholderCharacter,
},
},
startExpanded: widget.focusFront,
// A character alone is no text to check.
grammar: kind == CardKind.character
? null
: GrammarLanguage.front,
onChanged: (front) =>
_change(_sent.copyWith(front: front)),
),
if (content.hints.isNotEmpty) ...[
const SizedBox(height: 24),
_Hints(
hints: content.hints,
fieldKey: (index) => _key('hint-$index'),
opened: _newHint,
onChanged: (hints) =>
_change(_sent.copyWith(hints: hints)),
onRemove: (index) {
// The fields are by place: they start afresh.
setState(() {
_revision++;
_newHint = null;
});
_change(
_sent.copyWith(
hints: [..._sent.hints]..removeAt(index),
),
);
},
onAdd: content.hints.length < CardContent.maxHints
? _addHint
: null,
),
],
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 24),
if (kind == CardKind.flashcard)
CardFieldLabel(
label: answersOf(content).length > 1
? l10n.composerLabelAnswers
: l10n.composerLabelBack,
pinned: widget.pins.contains(CardPin.answer),
onPinPressed: () => _send(
const CardComposerEventPinToggled(CardPin.answer),
),
),
const SizedBox(height: 8),
switch (kind) {
CardKind.flashcard => AnswersEditor(
key: _key('answers'),
answers: answersOf(content),
onChanged: (answers) =>
_change(withAnswers(_sent, answers)),
),
CardKind.vocabulary => VocabularyFields(
key: _key('vocabulary'),
reading: content.reading,
meanings: content.meanings,
onReadingChanged: (reading) =>
_change(_sent.copyWith(reading: reading)),
onMeaningsChanged: (meanings) =>
_change(_sent.copyWith(meanings: meanings)),
senses: content.senses,
onSensesChanged: (senses) =>
_change(_sent.copyWith(senses: senses)),
),
CardKind.character => CharacterFields(
key: _key('character'),
meanings: content.meanings,
onReadings: content.onReadings,
kunReadings: content.kunReadings,
onMeaningsChanged: (meanings) =>
_change(_sent.copyWith(meanings: meanings)),
onOnReadingsChanged: (readings) =>
_change(_sent.copyWith(onReadings: readings)),
onKunReadingsChanged: (readings) =>
_change(_sent.copyWith(kunReadings: readings)),
),
CardKind.matching => MatchingFields(
key: _key('matching'),
matching: content.matching,
onChanged: (matching) =>
_change(_sent.copyWith(matching: matching)),
),
CardKind.map => _MapAnswer(
content: content,
around: widget.mapRegions,
onPicked: (map, region) => _change(
_sent.copyWith(
map: map.id,
answers: [
CardAnswer(
content: RichContent.plain(
regionName(context, region),
),
correct: true,
region: region.code,
),
],
),
),
),
},
if (content.explanation case final explanation?) ...[
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: Text(
l10n.cardExplanation,
style: theme.textTheme.labelLarge,
),
),
IconButton(
tooltip: l10n.cardRemoveExplanation,
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () => _change(
_sent.withField(CardField.explanation, null),
),
),
],
),
const SizedBox(height: 4),
CardFace(
key: _key('explanation'),
content: explanation,
placeholder: l10n.cardAddExplanation,
startExpanded: _newExplanation,
minEditorHeight: extraEditorHeight,
// Written in either of the studyset's languages.
grammar: GrammarLanguage.recognised,
onChanged: (explanation) => _change(
_sent.withField(CardField.explanation, explanation),
),
),
],
],
),
),
),
if (draft.status == CardDraftStatus.failed) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
l10n.composerSaveFailedInline,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
),
TextButton.icon(
onPressed: () => _send(CardComposerEventLeft(draft.id)),
icon: const Icon(LucideIcons.rotateCcw),
label: Text(l10n.composerRetry),
),
],
),
TextButton.icon(
onPressed: () => _send(CardComposerEventLeft(draft.id)),
icon: const Icon(LucideIcons.rotateCcw),
label: Text(l10n.composerRetry),
),
],
),
],
],
],
),
),
);
}

View file

@ -7,7 +7,9 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_reposit
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// A list of threads of reports and suggestions: the person's inbox (no
/// [_publicId]), or those on the published studyset [_publicId]; and what
/// [_publicId] nor [_studysetId]), those on the published studyset
/// [_publicId], or those within the shared space of the studyset
/// [_studysetId] (Halkyon's ADR 0044, D47); and what
/// the person does in them. Every action reads the list anew. Opening a
/// thread shows it read at once, and deciding on one (closing, hiding,
/// declining) shows the decision at once, as it was again where the
@ -16,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
class FeedbackListBloc(
final FeedbackRepository _feedback, {
final String? _publicId,
final String? _studysetId,
}) extends Bloc<FeedbackListEvent, FeedbackListState> {
this : super(const FeedbackListState()) {
on<FeedbackListEventLoaded>((event, emit) => _load(emit));
@ -79,9 +82,12 @@ class FeedbackListBloc(
Future<void> _load(Emitter<FeedbackListState> emit) async {
try {
final threads = switch (_publicId) {
final publicId? => await _feedback.threadsOf(publicId),
null => await _feedback.inbox(),
final threads = switch ((_publicId, _studysetId)) {
(final publicId?, _) => await _feedback.threadsOf(publicId),
(_, final studysetId?) => await _feedback.threadsInSpaceOf(
studysetId,
),
_ => await _feedback.inbox(),
};
emit(
FeedbackListState(

View file

@ -0,0 +1,93 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
/// What feedback within a shared space asks of the person's data
/// (Halkyon's ADR 0044, D46): the shared space a studyset is in, the key
/// its rows are written with, a row's id made before it is sealed for it,
/// sealing and opening, and a studyset's title, which the instance never
/// knows. Tests replace it with a fake.
abstract interface class FeedbackEnvelopes() {
/// The shared space [studysetId] is in; null for none.
Future<String?> spaceOf(String studysetId);
/// The key something written about [studysetId] is sealed with; null
/// where it is readable.
Future<String?> keyToWriteIn(String studysetId);
/// A new row's id, made here.
String newId();
/// [plain] as the row [id] of [table] keeps it, sealed with [keyId] and
/// [bound] to what it belongs to.
Envelope seal(
ByteData plain, {
required String? keyId,
required String table,
required String id,
required String bound,
});
/// [envelope] opened as [seal] sealed it; null where it doesn't open.
ByteData? open(
Envelope envelope, {
required String table,
required String id,
required String bound,
});
/// The title of the studyset [studysetId] here; empty for none.
Future<String> titleOf(String studysetId);
}
/// [FeedbackEnvelopes] on the person's [Privacy], the [SpaceRouter] of
/// their database and their studysets.
class PrivacyFeedbackEnvelopes(
final Privacy _privacy,
final SpaceRouter _spaces,
final StudysetRepository _studysets,
) implements FeedbackEnvelopes {
@override
Future<String?> spaceOf(String studysetId) async =>
(await _spaces.studysetSpaces())[studysetId];
@override
Future<String?> keyToWriteIn(String studysetId) =>
_privacy.keyToWriteIn(studysetId);
@override
String newId() => Privacy.newId().uuid;
@override
Envelope seal(
ByteData plain, {
required String? keyId,
required String table,
required String id,
required String bound,
}) => _privacy.seal(plain, keyId: keyId, table: table, id: id, bound: bound);
@override
ByteData? open(
Envelope envelope, {
required String table,
required String id,
required String bound,
}) => switch (_privacy.open(envelope, table: table, id: id, bound: bound)) {
Readable(:final payload) => payload,
Locked() => null,
};
@override
Future<String> titleOf(String studysetId) async {
for (final studyset in await _studysets.all()) {
if (studyset.id == studysetId) {
return studyset.content.title;
}
}
return '';
}
}

View file

@ -6,18 +6,37 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
/// 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].
/// Stores a thread of [kind] on the published studyset [publicationId],
/// or within the shared [space] (Halkyon's ADR 0044, D46), as the row
/// [id] (made here where null), its [payload] sealed with [keyId] and
/// [nonce] where they are given.
Future<String> insertThread({
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
String? space,
String? id,
String? keyId,
ByteData? nonce,
});
/// Stores a reply in the thread [threadId].
Future<void> insertMessage(String threadId, ByteData payload);
/// Stores a reply in the thread [threadId], as [insertThread] stores a
/// thread.
Future<void> insertMessage(
String threadId,
ByteData payload, {
String? id,
String? keyId,
ByteData? nonce,
});
/// Stores an author's [decision] on the thread [threadId].
Future<void> insertDecision(String threadId, FeedbackDecision decision);
/// Stores an author's [decision] on the thread [threadId]: in the shared
/// [space] it is within, else in the person's own.
Future<void> insertDecision(
String threadId,
FeedbackDecision decision, {
String? space,
});
}
/// The feedback the instance gathers from everyone (ADR 0005, rule 6).
@ -26,7 +45,11 @@ 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.
/// The threads within the shared space [space] (D46, D47).
Future<List<FeedbackThread>> threadsInSpace(String space);
/// The threads on the person's published studysets and those they wrote,
/// and within the shared spaces they decide in and those they wrote there.
Future<List<FeedbackThread>> inbox();
/// How many threads of the inbox have something new.

View file

@ -10,8 +10,10 @@ import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
// {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`.
// it names ([{assetId, sha256, mimeType, fileName}]); within a shared
// space (Halkyon's ADR 0044, D46), the `studyset` it is about, its target
// the item itself (version 0) or, proposing a new card or note (D48), the
// studyset or topic it goes into. A reply's, schema 1: its `body`.
/// The schema this app writes.
const feedbackSchemaVersion = 1;
@ -23,7 +25,9 @@ ByteData encodeThreadPayload({
ReportCategory? category,
Map<String, Object?>? proposed,
List<FeedbackFile> files = const [],
String? studysetId,
}) => encodePayload(feedbackSchemaVersion, {
'studyset': ?studysetId,
'target': {
'kind': target.kind.name,
'item': target.item,
@ -56,12 +60,22 @@ ByteData encodeMessagePayload(String body) =>
ReportCategory? category,
Map<String, Object?>? proposed,
List<FeedbackFile> files,
String? studysetId,
})
decodeThreadPayload(String payload) {
final json = decodePayload(
ByteData.sublistView(utf8.encode(payload)),
newestSchema: feedbackSchemaVersion,
);
decodeThreadPayload(String payload) =>
decodeThreadBytes(ByteData.sublistView(utf8.encode(payload)));
/// [decodeThreadPayload] on the payload's bytes, as a sealed one opens.
({
FeedbackTarget target,
String body,
ReportCategory? category,
Map<String, Object?>? proposed,
List<FeedbackFile> files,
String? studysetId,
})
decodeThreadBytes(ByteData payload) {
final json = decodePayload(payload, newestSchema: feedbackSchemaVersion);
if (json case {
'target': {
'kind': final String kind,
@ -107,14 +121,22 @@ decodeThreadPayload(String payload) {
},
),
],
studysetId: switch (json['studyset']) {
final String id => id,
_ => null,
},
);
}
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)),
String decodeMessagePayload(String payload) =>
decodeMessageBytes(ByteData.sublistView(utf8.encode(payload)));
/// [decodeMessagePayload] on the payload's bytes, as a sealed one opens.
String decodeMessageBytes(ByteData payload) => switch (decodePayload(
payload,
newestSchema: feedbackSchemaVersion,
)) {
{'body': final String body} => body,

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/profile/public_person.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_envelopes.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';
@ -28,11 +29,21 @@ final class SuggestionNotTaken() implements Exception {
String toString() => 'SuggestionNotTaken';
}
/// The origin of a card taken from a suggestion within a shared space
/// (Halkyon's ADR 0044, D48): nobody's own writing, as an assistant's.
const suggestedCardOrigin = 'suggestion';
/// 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.
///
/// Within a shared space (Halkyon's ADR 0044, D44, D46 to D48), a Viewer
/// or an agent suggests changes and new cards and notes the same way,
/// sealed with the studyset's key where it is private ([_envelopes]); its
/// members read them, and its Owner and Editors decide on them, in the
/// space itself.
class FeedbackRepository(
final FeedbackWriteGateway _write,
final FeedbackServerGateway _server,
@ -40,6 +51,7 @@ class FeedbackRepository(
final NoteRepository _notes,
final LearningPathRepository _paths,
final AssetRepository _assets,
final FeedbackEnvelopes _envelopes,
) {
/// 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
@ -182,13 +194,176 @@ class FeedbackRepository(
);
}
/// Replies [body] in [thread].
Future<void> reply(FeedbackThread thread, String body) =>
_write.insertMessage(thread.threadId, encodeMessagePayload(body.trim()));
/// Suggests a card within the shared studyset [studysetId], as the person
/// would have it ([content]): a change to its card [cardId], or with
/// none a new card for it (D48); [body] says why. Throws an
/// [ArgumentError] where the studyset is in no shared space.
Future<void> suggestCardInSpace(
CardContent content, {
required String studysetId,
required String body,
String? cardId,
}) => _suggestInSpace(
studysetId,
FeedbackTarget(
kind: cardId == null
? FeedbackTargetKind.newCard
: FeedbackTargetKind.card,
item: cardId ?? studysetId,
version: 0,
),
_json(
encodeCardPayload(
content
.withField(CardField.published, null)
.withField(CardField.report, null)
.normalized,
),
),
body,
);
/// Records the author's [decision] on [thread].
/// Suggests a note within the shared studyset [studysetId], as
/// [suggestCardInSpace] a card.
Future<void> suggestNoteInSpace(
NoteContent content, {
required String studysetId,
required String body,
String? noteId,
}) => _suggestInSpace(
studysetId,
FeedbackTarget(
kind: noteId == null
? FeedbackTargetKind.newNote
: FeedbackTargetKind.note,
item: noteId ?? studysetId,
version: 0,
),
_json(
encodeNotePayload(
content.withField(NoteField.published, null).normalized,
),
),
body,
);
/// Suggests a change to the step [stepId] of a learning path within the
/// shared studyset [studysetId], as [suggestCardInSpace] a card.
Future<void> suggestStepInSpace(
StepContent content, {
required String studysetId,
required String stepId,
required String body,
}) => _suggestInSpace(
studysetId,
FeedbackTarget(kind: FeedbackTargetKind.step, item: stepId, version: 0),
_json(encodeStepPayload(content.withField(StepField.published, null))),
body,
);
/// Reports what is wrong with [item] (of [kind]; the studyset itself for
/// [FeedbackTargetKind.studyset]) within the shared studyset
/// [studysetId], to its Owner and Editors: also how a Viewer asks for
/// something to go (D48).
Future<void> reportInSpace({
required String studysetId,
required FeedbackTargetKind kind,
required String item,
required ReportCategory category,
required String body,
}) => _writeInSpace(
studysetId,
FeedbackKind.report,
encodeThreadPayload(
target: FeedbackTarget(kind: kind, item: item, version: 0),
category: category,
body: body.trim(),
studysetId: studysetId,
),
);
Future<void> _suggestInSpace(
String studysetId,
FeedbackTarget target,
Map<String, Object?> proposed,
String body,
) => _writeInSpace(
studysetId,
FeedbackKind.suggestion,
encodeThreadPayload(
target: target,
body: body.trim(),
proposed: proposed,
studysetId: studysetId,
),
);
/// Writes a thread of [kind] about the shared studyset [studysetId] in
/// the person's own space, sealed for its space with its key.
Future<void> _writeInSpace(
String studysetId,
FeedbackKind kind,
ByteData payload,
) async {
final space =
await _envelopes.spaceOf(studysetId) ??
(throw ArgumentError.value(studysetId, 'studysetId', 'is not shared'));
final id = _envelopes.newId();
final sealed = _envelopes.seal(
payload,
keyId: await _envelopes.keyToWriteIn(studysetId),
table: 'feedback_thread',
id: id,
bound: 'space:$space',
);
await _write.insertThread(
publicationId: space,
kind: kind,
payload: sealed.payload,
space: space,
id: id,
keyId: sealed.keyId,
nonce: sealed.nonce,
);
}
/// The threads within the shared space of the studyset [studysetId];
/// none where it is in none.
Future<List<FeedbackThread>> threadsInSpaceOf(String studysetId) async =>
switch (await _envelopes.spaceOf(studysetId)) {
final space? => await _server.threadsInSpace(space),
null => const [],
};
/// Replies [body] in [thread]; within a shared space, sealed as its
/// thread is.
Future<void> reply(FeedbackThread thread, String body) async {
final payload = encodeMessagePayload(body.trim());
if (thread case FeedbackThread(space: _?, :final studysetId?)) {
final id = _envelopes.newId();
final sealed = _envelopes.seal(
payload,
keyId: await _envelopes.keyToWriteIn(studysetId),
table: 'feedback_message',
id: id,
bound: 'thread:${thread.threadId}',
);
await _write.insertMessage(
thread.threadId,
sealed.payload,
id: id,
keyId: sealed.keyId,
nonce: sealed.nonce,
);
return;
}
await _write.insertMessage(thread.threadId, payload);
}
/// Records the author's [decision] on [thread]; within a shared space,
/// in the space, where its Owner and Editors alone write.
Future<void> decide(FeedbackThread thread, FeedbackDecision decision) =>
_write.insertDecision(thread.threadId, decision);
_write.insertDecision(thread.threadId, decision, space: thread.space);
/// Takes the suggestion [thread] into the author's own studyset: its
/// content as a revision of their card, note or step (which they edit
@ -200,13 +375,19 @@ class FeedbackRepository(
if (proposed == null) {
throw SuggestionNotTaken();
}
if (thread.space != null) {
await _acceptInSpace(thread, proposed);
return;
}
// The writer's files as the author's own, in the item's studyset:
// sealed with its key where it is private (ADR 0019).
final kind = switch (thread.target.kind) {
FeedbackTargetKind.card => ItemKind.card,
FeedbackTargetKind.note => ItemKind.note,
FeedbackTargetKind.step => ItemKind.step,
FeedbackTargetKind.studyset => throw SuggestionNotTaken(),
FeedbackTargetKind.studyset ||
FeedbackTargetKind.newCard ||
FeedbackTargetKind.newNote => throw SuggestionNotTaken(),
};
final item = thread.target.item;
// Whole `asset:` values alone are swapped, never text: the writer
@ -254,6 +435,91 @@ class FeedbackRepository(
decodeStepPayload(payload, step.kind),
basedOn: step.heads,
);
case FeedbackTargetKind.studyset ||
FeedbackTargetKind.newCard ||
FeedbackTargetKind.newNote:
throw SuggestionNotTaken();
}
} on FormatException {
throw SuggestionNotTaken();
}
await decide(thread, FeedbackDecision.accepted);
}
/// [accept] within a shared space (D48): a change as a revision of the
/// space's card, note or step, a new card or note into the studyset it
/// names. It brings no files of its own, and names only those of the
/// studyset's tree: one naming another's isn't taken, as a member could
/// name a file of the person's they never saw.
Future<void> _acceptInSpace(
FeedbackThread thread,
Map<String, Object?> proposed,
) async {
final target = thread.target;
if (thread.files.isNotEmpty) {
throw SuggestionNotTaken();
}
final treeKey = switch (target.kind) {
FeedbackTargetKind.card => await _assets.keyOfItem(
ItemKind.card,
target.item,
),
FeedbackTargetKind.note => await _assets.keyOfItem(
ItemKind.note,
target.item,
),
FeedbackTargetKind.step => await _assets.keyOfItem(
ItemKind.step,
target.item,
),
FeedbackTargetKind.newCard ||
FeedbackTargetKind.newNote => await _envelopes.keyToWriteIn(target.item),
FeedbackTargetKind.studyset => throw SuggestionNotTaken(),
};
for (final assetId in assetIdsIn(proposed)) {
if (!await _assets.exists(assetId) ||
await _assets.keyIdOf(assetId) != treeKey) {
throw SuggestionNotTaken();
}
}
final payload = ByteData.sublistView(utf8.encode(jsonEncode(proposed)));
try {
switch (target.kind) {
case FeedbackTargetKind.newCard:
await _cards.create(
target.item,
decodeCardPayload(payload),
origin: suggestedCardOrigin,
);
case FeedbackTargetKind.newNote:
await _notes.create(target.item, decodeNotePayload(payload));
case FeedbackTargetKind.card:
final card =
await _cards.byId(target.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(target.item) ?? (throw SuggestionNotTaken());
await _notes.saveEdit(
note.id,
decodeNotePayload(payload),
basedOn: note.heads,
);
case FeedbackTargetKind.step:
final (:step, pathId: _) =
await _paths.stepById(target.item) ??
(throw SuggestionNotTaken());
await _paths.saveStep(
step.id,
decodeStepPayload(payload, step.kind),
basedOn: step.heads,
);
case FeedbackTargetKind.studyset:
throw SuggestionNotTaken();
}
@ -281,7 +547,9 @@ class FeedbackRepository(
(:final step, pathId: _) => _json(encodeStepPayload(step.content)),
null => null,
},
FeedbackTargetKind.studyset => null,
FeedbackTargetKind.studyset ||
FeedbackTargetKind.newCard ||
FeedbackTargetKind.newNote => null,
};
/// The threads on the published studyset [publicId] the person may see.

View file

@ -1,26 +1,41 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_envelopes.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';
/// [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 {
/// client. A thread or reply this app can't read is left out; one within a
/// shared space is opened through [_envelopes] (Halkyon's ADR 0044, D46),
/// and named by its studyset's title here.
class ServerpodFeedbackGateway(
final protocol.Client _client,
final FeedbackEnvelopes _envelopes,
) implements FeedbackServerGateway {
@override
Future<List<FeedbackThread>> threadsOf(String publicId) async => _threads(
await _client.feedback.threadsOf(
protocol.UuidValue.withValidation(publicId),
),
);
Future<List<FeedbackThread>> threadsOf(String publicId) async =>
await _threads(
await _client.feedback.threadsOf(
protocol.UuidValue.withValidation(publicId),
),
);
@override
Future<List<FeedbackThread>> threadsInSpace(String space) async =>
await _threads(
await _client.feedback.threadsInSpace(
protocol.UuidValue.withValidation(space),
),
);
@override
Future<List<FeedbackThread>> inbox() async =>
_threads(await _client.inbox.inbox());
await _threads(await _client.inbox.inbox());
@override
Future<int> unseen() => _client.inbox.unseen();
@ -47,21 +62,50 @@ class ServerpodFeedbackGateway(final protocol.Client _client)
),
);
List<FeedbackThread> _threads(List<protocol.FeedbackView> views) => [
for (final view in views) ?_thread(view),
Future<List<FeedbackThread>> _threads(
List<protocol.FeedbackView> views,
) async => [
for (final view in views) ?await _thread(view),
];
FeedbackThread? _thread(protocol.FeedbackView view) {
Future<FeedbackThread?> _thread(protocol.FeedbackView view) async {
try {
final (:target, :body, :category, :proposed, :files) =
decodeThreadPayload(view.payload);
final space = view.space?.uuid;
final (
:target,
:body,
:category,
:proposed,
:files,
:studysetId,
) = switch (space) {
null => decodeThreadPayload(view.payload),
_ => decodeThreadBytes(
_opened(
view.payload,
keyId: view.keyId,
nonce: view.nonce,
table: 'feedback_thread',
id: view.threadId.uuid,
bound: 'space:$space',
) ??
(throw const FormatException('A thread not opened.')),
),
};
if (space != null && studysetId == null) {
throw const FormatException('A thread in a space names no studyset.');
}
return FeedbackThread(
threadId: view.threadId.uuid,
publicId: view.publicId.uuid,
studysetTitle: view.studysetTitle,
studysetTitle: studysetId == null
? view.studysetTitle
: await _envelopes.titleOf(studysetId),
location: view.location,
space: space,
studysetId: studysetId,
kind:
FeedbackKind.values.asNameMap()[view.kind] ??
_kinds[view.kind] ??
(throw const FormatException('A thread of a kind not known.')),
writer: _person(view.writer),
target: target,
@ -72,7 +116,7 @@ class ServerpodFeedbackGateway(final protocol.Client _client)
files: files,
messages: [
for (final message in view.messages)
if (_body(message.payload) case final body?)
if (_body(message, view.threadId.uuid, space) case final body?)
FeedbackMessage(
id: message.id.uuid,
by: _person(message.by),
@ -98,14 +142,61 @@ class ServerpodFeedbackGateway(final protocol.Client _client)
}
}
String? _body(String payload) {
/// A reply's text; null where it can't be read. Within a shared space
/// it is opened as sealed for its thread.
String? _body(
protocol.FeedbackMessageView message,
String threadId,
String? space,
) {
try {
return decodeMessagePayload(payload);
if (space == null) {
return decodeMessagePayload(message.payload);
}
final opened = _opened(
message.payload,
keyId: message.keyId,
nonce: message.nonce,
table: 'feedback_message',
id: message.id.uuid,
bound: 'thread:$threadId',
);
return opened == null ? null : decodeMessageBytes(opened);
} on FormatException {
return null;
}
}
/// A payload within a shared space as the view passes it (sealed, in
/// base64; readable, its JSON's text), opened; null where it doesn't.
ByteData? _opened(
String payload, {
required String? keyId,
required String? nonce,
required String table,
required String id,
required String bound,
}) => _envelopes.open(
(
keyId: keyId,
nonce: nonce == null ? null : ByteData.sublistView(base64Decode(nonce)),
payload: ByteData.sublistView(
keyId == null ? utf8.encode(payload) : base64Decode(payload),
),
),
table: table,
id: id,
bound: bound,
);
/// The kinds of thread, within a shared space too.
static const _kinds = {
'report': FeedbackKind.report,
'suggestion': FeedbackKind.suggestion,
'space-report': FeedbackKind.report,
'space-suggestion': FeedbackKind.suggestion,
};
PublicPerson _person(protocol.FeedbackPerson person) => PublicPerson(
handle: person.handle,
displayName: person.displayName,

View file

@ -2,27 +2,40 @@ import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/sync/space_router.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.
/// engine takes to the instance. Threads and replies go in the person's
/// own space always; a decision on a thread within a shared space goes in
/// that space, placed there through [_spaces] (Halkyon's ADR 0044, D46).
class SyncFeedbackGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
final SpaceRouter _spaces,
) implements FeedbackWriteGateway {
@override
Future<String> insertThread({
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
String? space,
String? id,
String? keyId,
ByteData? nonce,
}) async {
final thread = await protocol.FeedbackThread.db.insertRow(
_db,
protocol.FeedbackThread(
publicationId: protocol.UuidValue.withValidation(publicationId),
kind: kind.name,
id: id == null ? null : protocol.UuidValue.withValidation(id),
publicationId: protocol.UuidValue.withValidation(
space ?? publicationId,
),
kind: space == null ? kind.name : 'space-${kind.name}',
keyId: keyId,
nonce: nonce,
payload: payload,
),
);
@ -31,11 +44,20 @@ class SyncFeedbackGateway(
}
@override
Future<void> insertMessage(String threadId, ByteData payload) async {
Future<void> insertMessage(
String threadId,
ByteData payload, {
String? id,
String? keyId,
ByteData? nonce,
}) async {
await protocol.FeedbackMessage.db.insertRow(
_db,
protocol.FeedbackMessage(
id: id == null ? null : protocol.UuidValue.withValidation(id),
threadId: protocol.UuidValue.withValidation(threadId),
keyId: keyId,
nonce: nonce,
payload: payload,
),
);
@ -45,11 +67,17 @@ class SyncFeedbackGateway(
@override
Future<void> insertDecision(
String threadId,
FeedbackDecision decision,
) async {
FeedbackDecision decision, {
String? space,
}) async {
final id = const protocol.Uuid().v7obj();
if (space != null) {
_spaces.placeRow(id.uuid, space);
}
await protocol.FeedbackDecision.db.insertRow(
_db,
protocol.FeedbackDecision(
id: id,
threadId: protocol.UuidValue.withValidation(threadId),
decision: decision.value,
),

View file

@ -29,11 +29,21 @@ enum FeedbackTargetKind() {
card,
note,
step,
/// A card proposed new, within a shared space (Halkyon's ADR 0044, D48):
/// the target's item is the studyset it goes into.
newCard,
/// A note proposed new, as [newCard].
newNote;
/// Whether this proposes something new rather than a change.
bool get isNew => this == newCard || this == newNote;
}
/// 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].
/// [version]; within a shared space, the item itself, version 0.
final class const FeedbackTarget({
required final FeedbackTargetKind kind,
required final String item,
@ -69,7 +79,9 @@ final class const FeedbackMessage({
/// 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.
/// author hid it. Or one within the shared [space] (Halkyon's ADR 0044,
/// D46, D47), on its studyset [studysetId], for its members; there the
/// "author" is whoever decides on it, its Owner and Editors.
final class const FeedbackThread({
required final String threadId,
required final String publicId,
@ -100,6 +112,13 @@ final class const FeedbackThread({
/// Whether something in it is new to the person.
final bool unseen = false,
/// The shared space it is in; null on a published studyset.
final String? space,
/// The studyset of the shared space it is about; null on a published
/// studyset.
final String? studysetId,
}) with Equatable {
/// This thread read by the person, as the instance will have it once it
/// has recorded so.
@ -136,6 +155,8 @@ final class const FeedbackThread({
mine: mine,
forMe: forMe,
unseen: unseen ?? this.unseen,
space: space,
studysetId: studysetId,
);
@override
@ -158,6 +179,8 @@ final class const FeedbackThread({
mine,
forMe,
unseen,
space,
studysetId,
];
}

View file

@ -1,7 +1,10 @@
import 'dart:developer';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
@ -82,6 +85,107 @@ Future<void> suggestToAuthor(
say(l10n.suggestSent);
}
/// Asks why a Viewer or an agent suggests a change within a shared
/// studyset (Halkyon's ADR 0044, D44), then suggests it through [send];
/// says how it went. Whether it went.
Future<bool> suggestInSpace(
BuildContext context, {
required Future<void> Function(FeedbackRepository feedback, String body) send,
}) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final feedback = PersonDependencies.of(context).feedback;
final body = await showFeedbackMessageSheet(
context,
title: l10n.suggestInSpace,
intro: l10n.suggestInSpaceIntro,
hint: l10n.suggestHint,
);
if (body == null) {
return false;
}
try {
await send(feedback, body);
} on Object catch (error) {
log('Suggesting failed', name: 'Feedback', error: error);
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.feedbackFailed)));
return false;
}
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.suggestSent)));
return true;
}
/// Above what a Viewer opens in a shared studyset (Halkyon's ADR 0044,
/// D35, D44): that it is theirs to view and study, with Suggest a change;
/// while [suggesting], that nothing changes until it is taken in, with
/// Cancel and Send suggestion.
class const SuggestBar({
required final bool suggesting,
required final VoidCallback onStart,
required final VoidCallback onCancel,
required final VoidCallback onSend,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (!suggesting) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const ViewOnlyNote(),
Align(
alignment: AlignmentDirectional.centerStart,
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.pencilLine, size: 18),
label: Text(l10n.suggestInSpace),
onPressed: onStart,
),
),
const SizedBox(height: 12),
],
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.suggestingNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
icon: const Icon(LucideIcons.send, size: 18),
label: Text(l10n.suggestSend),
onPressed: onSend,
),
TextButton(
onPressed: onCancel,
child: Text(
MaterialLocalizations.of(context).cancelButtonLabel,
),
),
],
),
],
),
);
}
}
/// Asks for a message: [title] on top, [intro] above the field, [hint] in
/// it; completes with what was written (perhaps empty), or null when the
/// person closes the sheet.

View file

@ -33,6 +33,28 @@ Future<void> showStudysetFeedback(
),
);
/// Shows the suggestions and reports within the shared space of the
/// studyset [studysetId] (Halkyon's ADR 0044, D47), to its members: each
/// opens with its replies, and its Owner and Editors decide on them.
Future<void> showSpaceFeedback(
BuildContext context, {
required String studysetId,
required String title,
}) => showAdaptiveSheet<void>(
context,
builder: (sheetContext) => BlocProvider(
create: (context) => FeedbackListBloc(
PersonDependencies.of(context).feedback,
studysetId: studysetId,
)..add(const FeedbackListEventLoaded()),
child: SheetScaffold(
title: title,
hasSnackBars: true,
body: const FeedbackThreadList(),
),
),
);
/// The threads of the [FeedbackListBloc] above: a spinner, a way to try
/// again, a word that there are none, or each with what it is, on what,
/// by whom and where it stands, opening it. Says what actions came to.
@ -311,7 +333,10 @@ class _ThreadSheetState() extends State<_ThreadSheet> {
message.by.name,
_date(context, message.createdAt),
),
if (message.fromAuthor) l10n.feedbackAuthor,
if (message.fromAuthor && thread.space == null)
l10n.feedbackAuthor
else if (message.fromAuthor)
l10n.feedbackDecider,
].join(' · '),
style: muted,
),
@ -366,7 +391,9 @@ class _ThreadSheetState() extends State<_ThreadSheet> {
thread.hidden ? l10n.feedbackShow : l10n.feedbackHide,
),
),
if (!thread.mine)
// Within a shared space, its Owner removes a member
// rather than blocking them.
if (!thread.mine && thread.space == null)
TextButton.icon(
icon: Icon(
LucideIcons.ban,
@ -486,6 +513,8 @@ String _targetName(AppLocalizations l10n, FeedbackTargetKind kind) =>
FeedbackTargetKind.card => l10n.feedbackOnCard,
FeedbackTargetKind.note => l10n.feedbackOnNote,
FeedbackTargetKind.step => l10n.feedbackOnStep,
FeedbackTargetKind.newCard => l10n.feedbackOnNewCard,
FeedbackTargetKind.newNote => l10n.feedbackOnNewNote,
};
/// Where [thread] stands, unless open and shown.

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
@ -130,20 +132,64 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
}
/// The note itself: the versions to keep while devices disagree, then one
/// card of the title, the tags, a line and the text.
/// card of the title, the tags, a line and the text. A Viewer reads it,
/// or changes it as a suggestion (Halkyon's ADR 0044, D44).
class const _Note({
required final NoteEditorState state,
required final TextEditingController title,
}) extends StatelessWidget {
}) extends StatefulWidget {
@override
State<_Note> createState() => _NoteState();
}
class _NoteState() extends State<_Note> {
/// Whether a Viewer is suggesting a change, and the note as they would
/// have it, which goes nowhere until sent.
var _suggesting = false;
NoteContent? _suggested;
/// Shows the editor anew as suggesting starts or stops.
var _round = 0;
NoteEditorState get state => widget.state;
TextEditingController get title => widget.title;
void _stopSuggesting() => setState(() {
_suggesting = false;
_suggested = null;
title.text = state.content.title;
_round++;
});
Future<void> _sendSuggestion() async {
final content = _suggested ?? state.content;
final studysetId =
GoRouterState.of(context).pathParameters['studysetId'] ?? '';
if (await suggestInSpace(
context,
send: (feedback, body) => feedback.suggestNoteInSpace(
content.normalized,
studysetId: studysetId,
noteId: state.noteId,
body: body,
),
)) {
if (mounted) {
_stopSuggesting();
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final conflicted = state.unresolved.isNotEmpty;
// A Viewer reads it as a conflict leaves it, unchanged (Halkyon's ADR
// 0044, D35).
// 0044, D35), unless suggesting a change.
final viewOnly = StudysetAccess.of(context).viewOnly;
final readOnly = conflicted || viewOnly;
final readOnly = conflicted || (viewOnly && !_suggesting);
final shown = _suggested ?? state.content;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Align(
@ -156,7 +202,16 @@ class const _Note({
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (conflicted) _Conflicts(state: state),
if (viewOnly) const ViewOnlyNote(),
if (viewOnly)
SuggestBar(
suggesting: _suggesting,
onStart: () => setState(() {
_suggesting = true;
_round++;
}),
onCancel: _stopSuggesting,
onSend: () => unawaited(_sendSuggestion()),
),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
@ -176,9 +231,15 @@ class const _Note({
border: InputBorder.none,
),
textInputAction: TextInputAction.next,
onChanged: (text) => context
.read<NoteEditorBloc>()
.add(NoteEditorEventTitleChanged(text)),
onChanged: (text) => _suggesting
? _suggested = (_suggested ?? state.content)
.withField(
NoteField.title,
text,
)
: context.read<NoteEditorBloc>().add(
NoteEditorEventTitleChanged(text),
),
),
),
if (state.noteId != null) _Tags(state: state),
@ -192,18 +253,31 @@ class const _Note({
content: state.content.body,
),
)
: LearnContentEditor(
// Shown anew whenever the text is replaced
// from outside: the editor reads its
// content once.
key: ValueKey(state.revision),
initialContent: state.content.body,
onChanged: (body) => context
.read<NoteEditorBloc>()
.add(NoteEditorEventBodyChanged(body)),
autofocus: state.noteId == null,
formatBar: true,
grammar: GrammarLanguage.recognised,
// A Viewer suggesting writes as an editor
// would, into the suggestion alone.
: StudysetAccess(
shared: StudysetAccess.of(context).shared,
viewOnly: false,
child: LearnContentEditor(
// Shown anew whenever the text is
// replaced from outside: the editor
// reads its content once.
key: ValueKey((state.revision, _round)),
initialContent: shown.body,
onChanged: (body) => _suggesting
? _suggested =
(_suggested ?? state.content)
.withField(
NoteField.body,
body,
)
: context.read<NoteEditorBloc>().add(
NoteEditorEventBodyChanged(body),
),
autofocus: state.noteId == null,
formatBar: true,
grammar: GrammarLanguage.recognised,
),
),
),
),

View file

@ -41,6 +41,10 @@ enum StudysetOption() {
reportToAuthor,
feedback,
discussions,
/// The suggestions and reports within a shared studyset (Halkyon's ADR
/// 0044, D47).
suggestions,
}
/// An option as its menus show it: what it is, its icon and its name, and
@ -149,6 +153,12 @@ List<StudysetOptionEntry> studysetOptions(
// Making it private or not is in Edit, with what it means one tap
// away, not a shortcut here (the developer's review of 3 Nov 2026).
entry(StudysetOption.info, LucideIcons.info, l10n.moreInfo),
if (studyset.isShared)
entry(
StudysetOption.suggestions,
LucideIcons.pencilLine,
l10n.studysetSuggestions,
),
if (fromRelease(studyset.content.published)) ...[
entry(
StudysetOption.reportToAuthor,
@ -259,6 +269,12 @@ Future<void> doStudysetOption(
title: l10n.studysetFeedback,
);
}
case StudysetOption.suggestions:
await showSpaceFeedback(
context,
studysetId: studyset.id,
title: l10n.studysetSuggestions,
);
case StudysetOption.discussions:
if (studyset.content.published case final origin?) {
// Those within this topic of the release (the whole studyset for