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
48 changed files with 2949 additions and 427 deletions

View file

@ -672,6 +672,18 @@ class EndpointFeedback extends _isc.EndpointRef {
'threadsOf',
{'publicId': publicId},
);
/// The suggestions and reports within the shared space [space] (Halkyon's
/// ADR 0044, D46, D47), for its members alone, the most recently active
/// first. Throws [NotFoundException] for anyone else, as for a space that
/// doesn't exist.
_ida.Future<List<_idpwh21d.FeedbackView>> threadsInSpace(
_isc.UuidValue space,
) => caller.callServerEndpoint<List<_idpwh21d.FeedbackView>>(
'feedback',
'threadsInSpace',
{'space': space},
);
}
/// The person's inbox of reports and suggestions: those on their published
@ -686,7 +698,9 @@ class EndpointInbox extends _isc.EndpointRef {
String get name => 'inbox';
/// The threads on the caller's published studysets and those they wrote,
/// the most recently active first.
/// and within the shared spaces they decide in, as an Owner or Editor,
/// and those they wrote there (Halkyon's ADR 0044, D46), the most
/// recently active first.
_ida.Future<List<_idpwh21d.FeedbackView>> inbox() =>
caller.callServerEndpoint<List<_idpwh21d.FeedbackView>>(
'inbox',

View file

@ -16,7 +16,8 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
/// What a published studyset's author decided about a thread of reports and
/// suggestions (FeedbackThread): 'closed', 'reopened', 'hidden', 'shown',
/// 'accepted' or 'declined' (a suggestion). Written in the author's own
/// space, and only the author's count; the latest of each pair (by its id,
/// space, and only the author's count; in a shared space, written in that
/// space, where only its Owner and Editors write (Halkyon's ADR 0044, D46); the latest of each pair (by its id,
/// a UUID v7) stands. Never changed. Synced.
abstract class FeedbackDecision
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {

View file

@ -72,13 +72,15 @@ abstract class FeedbackMessage
/// The thread replied in.
_isc.UuidValue threadId;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
String? keyId;
/// Its content: UTF-8 JSON with a `schemaVersion` and its text.
_idt.ByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
_idt.ByteData? nonce;
@override
@ -259,13 +261,15 @@ class FeedbackMessageTable extends _isd.Table<_isc.UuidValue?> {
/// The thread replied in.
late final _isd.ColumnUuid threadId;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
late final _isd.ColumnString keyId;
/// Its content: UTF-8 JSON with a `schemaVersion` and its text.
late final _isd.ColumnByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
late final _isd.ColumnByteData nonce;
@override

View file

@ -22,6 +22,8 @@ abstract class FeedbackMessageView
required this.by,
required this.fromAuthor,
required this.payload,
this.keyId,
this.nonce,
required this.createdAt,
});
@ -30,6 +32,8 @@ abstract class FeedbackMessageView
required _i4q3oto5.FeedbackPerson by,
required bool fromAuthor,
required String payload,
String? keyId,
String? nonce,
required DateTime createdAt,
}) = _FeedbackMessageViewImpl;
@ -43,6 +47,8 @@ abstract class FeedbackMessageView
jsonSerialization['fromAuthor'],
),
payload: jsonSerialization['payload'] as String,
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] as String?,
createdAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -51,14 +57,21 @@ abstract class FeedbackMessageView
_isc.UuidValue id;
/// Who wrote it, and whether that is the published studyset's author.
/// Who wrote it, and whether that is the published studyset's author
/// (in a shared space, its Owner or an Editor).
_i4q3oto5.FeedbackPerson by;
bool fromAuthor;
/// What it says: its payload as written.
/// What it says: its payload as written; sealed (keyId), in base64.
String payload;
/// The key a reply in a private shared studyset is sealed with, and its
/// nonce in base64; null while readable.
String? keyId;
String? nonce;
/// When, in UTC.
DateTime createdAt;
@ -70,6 +83,8 @@ abstract class FeedbackMessageView
_i4q3oto5.FeedbackPerson? by,
bool? fromAuthor,
String? payload,
String? keyId,
String? nonce,
DateTime? createdAt,
});
@override
@ -80,6 +95,8 @@ abstract class FeedbackMessageView
'by': by.toJson(),
'fromAuthor': fromAuthor,
'payload': payload,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
};
}
@ -92,6 +109,8 @@ abstract class FeedbackMessageView
'by': by.toJsonForProtocol(),
'fromAuthor': fromAuthor,
'payload': payload,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
};
}
@ -102,18 +121,24 @@ abstract class FeedbackMessageView
}
}
class _Undefined {}
class _FeedbackMessageViewImpl extends FeedbackMessageView {
_FeedbackMessageViewImpl({
required _isc.UuidValue id,
required _i4q3oto5.FeedbackPerson by,
required bool fromAuthor,
required String payload,
String? keyId,
String? nonce,
required DateTime createdAt,
}) : super._(
id: id,
by: by,
fromAuthor: fromAuthor,
payload: payload,
keyId: keyId,
nonce: nonce,
createdAt: createdAt,
);
@ -126,6 +151,8 @@ class _FeedbackMessageViewImpl extends FeedbackMessageView {
_i4q3oto5.FeedbackPerson? by,
bool? fromAuthor,
String? payload,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
DateTime? createdAt,
}) {
return FeedbackMessageView(
@ -133,6 +160,8 @@ class _FeedbackMessageViewImpl extends FeedbackMessageView {
by: by ?? this.by.copyWith(),
fromAuthor: fromAuthor ?? this.fromAuthor,
payload: payload ?? this.payload,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is String? ? nonce : this.nonce,
createdAt: createdAt ?? this.createdAt,
);
}

View file

@ -75,19 +75,24 @@ abstract class FeedbackThread
int? spaceId;
/// The published studyset it is on (its public id): a column, for the
/// server to gather by.
/// server to gather by; for a thread in a shared space, the space's id
/// (Halkyon's ADR 0044, D46).
_isc.UuidValue publicationId;
/// 'report' or 'suggestion'; a kind this app doesn't know is skipped.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space, sealed with its studyset's key where that is private.
/// A kind an app doesn't know is skipped.
String kind;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
String? keyId;
/// Its content: UTF-8 JSON with a `schemaVersion`.
_idt.ByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
_idt.ByteData? nonce;
@override
@ -281,19 +286,24 @@ class FeedbackThreadTable extends _isd.Table<_isc.UuidValue?> {
late final _isd.ColumnInt spaceId;
/// The published studyset it is on (its public id): a column, for the
/// server to gather by.
/// server to gather by; for a thread in a shared space, the space's id
/// (Halkyon's ADR 0044, D46).
late final _isd.ColumnUuid publicationId;
/// 'report' or 'suggestion'; a kind this app doesn't know is skipped.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space, sealed with its studyset's key where that is private.
/// A kind an app doesn't know is skipped.
late final _isd.ColumnString kind;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
late final _isd.ColumnString keyId;
/// Its content: UTF-8 JSON with a `schemaVersion`.
late final _isd.ColumnByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
late final _isd.ColumnByteData nonce;
@override

View file

@ -16,8 +16,9 @@ import '../feedback/feedback_message_view.dart' as _irmjyw1o;
import '../feedback/feedback_person.dart' as _i4q3oto5;
/// A thread of reports and suggestions as the app shows it: on which
/// published studyset, by whom, what it says (its payload's JSON), its
/// replies, and where it stands.
/// published studyset, or in which shared space (Halkyon's ADR 0044, D46),
/// by whom, what it says (its payload's JSON, or sealed), its replies, and
/// where it stands.
abstract class FeedbackView
implements _isc.SerializableModel, _isc.ProtocolSerialization {
FeedbackView._({
@ -28,6 +29,9 @@ abstract class FeedbackView
required this.kind,
required this.writer,
required this.payload,
this.space,
this.keyId,
this.nonce,
required this.createdAt,
required this.messages,
required this.status,
@ -45,6 +49,9 @@ abstract class FeedbackView
required String kind,
required _i4q3oto5.FeedbackPerson writer,
required String payload,
_isc.UuidValue? space,
String? keyId,
String? nonce,
required DateTime createdAt,
required List<_irmjyw1o.FeedbackMessageView> messages,
required String status,
@ -69,6 +76,11 @@ abstract class FeedbackView
jsonSerialization['writer'],
),
payload: jsonSerialization['payload'] as String,
space: jsonSerialization['space'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['space']),
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] as String?,
createdAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -86,22 +98,33 @@ abstract class FeedbackView
_isc.UuidValue threadId;
/// The published studyset: its public id, title and location.
/// The published studyset: its public id, title and location; in a
/// shared space, the space's id, and neither title nor location.
_isc.UuidValue publicId;
String studysetTitle;
String location;
/// 'report' or 'suggestion'.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space.
String kind;
/// Who wrote it.
_i4q3oto5.FeedbackPerson writer;
/// What it says: its payload as written.
/// What it says: its payload as written; sealed (keyId), in base64.
String payload;
/// The shared space it is in (D46); null on a published studyset.
_isc.UuidValue? space;
/// The key a thread in a private shared studyset is sealed with, and
/// its nonce in base64; null while readable.
String? keyId;
String? nonce;
/// When it was written, in UTC.
DateTime createdAt;
@ -114,7 +137,8 @@ abstract class FeedbackView
/// Whether the author hid it: then only its writer and the author see it.
bool hidden;
/// Whether the caller wrote it; whether the caller is the author.
/// Whether the caller wrote it; whether the caller is the author (in a
/// shared space, its Owner or an Editor, who decide on it).
bool mine;
bool forMe;
@ -133,6 +157,9 @@ abstract class FeedbackView
String? kind,
_i4q3oto5.FeedbackPerson? writer,
String? payload,
_isc.UuidValue? space,
String? keyId,
String? nonce,
DateTime? createdAt,
List<_irmjyw1o.FeedbackMessageView>? messages,
String? status,
@ -152,6 +179,9 @@ abstract class FeedbackView
'kind': kind,
'writer': writer.toJson(),
'payload': payload,
if (space != null) 'space': space?.toJson(),
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
'messages': messages.toJson(valueToJson: (v) => v.toJson()),
'status': status,
@ -173,6 +203,9 @@ abstract class FeedbackView
'kind': kind,
'writer': writer.toJsonForProtocol(),
'payload': payload,
if (space != null) 'space': space?.toJson(),
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
'messages': messages.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'status': status,
@ -189,6 +222,8 @@ abstract class FeedbackView
}
}
class _Undefined {}
class _FeedbackViewImpl extends FeedbackView {
_FeedbackViewImpl({
required _isc.UuidValue threadId,
@ -198,6 +233,9 @@ class _FeedbackViewImpl extends FeedbackView {
required String kind,
required _i4q3oto5.FeedbackPerson writer,
required String payload,
_isc.UuidValue? space,
String? keyId,
String? nonce,
required DateTime createdAt,
required List<_irmjyw1o.FeedbackMessageView> messages,
required String status,
@ -213,6 +251,9 @@ class _FeedbackViewImpl extends FeedbackView {
kind: kind,
writer: writer,
payload: payload,
space: space,
keyId: keyId,
nonce: nonce,
createdAt: createdAt,
messages: messages,
status: status,
@ -234,6 +275,9 @@ class _FeedbackViewImpl extends FeedbackView {
String? kind,
_i4q3oto5.FeedbackPerson? writer,
String? payload,
Object? space = _Undefined,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
DateTime? createdAt,
List<_irmjyw1o.FeedbackMessageView>? messages,
String? status,
@ -250,6 +294,9 @@ class _FeedbackViewImpl extends FeedbackView {
kind: kind ?? this.kind,
writer: writer ?? this.writer.copyWith(),
payload: payload ?? this.payload,
space: space is _isc.UuidValue? ? space : this.space,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is String? ? nonce : this.nonce,
createdAt: createdAt ?? this.createdAt,
messages: messages ?? this.messages.map((e0) => e0.copyWith()).toList(),
status: status ?? this.status,

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,

Some files were not shown because too many files have changed in this diff Show more