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:
parent
7aee78bd87
commit
82c777b1a5
48 changed files with 2949 additions and 427 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue