feat(discussions): questions on published studysets, answered by learners

A learner asks about a published studyset, a topic in it, or one card,
note or step of a release, and others answer, as on Stack Exchange
(ADR 0013): questions and answers in the app's editor without images,
sounds or videos, comments of up to 600 characters under the question
and each answer. There are no votes: the asker accepts an answer, which
comes first, then the oldest; the author's posts are marked. The
instance reads from git's file list which topics a question's target
sits in, so a topic lists what is asked within it and below it.

Posts live in two server-only tables, written through endpoints, so the
synced tables stay as they are and v0.17.0 keeps syncing. Everyone
signed in reads; writing needs an identity and a public profile; the
studyset's author hides posts and blocks people with feedback's blocks;
a withdrawn studyset takes no new questions; anyone writes at most 30
posts an hour. A post with others' replies under it is emptied when
deleted, else deleted; deletePerson deletes what a person wrote the same
way, and a published studyset's deletion takes its discussions with it.

The migration adds Serverpod's gen_random_uuid_v7(), discussion_post
and discussion_seen with their indexes and cascading foreign keys, and
changes nothing else.

In the app, discussions open from a copy's studyset or topic menu, a
studied card's "Ask about this", Explore's summary and the Publish
sheet; the Profile tab's inbox lists those the person follows, and its
count includes what is new in them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-15 23:52:40 +02:00
commit 5efa552489
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
37 changed files with 28214 additions and 27 deletions

View file

@ -1749,11 +1749,78 @@
"feedbackUnreachable": "Meldungen und Vorschläge sind gerade nicht erreichbar.",
"feedbackRetry": "Erneut versuchen",
"inboxTitle": "Posteingang",
"inboxIntro": "Meldungen und Vorschläge zu deinen veröffentlichten Lernsets und die, die du geschrieben hast.",
"inboxIntro": "Fragen und Antworten, Meldungen und Vorschläge zu deinen veröffentlichten Lernsets und die, in denen du geschrieben hast.",
"inboxEmpty": "Noch nichts.",
"studysetFeedback": "Meldungen und Vorschläge",
"studysetFeedbackEmpty": "Noch hat niemand etwas gemeldet oder vorgeschlagen.",
"reportToAuthor": "Fehler im Original melden",
"discussionsTitle": "Diskussionen",
"discussionsSearch": "Fragen durchsuchen",
"discussionsAsk": "Frage stellen",
"discussionsEmpty": "Noch keine Fragen",
"discussionsEmptyHint": "Stell die erste: zum Lernset, zu einem Thema oder zu einer seiner Karteikarten.",
"discussionsNoMatch": "Kein Fragentitel sagt das.",
"discussionsMore": "Mehr zeigen",
"discussionsUnreachable": "Die Diskussionen sind gerade nicht erreichbar.",
"discussionsAnswers": "{count, plural, =0{Keine Antworten} =1{1 Antwort} other{{count} Antworten}}",
"@discussionsAnswers": {"placeholders": {"count": {"type": "int"}}},
"discussionsBy": "{name} · {date}",
"@discussionsBy": {"placeholders": {"name": {"type": "String"}, "date": {"type": "String"}}},
"discussionsSomeone": "Jemand",
"discussionsDeletedQuestion": "Gelöschte Frage",
"discussionsHidden": "Ausgeblendet",
"discussionsAboutCard": "Zu einer Karteikarte",
"discussionsAboutNote": "Zu einer Notiz",
"discussionsAboutStep": "Zu einem Schritt",
"discussionsAccepted": "Angenommen",
"discussionsOn": "Zu {title}",
"@discussionsOn": {"placeholders": {"title": {"type": "String"}}},
"discussionRefusalNoIdentity": "Um zu fragen und zu antworten, melde dich im Profil mit einer Identität an: Beiträge zeigen, wer sie geschrieben hat.",
"discussionRefusalNoProfile": "Um zu fragen und zu antworten, leg im Profil ein öffentliches Profil an: Beiträge zeigen, wer sie geschrieben hat.",
"discussionRefusalBlocked": "Wer dieses Lernset veröffentlicht hat, lässt dich zu seinen Lernsets nichts schreiben.",
"discussionRefusalWithdrawn": "Dieses Lernset wurde aus Entdecken genommen: Was hier steht, bleibt, aber Neues wird nicht gefragt.",
"discussionRefusalNotInRelease": "Das ist nicht mehr in der Veröffentlichung des Lernsets.",
"discussionRefusalEmpty": "Schreib zuerst etwas.",
"discussionRefusalTooLong": "Das ist länger als erlaubt.",
"discussionRefusalTooMany": "Du hast in der letzten Stunde viel geschrieben. Versuch es etwas später noch einmal.",
"discussionRefusalWithFiles": "Bilder und Töne lassen sich nicht posten: Deine Dateien gehören dir allein, also sähe sie niemand sonst.",
"discussionFailed": "Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.",
"discussionTitle": "Frage",
"discussionMissing": "Diese Frage gibt es nicht mehr.",
"discussionDeleted": "Das wurde gelöscht.",
"discussionEdited": "bearbeitet",
"discussionWriteAnswer": "Antwort schreiben",
"discussionYourAnswer": "Deine Antwort",
"discussionEditHeading": "Bearbeiten",
"discussionTitleField": "Titel",
"discussionTitleHint": "Was möchtest du wissen?",
"discussionPost": "Posten",
"discussionComment": "Kommentar hinzufügen",
"discussionCommentHint": "Eine kurze Bemerkung",
"discussionAccept": "Annehmen",
"discussionUnaccept": "Zurücknehmen",
"discussionMenu": "Mehr",
"discussionMenuEdit": "Bearbeiten",
"discussionMenuDelete": "Löschen",
"discussionMenuHide": "Ausblenden",
"discussionMenuShow": "Wieder zeigen",
"discussionMenuBlock": "{name} blockieren",
"@discussionMenuBlock": {"placeholders": {"name": {"type": "String"}}},
"discussionDeleteTitle": "Das löschen?",
"discussionDeleteBody": "Was andere darunter geantwortet haben, bleibt, ohne deinen Text und Namen.",
"discussionAnswered": "Antwort gepostet.",
"discussionCommented": "Kommentar gepostet.",
"discussionSaved": "Gespeichert.",
"discussionDeletedDone": "Gelöscht.",
"discussionAcceptedDone": "Antwort angenommen.",
"discussionUnacceptedDone": "Jetzt ist keine Antwort angenommen.",
"discussionHiddenDone": "Ausgeblendet: Nur wer es geschrieben hat und du sehen es.",
"discussionShownDone": "Wieder sichtbar.",
"discussionBlockedDone": "Blockiert: Was diese Person zu deinen Lernsets schreibt, sieht nur sie selbst.",
"discussionAskAbout": "Dazu fragen",
"inboxDiscussions": "Diskussionen",
"inboxFeedback": "Meldungen und Vorschläge",
"inboxDiscussionsEmpty": "Noch keine Diskussionen.",
"cardReportToAuthor": "Auch im Original melden",
"cardReportToAuthorHint": "Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.",
"feedbackReportSent": "Im Original gemeldet.",

View file

@ -2051,11 +2051,78 @@
"feedbackUnreachable": "Reports and suggestions can't be reached right now.",
"feedbackRetry": "Retry",
"inboxTitle": "Inbox",
"inboxIntro": "Reports and suggestions on your published studysets, and those you wrote.",
"inboxIntro": "Questions and answers, reports and suggestions on your published studysets, and those you wrote in.",
"inboxEmpty": "Nothing yet.",
"studysetFeedback": "Reports and suggestions",
"studysetFeedbackEmpty": "Nobody has reported or suggested anything yet.",
"reportToAuthor": "Report to the author",
"discussionsTitle": "Discussions",
"discussionsSearch": "Search questions",
"discussionsAsk": "Ask a question",
"discussionsEmpty": "No questions yet",
"discussionsEmptyHint": "Ask the first: about the studyset, a topic or one of its flashcards.",
"discussionsNoMatch": "No question's title says that.",
"discussionsMore": "Show more",
"discussionsUnreachable": "The discussions can't be reached right now.",
"discussionsAnswers": "{count, plural, =0{No answers} =1{1 answer} other{{count} answers}}",
"@discussionsAnswers": {"placeholders": {"count": {"type": "int"}}},
"discussionsBy": "{name} · {date}",
"@discussionsBy": {"placeholders": {"name": {"type": "String"}, "date": {"type": "String"}}},
"discussionsSomeone": "Someone",
"discussionsDeletedQuestion": "Deleted question",
"discussionsHidden": "Hidden",
"discussionsAboutCard": "About a flashcard",
"discussionsAboutNote": "About a note",
"discussionsAboutStep": "About a step",
"discussionsAccepted": "Accepted",
"discussionsOn": "On {title}",
"@discussionsOn": {"placeholders": {"title": {"type": "String"}}},
"discussionRefusalNoIdentity": "To ask and answer, sign in with an identity in Profile: posts show who wrote them.",
"discussionRefusalNoProfile": "To ask and answer, make a public profile in Profile: posts show who wrote them.",
"discussionRefusalBlocked": "The author of this studyset keeps you from writing on their studysets.",
"discussionRefusalWithdrawn": "The author took this studyset off Explore: what is here stays, but nothing new is asked.",
"discussionRefusalNotInRelease": "That isn't in the studyset's release any more.",
"discussionRefusalEmpty": "Write something first.",
"discussionRefusalTooLong": "That's longer than allowed.",
"discussionRefusalTooMany": "You've written a lot in the last hour. Try again a little later.",
"discussionRefusalWithFiles": "Images and sounds can't be posted: your files are yours alone, so nobody else would see them.",
"discussionFailed": "Couldn't reach the instance. Try again once you are online.",
"discussionTitle": "Question",
"discussionMissing": "This question isn't there any more.",
"discussionDeleted": "This was deleted.",
"discussionEdited": "edited",
"discussionWriteAnswer": "Write an answer",
"discussionYourAnswer": "Your answer",
"discussionEditHeading": "Edit",
"discussionTitleField": "Title",
"discussionTitleHint": "What do you want to know?",
"discussionPost": "Post",
"discussionComment": "Add a comment",
"discussionCommentHint": "A short remark",
"discussionAccept": "Accept",
"discussionUnaccept": "Take back",
"discussionMenu": "More",
"discussionMenuEdit": "Edit",
"discussionMenuDelete": "Delete",
"discussionMenuHide": "Hide",
"discussionMenuShow": "Show again",
"discussionMenuBlock": "Block {name}",
"@discussionMenuBlock": {"placeholders": {"name": {"type": "String"}}},
"discussionDeleteTitle": "Delete this?",
"discussionDeleteBody": "What others replied under it stays, without your text and name.",
"discussionAnswered": "Answer posted.",
"discussionCommented": "Comment posted.",
"discussionSaved": "Saved.",
"discussionDeletedDone": "Deleted.",
"discussionAcceptedDone": "Answer accepted.",
"discussionUnacceptedDone": "No answer accepted now.",
"discussionHiddenDone": "Hidden: only its writer and you see it.",
"discussionShownDone": "Shown again.",
"discussionBlockedDone": "Blocked: what they write on your studysets shows to them alone.",
"discussionAskAbout": "Ask about this",
"inboxDiscussions": "Discussions",
"inboxFeedback": "Reports and suggestions",
"inboxDiscussionsEmpty": "No discussions yet.",
"cardReportToAuthor": "Send to the author too",
"cardReportToAuthorHint": "Public: everyone learning it reads it, with your public profile.",
"feedbackReportSent": "Sent to the author.",

View file

@ -10362,7 +10362,7 @@ abstract class AppLocalizations {
/// No description provided for @inboxIntro.
///
/// In en, this message translates to:
/// **'Reports and suggestions on your published studysets, and those you wrote.'**
/// **'Questions and answers, reports and suggestions on your published studysets, and those you wrote in.'**
String get inboxIntro;
/// No description provided for @inboxEmpty.
@ -10389,6 +10389,384 @@ abstract class AppLocalizations {
/// **'Report to the author'**
String get reportToAuthor;
/// No description provided for @discussionsTitle.
///
/// In en, this message translates to:
/// **'Discussions'**
String get discussionsTitle;
/// No description provided for @discussionsSearch.
///
/// In en, this message translates to:
/// **'Search questions'**
String get discussionsSearch;
/// No description provided for @discussionsAsk.
///
/// In en, this message translates to:
/// **'Ask a question'**
String get discussionsAsk;
/// No description provided for @discussionsEmpty.
///
/// In en, this message translates to:
/// **'No questions yet'**
String get discussionsEmpty;
/// No description provided for @discussionsEmptyHint.
///
/// In en, this message translates to:
/// **'Ask the first: about the studyset, a topic or one of its flashcards.'**
String get discussionsEmptyHint;
/// No description provided for @discussionsNoMatch.
///
/// In en, this message translates to:
/// **'No question\'s title says that.'**
String get discussionsNoMatch;
/// No description provided for @discussionsMore.
///
/// In en, this message translates to:
/// **'Show more'**
String get discussionsMore;
/// No description provided for @discussionsUnreachable.
///
/// In en, this message translates to:
/// **'The discussions can\'t be reached right now.'**
String get discussionsUnreachable;
/// No description provided for @discussionsAnswers.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No answers} =1{1 answer} other{{count} answers}}'**
String discussionsAnswers(int count);
/// No description provided for @discussionsBy.
///
/// In en, this message translates to:
/// **'{name} · {date}'**
String discussionsBy(String name, String date);
/// No description provided for @discussionsSomeone.
///
/// In en, this message translates to:
/// **'Someone'**
String get discussionsSomeone;
/// No description provided for @discussionsDeletedQuestion.
///
/// In en, this message translates to:
/// **'Deleted question'**
String get discussionsDeletedQuestion;
/// No description provided for @discussionsHidden.
///
/// In en, this message translates to:
/// **'Hidden'**
String get discussionsHidden;
/// No description provided for @discussionsAboutCard.
///
/// In en, this message translates to:
/// **'About a flashcard'**
String get discussionsAboutCard;
/// No description provided for @discussionsAboutNote.
///
/// In en, this message translates to:
/// **'About a note'**
String get discussionsAboutNote;
/// No description provided for @discussionsAboutStep.
///
/// In en, this message translates to:
/// **'About a step'**
String get discussionsAboutStep;
/// No description provided for @discussionsAccepted.
///
/// In en, this message translates to:
/// **'Accepted'**
String get discussionsAccepted;
/// No description provided for @discussionsOn.
///
/// In en, this message translates to:
/// **'On {title}'**
String discussionsOn(String title);
/// No description provided for @discussionRefusalNoIdentity.
///
/// In en, this message translates to:
/// **'To ask and answer, sign in with an identity in Profile: posts show who wrote them.'**
String get discussionRefusalNoIdentity;
/// No description provided for @discussionRefusalNoProfile.
///
/// In en, this message translates to:
/// **'To ask and answer, make a public profile in Profile: posts show who wrote them.'**
String get discussionRefusalNoProfile;
/// No description provided for @discussionRefusalBlocked.
///
/// In en, this message translates to:
/// **'The author of this studyset keeps you from writing on their studysets.'**
String get discussionRefusalBlocked;
/// No description provided for @discussionRefusalWithdrawn.
///
/// In en, this message translates to:
/// **'The author took this studyset off Explore: what is here stays, but nothing new is asked.'**
String get discussionRefusalWithdrawn;
/// No description provided for @discussionRefusalNotInRelease.
///
/// In en, this message translates to:
/// **'That isn\'t in the studyset\'s release any more.'**
String get discussionRefusalNotInRelease;
/// No description provided for @discussionRefusalEmpty.
///
/// In en, this message translates to:
/// **'Write something first.'**
String get discussionRefusalEmpty;
/// No description provided for @discussionRefusalTooLong.
///
/// In en, this message translates to:
/// **'That\'s longer than allowed.'**
String get discussionRefusalTooLong;
/// No description provided for @discussionRefusalTooMany.
///
/// In en, this message translates to:
/// **'You\'ve written a lot in the last hour. Try again a little later.'**
String get discussionRefusalTooMany;
/// No description provided for @discussionRefusalWithFiles.
///
/// In en, this message translates to:
/// **'Images and sounds can\'t be posted: your files are yours alone, so nobody else would see them.'**
String get discussionRefusalWithFiles;
/// No description provided for @discussionFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again once you are online.'**
String get discussionFailed;
/// No description provided for @discussionTitle.
///
/// In en, this message translates to:
/// **'Question'**
String get discussionTitle;
/// No description provided for @discussionMissing.
///
/// In en, this message translates to:
/// **'This question isn\'t there any more.'**
String get discussionMissing;
/// No description provided for @discussionDeleted.
///
/// In en, this message translates to:
/// **'This was deleted.'**
String get discussionDeleted;
/// No description provided for @discussionEdited.
///
/// In en, this message translates to:
/// **'edited'**
String get discussionEdited;
/// No description provided for @discussionWriteAnswer.
///
/// In en, this message translates to:
/// **'Write an answer'**
String get discussionWriteAnswer;
/// No description provided for @discussionYourAnswer.
///
/// In en, this message translates to:
/// **'Your answer'**
String get discussionYourAnswer;
/// No description provided for @discussionEditHeading.
///
/// In en, this message translates to:
/// **'Edit'**
String get discussionEditHeading;
/// No description provided for @discussionTitleField.
///
/// In en, this message translates to:
/// **'Title'**
String get discussionTitleField;
/// No description provided for @discussionTitleHint.
///
/// In en, this message translates to:
/// **'What do you want to know?'**
String get discussionTitleHint;
/// No description provided for @discussionPost.
///
/// In en, this message translates to:
/// **'Post'**
String get discussionPost;
/// No description provided for @discussionComment.
///
/// In en, this message translates to:
/// **'Add a comment'**
String get discussionComment;
/// No description provided for @discussionCommentHint.
///
/// In en, this message translates to:
/// **'A short remark'**
String get discussionCommentHint;
/// No description provided for @discussionAccept.
///
/// In en, this message translates to:
/// **'Accept'**
String get discussionAccept;
/// No description provided for @discussionUnaccept.
///
/// In en, this message translates to:
/// **'Take back'**
String get discussionUnaccept;
/// No description provided for @discussionMenu.
///
/// In en, this message translates to:
/// **'More'**
String get discussionMenu;
/// No description provided for @discussionMenuEdit.
///
/// In en, this message translates to:
/// **'Edit'**
String get discussionMenuEdit;
/// No description provided for @discussionMenuDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get discussionMenuDelete;
/// No description provided for @discussionMenuHide.
///
/// In en, this message translates to:
/// **'Hide'**
String get discussionMenuHide;
/// No description provided for @discussionMenuShow.
///
/// In en, this message translates to:
/// **'Show again'**
String get discussionMenuShow;
/// No description provided for @discussionMenuBlock.
///
/// In en, this message translates to:
/// **'Block {name}'**
String discussionMenuBlock(String name);
/// No description provided for @discussionDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete this?'**
String get discussionDeleteTitle;
/// No description provided for @discussionDeleteBody.
///
/// In en, this message translates to:
/// **'What others replied under it stays, without your text and name.'**
String get discussionDeleteBody;
/// No description provided for @discussionAnswered.
///
/// In en, this message translates to:
/// **'Answer posted.'**
String get discussionAnswered;
/// No description provided for @discussionCommented.
///
/// In en, this message translates to:
/// **'Comment posted.'**
String get discussionCommented;
/// No description provided for @discussionSaved.
///
/// In en, this message translates to:
/// **'Saved.'**
String get discussionSaved;
/// No description provided for @discussionDeletedDone.
///
/// In en, this message translates to:
/// **'Deleted.'**
String get discussionDeletedDone;
/// No description provided for @discussionAcceptedDone.
///
/// In en, this message translates to:
/// **'Answer accepted.'**
String get discussionAcceptedDone;
/// No description provided for @discussionUnacceptedDone.
///
/// In en, this message translates to:
/// **'No answer accepted now.'**
String get discussionUnacceptedDone;
/// No description provided for @discussionHiddenDone.
///
/// In en, this message translates to:
/// **'Hidden: only its writer and you see it.'**
String get discussionHiddenDone;
/// No description provided for @discussionShownDone.
///
/// In en, this message translates to:
/// **'Shown again.'**
String get discussionShownDone;
/// No description provided for @discussionBlockedDone.
///
/// In en, this message translates to:
/// **'Blocked: what they write on your studysets shows to them alone.'**
String get discussionBlockedDone;
/// No description provided for @discussionAskAbout.
///
/// In en, this message translates to:
/// **'Ask about this'**
String get discussionAskAbout;
/// No description provided for @inboxDiscussions.
///
/// In en, this message translates to:
/// **'Discussions'**
String get inboxDiscussions;
/// No description provided for @inboxFeedback.
///
/// In en, this message translates to:
/// **'Reports and suggestions'**
String get inboxFeedback;
/// No description provided for @inboxDiscussionsEmpty.
///
/// In en, this message translates to:
/// **'No discussions yet.'**
String get inboxDiscussionsEmpty;
/// No description provided for @cardReportToAuthor.
///
/// In en, this message translates to:

View file

@ -6518,7 +6518,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get inboxIntro =>
'Meldungen und Vorschläge zu deinen veröffentlichten Lernsets und die, die du geschrieben hast.';
'Fragen und Antworten, Meldungen und Vorschläge zu deinen veröffentlichten Lernsets und die, in denen du geschrieben hast.';
@override
String get inboxEmpty => 'Noch nichts.';
@ -6533,6 +6533,223 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get reportToAuthor => 'Fehler im Original melden';
@override
String get discussionsTitle => 'Diskussionen';
@override
String get discussionsSearch => 'Fragen durchsuchen';
@override
String get discussionsAsk => 'Frage stellen';
@override
String get discussionsEmpty => 'Noch keine Fragen';
@override
String get discussionsEmptyHint =>
'Stell die erste: zum Lernset, zu einem Thema oder zu einer seiner Karteikarten.';
@override
String get discussionsNoMatch => 'Kein Fragentitel sagt das.';
@override
String get discussionsMore => 'Mehr zeigen';
@override
String get discussionsUnreachable =>
'Die Diskussionen sind gerade nicht erreichbar.';
@override
String discussionsAnswers(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Antworten',
one: '1 Antwort',
zero: 'Keine Antworten',
);
return '$_temp0';
}
@override
String discussionsBy(String name, String date) {
return '$name · $date';
}
@override
String get discussionsSomeone => 'Jemand';
@override
String get discussionsDeletedQuestion => 'Gelöschte Frage';
@override
String get discussionsHidden => 'Ausgeblendet';
@override
String get discussionsAboutCard => 'Zu einer Karteikarte';
@override
String get discussionsAboutNote => 'Zu einer Notiz';
@override
String get discussionsAboutStep => 'Zu einem Schritt';
@override
String get discussionsAccepted => 'Angenommen';
@override
String discussionsOn(String title) {
return 'Zu $title';
}
@override
String get discussionRefusalNoIdentity =>
'Um zu fragen und zu antworten, melde dich im Profil mit einer Identität an: Beiträge zeigen, wer sie geschrieben hat.';
@override
String get discussionRefusalNoProfile =>
'Um zu fragen und zu antworten, leg im Profil ein öffentliches Profil an: Beiträge zeigen, wer sie geschrieben hat.';
@override
String get discussionRefusalBlocked =>
'Wer dieses Lernset veröffentlicht hat, lässt dich zu seinen Lernsets nichts schreiben.';
@override
String get discussionRefusalWithdrawn =>
'Dieses Lernset wurde aus Entdecken genommen: Was hier steht, bleibt, aber Neues wird nicht gefragt.';
@override
String get discussionRefusalNotInRelease =>
'Das ist nicht mehr in der Veröffentlichung des Lernsets.';
@override
String get discussionRefusalEmpty => 'Schreib zuerst etwas.';
@override
String get discussionRefusalTooLong => 'Das ist länger als erlaubt.';
@override
String get discussionRefusalTooMany =>
'Du hast in der letzten Stunde viel geschrieben. Versuch es etwas später noch einmal.';
@override
String get discussionRefusalWithFiles =>
'Bilder und Töne lassen sich nicht posten: Deine Dateien gehören dir allein, also sähe sie niemand sonst.';
@override
String get discussionFailed =>
'Die Instanz ist nicht erreichbar. Versuch es noch einmal, sobald du online bist.';
@override
String get discussionTitle => 'Frage';
@override
String get discussionMissing => 'Diese Frage gibt es nicht mehr.';
@override
String get discussionDeleted => 'Das wurde gelöscht.';
@override
String get discussionEdited => 'bearbeitet';
@override
String get discussionWriteAnswer => 'Antwort schreiben';
@override
String get discussionYourAnswer => 'Deine Antwort';
@override
String get discussionEditHeading => 'Bearbeiten';
@override
String get discussionTitleField => 'Titel';
@override
String get discussionTitleHint => 'Was möchtest du wissen?';
@override
String get discussionPost => 'Posten';
@override
String get discussionComment => 'Kommentar hinzufügen';
@override
String get discussionCommentHint => 'Eine kurze Bemerkung';
@override
String get discussionAccept => 'Annehmen';
@override
String get discussionUnaccept => 'Zurücknehmen';
@override
String get discussionMenu => 'Mehr';
@override
String get discussionMenuEdit => 'Bearbeiten';
@override
String get discussionMenuDelete => 'Löschen';
@override
String get discussionMenuHide => 'Ausblenden';
@override
String get discussionMenuShow => 'Wieder zeigen';
@override
String discussionMenuBlock(String name) {
return '$name blockieren';
}
@override
String get discussionDeleteTitle => 'Das löschen?';
@override
String get discussionDeleteBody =>
'Was andere darunter geantwortet haben, bleibt, ohne deinen Text und Namen.';
@override
String get discussionAnswered => 'Antwort gepostet.';
@override
String get discussionCommented => 'Kommentar gepostet.';
@override
String get discussionSaved => 'Gespeichert.';
@override
String get discussionDeletedDone => 'Gelöscht.';
@override
String get discussionAcceptedDone => 'Antwort angenommen.';
@override
String get discussionUnacceptedDone => 'Jetzt ist keine Antwort angenommen.';
@override
String get discussionHiddenDone =>
'Ausgeblendet: Nur wer es geschrieben hat und du sehen es.';
@override
String get discussionShownDone => 'Wieder sichtbar.';
@override
String get discussionBlockedDone =>
'Blockiert: Was diese Person zu deinen Lernsets schreibt, sieht nur sie selbst.';
@override
String get discussionAskAbout => 'Dazu fragen';
@override
String get inboxDiscussions => 'Diskussionen';
@override
String get inboxFeedback => 'Meldungen und Vorschläge';
@override
String get inboxDiscussionsEmpty => 'Noch keine Diskussionen.';
@override
String get cardReportToAuthor => 'Auch im Original melden';

View file

@ -6455,7 +6455,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get inboxIntro =>
'Reports and suggestions on your published studysets, and those you wrote.';
'Questions and answers, reports and suggestions on your published studysets, and those you wrote in.';
@override
String get inboxEmpty => 'Nothing yet.';
@ -6470,6 +6470,222 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get reportToAuthor => 'Report to the author';
@override
String get discussionsTitle => 'Discussions';
@override
String get discussionsSearch => 'Search questions';
@override
String get discussionsAsk => 'Ask a question';
@override
String get discussionsEmpty => 'No questions yet';
@override
String get discussionsEmptyHint =>
'Ask the first: about the studyset, a topic or one of its flashcards.';
@override
String get discussionsNoMatch => 'No question\'s title says that.';
@override
String get discussionsMore => 'Show more';
@override
String get discussionsUnreachable =>
'The discussions can\'t be reached right now.';
@override
String discussionsAnswers(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count answers',
one: '1 answer',
zero: 'No answers',
);
return '$_temp0';
}
@override
String discussionsBy(String name, String date) {
return '$name · $date';
}
@override
String get discussionsSomeone => 'Someone';
@override
String get discussionsDeletedQuestion => 'Deleted question';
@override
String get discussionsHidden => 'Hidden';
@override
String get discussionsAboutCard => 'About a flashcard';
@override
String get discussionsAboutNote => 'About a note';
@override
String get discussionsAboutStep => 'About a step';
@override
String get discussionsAccepted => 'Accepted';
@override
String discussionsOn(String title) {
return 'On $title';
}
@override
String get discussionRefusalNoIdentity =>
'To ask and answer, sign in with an identity in Profile: posts show who wrote them.';
@override
String get discussionRefusalNoProfile =>
'To ask and answer, make a public profile in Profile: posts show who wrote them.';
@override
String get discussionRefusalBlocked =>
'The author of this studyset keeps you from writing on their studysets.';
@override
String get discussionRefusalWithdrawn =>
'The author took this studyset off Explore: what is here stays, but nothing new is asked.';
@override
String get discussionRefusalNotInRelease =>
'That isn\'t in the studyset\'s release any more.';
@override
String get discussionRefusalEmpty => 'Write something first.';
@override
String get discussionRefusalTooLong => 'That\'s longer than allowed.';
@override
String get discussionRefusalTooMany =>
'You\'ve written a lot in the last hour. Try again a little later.';
@override
String get discussionRefusalWithFiles =>
'Images and sounds can\'t be posted: your files are yours alone, so nobody else would see them.';
@override
String get discussionFailed =>
'Couldn\'t reach the instance. Try again once you are online.';
@override
String get discussionTitle => 'Question';
@override
String get discussionMissing => 'This question isn\'t there any more.';
@override
String get discussionDeleted => 'This was deleted.';
@override
String get discussionEdited => 'edited';
@override
String get discussionWriteAnswer => 'Write an answer';
@override
String get discussionYourAnswer => 'Your answer';
@override
String get discussionEditHeading => 'Edit';
@override
String get discussionTitleField => 'Title';
@override
String get discussionTitleHint => 'What do you want to know?';
@override
String get discussionPost => 'Post';
@override
String get discussionComment => 'Add a comment';
@override
String get discussionCommentHint => 'A short remark';
@override
String get discussionAccept => 'Accept';
@override
String get discussionUnaccept => 'Take back';
@override
String get discussionMenu => 'More';
@override
String get discussionMenuEdit => 'Edit';
@override
String get discussionMenuDelete => 'Delete';
@override
String get discussionMenuHide => 'Hide';
@override
String get discussionMenuShow => 'Show again';
@override
String discussionMenuBlock(String name) {
return 'Block $name';
}
@override
String get discussionDeleteTitle => 'Delete this?';
@override
String get discussionDeleteBody =>
'What others replied under it stays, without your text and name.';
@override
String get discussionAnswered => 'Answer posted.';
@override
String get discussionCommented => 'Comment posted.';
@override
String get discussionSaved => 'Saved.';
@override
String get discussionDeletedDone => 'Deleted.';
@override
String get discussionAcceptedDone => 'Answer accepted.';
@override
String get discussionUnacceptedDone => 'No answer accepted now.';
@override
String get discussionHiddenDone => 'Hidden: only its writer and you see it.';
@override
String get discussionShownDone => 'Shown again.';
@override
String get discussionBlockedDone =>
'Blocked: what they write on your studysets shows to them alone.';
@override
String get discussionAskAbout => 'Ask about this';
@override
String get inboxDiscussions => 'Discussions';
@override
String get inboxFeedback => 'Reports and suggestions';
@override
String get inboxDiscussionsEmpty => 'No discussions yet.';
@override
String get cardReportToAuthor => 'Send to the author too';

View file

@ -16,6 +16,9 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.d
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussions_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
@ -294,6 +297,29 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
page: int.tryParse(state.uri.queryParameters['page'] ?? ''),
),
),
// A published studyset's discussions, and each of them (ADR 0013).
GoRoute(
path: '/discussions/:publicId',
builder: (context, state) => FramedScreen(
child: DiscussionsScreen(
publicId: state.pathParameters['publicId']!,
topic: state.uri.queryParameters['topic'],
version: int.tryParse(state.uri.queryParameters['version'] ?? ''),
title: state.uri.queryParameters['title'],
ask: targetFromQuery(state.uri.queryParameters['ask']),
),
),
routes: [
GoRoute(
path: ':questionId',
builder: (context, state) => FramedScreen(
child: DiscussionScreen(
questionId: state.pathParameters['questionId']!,
),
),
),
],
),
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const FramedScreen(child: SettingsScreen()),

View file

@ -92,9 +92,10 @@ class _PersonAppState() extends State<PersonApp> {
),
// What is new in the inbox, for the Profile tab's badge.
BlocProvider(
create: (context) =>
InboxCountBloc(PersonScope.of(context).feedback)
..add(const InboxCountEventRefreshed()),
create: (context) => InboxCountBloc(
PersonScope.of(context).feedback,
PersonScope.of(context).discussions,
)..add(const InboxCountEventRefreshed()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(

View file

@ -42,6 +42,8 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_diction
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/japanese_analyser.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/serverpod_dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/serverpod_discussion_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
@ -429,6 +431,7 @@ Future<PersonDependencies> _openPerson(
);
await studysetTabs.open();
final discussions = DiscussionRepository(ServerpodDiscussionGateway(client));
return PersonDependencies(
sync: sync,
studysets: studysets,
@ -467,6 +470,7 @@ Future<PersonDependencies> _openPerson(
paths,
assets,
),
discussions: discussions,
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way
@ -478,6 +482,7 @@ Future<PersonDependencies> _openPerson(
await pathDeletions.dispose();
await stepDeletions.dispose();
await pdfDeletions.dispose();
await discussions.dispose();
await studysets.dispose();
await tags.dispose();
await settings.dispose();

View file

@ -2,6 +2,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
@ -95,6 +96,9 @@ class PersonDependencies({
/// Reports and suggestions on published studysets.
required final FeedbackRepository feedback,
/// Published studysets' discussions, on the instance.
required final DiscussionRepository discussions,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -102,4 +102,32 @@ abstract final class RoutePaths() {
/// A step of a learning path, to write, over the tabs.
static String pathStep(String studysetId, String pathId, String stepId) =>
'/studysets/$studysetId/paths/$pathId/steps/$stepId';
/// A published studyset's discussions, over the tabs: those within the
/// topic [topic] (a studyset's id in the release [version], which asking
/// about it names), named [title], or all; with [ask]
/// (`<kind>:<item>:<version>`: a card, note or step), asking about it at
/// once.
static String discussions(
String publicId, {
String? topic,
int? version,
String? title,
String? ask,
}) {
final query = {
'topic': ?topic,
if (version != null) 'version': '$version',
'title': ?title,
'ask': ?ask,
};
return Uri(
path: '/discussions/$publicId',
queryParameters: query.isEmpty ? null : query,
).toString();
}
/// A question on a published studyset, with its answers, over the tabs.
static String discussion(String publicId, String questionId) =>
'/discussions/$publicId/$questionId';
}

View file

@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/sign_in_ways_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/email_sign_in_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_inbox.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/widget/public_profile_section.dart';
import 'package:halkyon_learn_flutter/src/feature/statistics/widget/statistics_view.dart';
@ -190,7 +191,9 @@ class const _Account() extends StatelessWidget {
),
if (isIdentified) ...[
const SizedBox(height: 12),
const SectionCard(child: InboxSection()),
const SectionCard(
child: InboxSection(discussions: DiscussionInbox()),
),
const SizedBox(height: 12),
const SectionCard(child: PublicProfileSection()),
],

View file

@ -0,0 +1,293 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
/// One discussion, the question [_questionId] with its answers and
/// comments, and what the person does in it: answering, commenting,
/// editing and deleting their own, accepting an answer to their question,
/// and, as the studyset's author, hiding posts and blocking people.
/// Reading it marks it read; every action reads it anew.
class DiscussionBloc(
final DiscussionRepository _discussions, {
required final String _questionId,
}) extends Bloc<DiscussionEvent, DiscussionState> {
this : super(const DiscussionState()) {
on<DiscussionEventLoaded>((event, emit) async {
await _load(emit);
if (state.status == DiscussionStatus.ready) {
try {
await _discussions.markSeen(_questionId);
} on Object catch (error) {
log('Marking read failed', name: 'Discussions', error: error);
}
}
});
on<DiscussionEventAnswered>(
(event, emit) => _act(
emit,
() => _discussions.answer(_questionId, event.content),
DiscussionOutcome.answered,
),
);
on<DiscussionEventCommented>(
(event, emit) => _act(
emit,
() => _discussions.comment(event.onId, event.text),
DiscussionOutcome.commented,
),
);
on<DiscussionEventEdited>(
(event, emit) => _act(
emit,
() =>
_discussions.edit(event.postId, event.content, title: event.title),
DiscussionOutcome.edited,
),
);
on<DiscussionEventCommentEdited>(
(event, emit) => _act(
emit,
() => _discussions.editComment(event.commentId, event.text),
DiscussionOutcome.edited,
),
);
on<DiscussionEventDeleted>(
(event, emit) => _act(
emit,
() => _discussions.delete(event.postId),
DiscussionOutcome.deleted,
),
);
on<DiscussionEventAccepted>(
(event, emit) => _act(
emit,
() => _discussions.accept(_questionId, event.answerId),
event.answerId == null
? DiscussionOutcome.unaccepted
: DiscussionOutcome.accepted,
),
);
on<DiscussionEventHidden>(
(event, emit) => _act(
emit,
() => _discussions.hide(event.postId, hidden: event.hidden),
event.hidden ? DiscussionOutcome.hidden : DiscussionOutcome.shown,
),
);
on<DiscussionEventBlocked>(
(event, emit) => _act(
emit,
() => _discussions.block(event.person),
DiscussionOutcome.blocked,
),
);
}
Future<void> _load(Emitter<DiscussionState> emit) async {
try {
final discussion = await _discussions.read(_questionId);
emit(
state.copyWith(status: DiscussionStatus.ready, discussion: discussion),
);
} on DiscussionMissing {
emit(state.copyWith(status: DiscussionStatus.missing));
} on Object catch (error) {
log('Reading the discussion failed', name: 'Discussions', error: error);
if (state.discussion == null) {
emit(state.copyWith(status: DiscussionStatus.unreachable));
}
}
}
/// Does [action], then reads the discussion anew, saying how it went.
Future<void> _act(
Emitter<DiscussionState> emit,
Future<void> Function() action,
DiscussionOutcome outcome,
) async {
emit(state.copyWith(busy: true));
try {
await action();
await _load(emit);
emit(
state.copyWith(
busy: false,
outcome: outcome,
refusal: null,
outcomes: state.outcomes + 1,
),
);
} on DiscussionRefusedException catch (error) {
emit(
state.copyWith(
busy: false,
outcome: DiscussionOutcome.refused,
refusal: error.reason,
outcomes: state.outcomes + 1,
),
);
} on Object catch (error) {
log('Acting in a discussion failed', name: 'Discussions', error: error);
await _load(emit);
emit(
state.copyWith(
busy: false,
outcome: DiscussionOutcome.failed,
outcomes: state.outcomes + 1,
),
);
}
}
}
/// Whether the discussion could be read: still reading, read, gone (or not
/// the person's to see), or the instance unreachable.
enum DiscussionStatus() {
loading,
ready,
missing,
unreachable,
}
/// What an action came to.
enum DiscussionOutcome() {
answered,
commented,
edited,
deleted,
accepted,
unaccepted,
hidden,
shown,
blocked,
/// Refused, for the state's refusal.
refused,
/// The instance couldn't be reached.
failed,
}
/// A discussion as read, whether an action is under way ([busy]), and how
/// the last one went: its [outcome] (with [refusal] when refused);
/// [outcomes] counts them, so the same outcome twice is told twice.
final class const DiscussionState({
final DiscussionStatus status = DiscussionStatus.loading,
final Discussion? discussion,
final bool busy = false,
final DiscussionOutcome? outcome,
final DiscussionRefusal? refusal,
final int outcomes = 0,
}) with Equatable {
DiscussionState copyWith({
DiscussionStatus? status,
Discussion? discussion,
bool? busy,
DiscussionOutcome? outcome,
Object? refusal = _keep,
int? outcomes,
}) => DiscussionState(
status: status ?? this.status,
discussion: discussion ?? this.discussion,
busy: busy ?? this.busy,
outcome: outcome ?? this.outcome,
refusal: identical(refusal, _keep)
? this.refusal
: refusal as DiscussionRefusal?,
outcomes: outcomes ?? this.outcomes,
);
@override
List<Object?> get props => [
status,
discussion,
busy,
outcome,
refusal,
outcomes,
];
}
/// What `DiscussionState.copyWith` keeps as it is.
const _keep = Object();
/// What happens in a discussion.
sealed class const DiscussionEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Read it (anew).
final class const DiscussionEventLoaded() extends DiscussionEvent;
/// The person answers, saying [content].
final class const DiscussionEventAnswered(final RichContent content)
extends DiscussionEvent {
@override
List<Object?> get props => [content];
}
/// The person comments [text] on [onId], the question or an answer.
final class const DiscussionEventCommented(
final String onId,
final String text,
) extends DiscussionEvent {
@override
List<Object?> get props => [onId, text];
}
/// The person changes their question or answer [postId] to say [content],
/// and a question's [title].
final class const DiscussionEventEdited(
final String postId,
final RichContent content, {
final String? title,
}) extends DiscussionEvent {
@override
List<Object?> get props => [postId, content, title];
}
/// The person changes their comment [commentId] to say [text].
final class const DiscussionEventCommentEdited(
final String commentId,
final String text,
) extends DiscussionEvent {
@override
List<Object?> get props => [commentId, text];
}
/// The person deletes their post [postId].
final class const DiscussionEventDeleted(final String postId)
extends DiscussionEvent {
@override
List<Object?> get props => [postId];
}
/// The asker accepts [answerId]; null accepts none.
final class const DiscussionEventAccepted(final String? answerId)
extends DiscussionEvent {
@override
List<Object?> get props => [answerId];
}
/// The author hides the post [postId], or shows it again.
final class const DiscussionEventHidden(
final String postId, {
required final bool hidden,
}) extends DiscussionEvent {
@override
List<Object?> get props => [postId, hidden];
}
/// The author keeps [person] from writing on their studysets.
final class const DiscussionEventBlocked(final PublicPerson person)
extends DiscussionEvent {
@override
List<Object?> get props => [person];
}

View file

@ -0,0 +1,241 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
/// A list of questions: those on the published studyset [_publicId] (within
/// [_topic], or about [_item] alone), found by what their titles say, a
/// page at a time; or, without a studyset, the discussions the person
/// follows (their inbox). Asks new questions, and reads anew whenever the
/// person wrote something.
class DiscussionListBloc(
final DiscussionRepository _discussions, {
final String? _publicId,
final String? _topic,
final DiscussionTarget? _item,
}) extends Bloc<DiscussionListEvent, DiscussionListState> {
this : super(const DiscussionListState()) {
_changes = _discussions.changes.listen(
(_) => add(const DiscussionListEventLoaded()),
);
on<DiscussionListEventLoaded>((event, emit) => _load(emit, state.query));
on<DiscussionListEventQueryChanged>(
(event, emit) => _load(emit, event.query),
);
on<DiscussionListEventMoreRequested>((event, emit) async {
final publicId = _publicId;
if (publicId == null || !state.hasMore || state.loadingMore) {
return;
}
emit(state.copyWith(loadingMore: true));
final reading = ++_reading;
try {
final more = await _discussions.list(
publicId,
topic: _topic,
item: _item,
query: state.query,
page: _page + 1,
);
if (reading != _reading) {
return;
}
_page++;
emit(
state.copyWith(
questions: [...state.questions, ...more],
hasMore: more.length >= DiscussionRepository.page,
loadingMore: false,
),
);
} on Object catch (error) {
log('Reading more questions failed', name: 'Discussions', error: error);
emit(state.copyWith(loadingMore: false));
}
});
on<DiscussionListEventAsked>((event, emit) async {
final publicId = _publicId;
if (publicId == null) {
return;
}
emit(state.copyWith(asking: true));
try {
final id = await _discussions.ask(
publicId,
event.target,
event.title,
event.content,
);
emit(
state.copyWith(
asking: false,
asked: id,
refusal: null,
failed: false,
),
);
} on DiscussionRefusedException catch (error) {
emit(state.copyWith(asking: false, refusal: error.reason));
} on Object catch (error) {
log('Asking failed', name: 'Discussions', error: error);
emit(state.copyWith(asking: false, failed: true));
}
});
}
late final StreamSubscription<void> _changes;
/// Which reading is the latest: an older one's answer is dropped.
var _reading = 0;
/// The last page read.
var _page = 0;
Future<void> _load(Emitter<DiscussionListState> emit, String query) async {
final reading = ++_reading;
if (query != state.query || state.status != DiscussionListStatus.ready) {
emit(state.copyWith(query: query));
}
try {
final publicId = _publicId;
final questions = publicId == null
? await _discussions.inbox()
: await _discussions.list(
publicId,
topic: _topic,
item: _item,
query: query,
);
final refusal = publicId == null
? null
: await _discussions.askRefusal(publicId);
if (reading != _reading) {
return;
}
_page = 0;
emit(
state.copyWith(
status: DiscussionListStatus.ready,
questions: questions,
hasMore:
publicId != null && questions.length >= DiscussionRepository.page,
askRefusal: refusal,
),
);
} on Object catch (error) {
log('Reading questions failed', name: 'Discussions', error: error);
if (reading == _reading) {
emit(state.copyWith(status: DiscussionListStatus.unreachable));
}
}
}
@override
Future<void> close() async {
await _changes.cancel();
await super.close();
}
}
/// Whether a list could be read yet.
enum DiscussionListStatus() {
loading,
ready,
unreachable,
}
/// A list of questions as read: those found by [query], whether more pages
/// may follow, why the person may not ask ([askRefusal]; null when they
/// may), and how the last question asked went: [asked] (its id), refused
/// for [refusal], or [failed] (the instance unreachable).
final class const DiscussionListState({
final DiscussionListStatus status = DiscussionListStatus.loading,
final List<DiscussionSummary> questions = const [],
final String query = '',
final bool hasMore = false,
final bool loadingMore = false,
final DiscussionRefusal? askRefusal,
final bool asking = false,
final String? asked,
final DiscussionRefusal? refusal,
final bool failed = false,
}) with Equatable {
DiscussionListState copyWith({
DiscussionListStatus? status,
List<DiscussionSummary>? questions,
String? query,
bool? hasMore,
bool? loadingMore,
Object? askRefusal = _keep,
bool? asking,
Object? asked = _keep,
Object? refusal = _keep,
bool? failed,
}) => DiscussionListState(
status: status ?? this.status,
questions: questions ?? this.questions,
query: query ?? this.query,
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
askRefusal: identical(askRefusal, _keep)
? this.askRefusal
: askRefusal as DiscussionRefusal?,
asking: asking ?? this.asking,
asked: identical(asked, _keep) ? this.asked : asked as String?,
refusal: identical(refusal, _keep)
? this.refusal
: refusal as DiscussionRefusal?,
failed: failed ?? this.failed,
);
@override
List<Object?> get props => [
status,
questions,
query,
hasMore,
loadingMore,
askRefusal,
asking,
asked,
refusal,
failed,
];
}
/// What `DiscussionListState.copyWith` keeps as it is.
const _keep = Object();
/// What happens to a list of questions.
sealed class const DiscussionListEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Read it (anew).
final class const DiscussionListEventLoaded() extends DiscussionListEvent;
/// Find the questions whose titles say [query].
final class const DiscussionListEventQueryChanged(final String query)
extends DiscussionListEvent {
@override
List<Object?> get props => [query];
}
/// Read the next page.
final class const DiscussionListEventMoreRequested()
extends DiscussionListEvent;
/// Ask [title], saying [content], about [target].
final class const DiscussionListEventAsked(
final DiscussionTarget target,
final String title,
final RichContent content,
) extends DiscussionListEvent {
@override
List<Object?> get props => [target, title, content];
}

View file

@ -0,0 +1,75 @@
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
/// The boundary to the instance's discussions (ADR 0013). Writing throws
/// [DiscussionRefusedException] when refused, and [DiscussionMissing] for
/// something the person can't see; tests replace it with a fake.
abstract interface class DiscussionGateway() {
/// The questions on the published studyset [publicId], those last active
/// first, a [page] at a time: within the topic [topic] (a studyset's id in
/// the release), about [item] alone, or with [query] in their titles.
Future<List<DiscussionSummary>> list(
String publicId, {
String? topic,
DiscussionTarget? item,
String? query,
int page = 0,
});
/// The question [questionId] with its answers and comments.
Future<Discussion> read(String questionId);
/// Why the person may not ask about [publicId]; null when they may.
Future<DiscussionRefusal?> askRefusal(String publicId);
/// Asks [title], saying [content], about [target] of [publicId]; returns
/// the question's id.
Future<String> ask(
String publicId,
DiscussionTarget target,
String title,
RichContent content,
);
/// Answers the question [questionId]; returns the answer's id.
Future<String> answer(String questionId, RichContent content);
/// Comments [text] on [onId], a question or an answer; returns the
/// comment's id.
Future<String> comment(String onId, String text);
/// Changes what the person's question or answer [postId] says, and a
/// question's [title] where given.
Future<void> edit(String postId, RichContent content, {String? title});
/// Changes what the person's comment [commentId] says.
Future<void> editComment(String commentId, String text);
/// Deletes the person's post [postId].
Future<void> delete(String postId);
/// Accepts [answerId] as the answer to the person's question
/// [questionId]; null accepts none.
Future<void> accept(String questionId, String? answerId);
/// Hides the post [postId] on the person's published studyset, or shows
/// it again.
Future<void> hide(String postId, {required bool hidden});
/// The discussions the person follows, those last active first.
Future<List<DiscussionSummary>> inbox();
/// How many of them have something new.
Future<int> unseen();
/// Records that the person read the discussion [questionId].
Future<void> markSeen(String questionId);
/// Keeps the person with [handle] from writing on the person's published
/// studysets: what they wrote there shows to them alone. The same block
/// as reports' and suggestions'.
Future<void> block(String handle);
}
/// What was asked for isn't there, or isn't the person's to see or change.
final class DiscussionMissing() implements Exception;

View file

@ -0,0 +1,139 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
/// Published studysets' discussions (ADR 0013), on the instance: what the
/// person reads and writes there. Written text is checked here first: never
/// blank, and no images or sounds, which live in the person's own files
/// and nobody else could see. Says when it wrote something ([changes]), so
/// lists and the inbox read anew.
class DiscussionRepository(final DiscussionGateway _gateway) {
final _changes = StreamController<void>.broadcast();
/// Fires whenever the person wrote or changed something.
Stream<void> get changes => _changes.stream;
/// The questions on the published studyset [publicId], those last active
/// first, a [page] at a time: within [topic], about [item], or with
/// [query] in their titles.
Future<List<DiscussionSummary>> list(
String publicId, {
String? topic,
DiscussionTarget? item,
String? query,
int page = 0,
}) => _gateway.list(
publicId,
topic: topic,
item: item,
query: query,
page: page,
);
/// How many questions a page of [list] holds at most.
static const page = 30;
/// The question [questionId] with its answers and comments.
Future<Discussion> read(String questionId) => _gateway.read(questionId);
/// Why the person may not ask about [publicId]; null when they may.
Future<DiscussionRefusal?> askRefusal(String publicId) =>
_gateway.askRefusal(publicId);
/// Asks [title], saying [content], about [target] of [publicId]; returns
/// the question's id.
Future<String> ask(
String publicId,
DiscussionTarget target,
String title,
RichContent content,
) => _written(() {
_check(content);
if (title.trim().isEmpty) {
throw DiscussionRefusedException(DiscussionRefusal.empty);
}
return _gateway.ask(publicId, target, title.trim(), content);
});
/// Answers the question [questionId]; returns the answer's id.
Future<String> answer(String questionId, RichContent content) => _written(() {
_check(content);
return _gateway.answer(questionId, content);
});
/// Comments [text] on [onId], a question or an answer.
Future<String> comment(String onId, String text) => _written(() {
if (text.trim().isEmpty) {
throw DiscussionRefusedException(DiscussionRefusal.empty);
}
return _gateway.comment(onId, text.trim());
});
/// Changes what the person's question or answer [postId] says, and a
/// question's [title] where given.
Future<void> edit(String postId, RichContent content, {String? title}) =>
_written(() {
_check(content);
return _gateway.edit(postId, content, title: title?.trim());
});
/// Changes what the person's comment [commentId] says.
Future<void> editComment(String commentId, String text) => _written(() {
if (text.trim().isEmpty) {
throw DiscussionRefusedException(DiscussionRefusal.empty);
}
return _gateway.editComment(commentId, text.trim());
});
/// Deletes the person's post [postId].
Future<void> delete(String postId) => _written(() => _gateway.delete(postId));
/// Accepts [answerId] for the person's question [questionId]; null
/// accepts none.
Future<void> accept(String questionId, String? answerId) =>
_written(() => _gateway.accept(questionId, answerId));
/// Hides the post [postId] on the person's published studyset, or shows
/// it again.
Future<void> hide(String postId, {required bool hidden}) =>
_written(() => _gateway.hide(postId, hidden: hidden));
/// The discussions the person follows, those last active first.
Future<List<DiscussionSummary>> inbox() => _gateway.inbox();
/// How many of them have something new.
Future<int> unseen() => _gateway.unseen();
/// Records that the person read the discussion [questionId]; lists then
/// read anew, as what is new in them changed.
Future<void> markSeen(String questionId) =>
_written(() => _gateway.markSeen(questionId));
/// Keeps [person] from writing on the person's published studysets.
Future<void> block(PublicPerson person) =>
_written(() => _gateway.block(person.handle));
Future<void> dispose() => _changes.close();
/// Runs [write], then says something changed.
Future<T> _written<T>(Future<T> Function() write) async {
final result = await write();
if (!_changes.isClosed) {
_changes.add(null);
}
return result;
}
/// Refuses blank [content], or content holding images or sounds.
static void _check(RichContent content) {
if (content.isBlank) {
throw DiscussionRefusedException(DiscussionRefusal.empty);
}
if (content.hasImages || content.hasSounds) {
throw DiscussionRefusedException(DiscussionRefusal.withFiles);
}
}
}

View file

@ -0,0 +1,247 @@
import 'dart:convert';
import 'dart:developer';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
/// [DiscussionGateway] asking the instance through the Serverpod client.
/// A question of a kind this app doesn't know, or rich text in a newer
/// format, is left out or shown without it.
class ServerpodDiscussionGateway(final protocol.Client _client)
implements DiscussionGateway {
@override
Future<List<DiscussionSummary>> list(
String publicId, {
String? topic,
DiscussionTarget? item,
String? query,
int page = 0,
}) => _call(
() async => [
for (final summary in await _client.discussions.list(
_uuid(publicId),
topic: topic,
item: item == null ? null : _targetOut(item),
query: query,
page: page,
))
?_summary(summary),
],
);
@override
Future<Discussion> read(String questionId) => _call(
() async => _discussion(await _client.discussions.read(_uuid(questionId))),
);
@override
Future<DiscussionRefusal?> askRefusal(String publicId) => _call(
() async => switch (await _client.discussions.askRefusal(_uuid(publicId))) {
final refusal? => _refusal(refusal),
null => null,
},
);
@override
Future<String> ask(
String publicId,
DiscussionTarget target,
String title,
RichContent content,
) => _call(
() async => (await _client.discussion.ask(
_uuid(publicId),
_targetOut(target),
title,
jsonEncode(content.toJson()),
)).uuid,
);
@override
Future<String> answer(String questionId, RichContent content) => _call(
() async => (await _client.discussion.answer(
_uuid(questionId),
jsonEncode(content.toJson()),
)).uuid,
);
@override
Future<String> comment(String onId, String text) => _call(
() async => (await _client.discussion.comment(_uuid(onId), text)).uuid,
);
@override
Future<void> edit(String postId, RichContent content, {String? title}) =>
_call(
() => _client.discussion.edit(
_uuid(postId),
jsonEncode(content.toJson()),
title: title,
),
);
@override
Future<void> editComment(String commentId, String text) =>
_call(() => _client.discussion.edit(_uuid(commentId), text));
@override
Future<void> delete(String postId) =>
_call(() => _client.discussion.delete(_uuid(postId)));
@override
Future<void> accept(String questionId, String? answerId) => _call(
() => _client.discussion.accept(
_uuid(questionId),
answerId == null ? null : _uuid(answerId),
),
);
@override
Future<void> hide(String postId, {required bool hidden}) =>
_call(() => _client.discussion.hide(_uuid(postId), hidden));
@override
Future<List<DiscussionSummary>> inbox() => _call(
() async => [
for (final summary in await _client.discussion.inbox())
?_summary(summary),
],
);
@override
Future<int> unseen() => _call(_client.discussion.unseen);
@override
Future<void> markSeen(String questionId) =>
_call(() => _client.discussion.markSeen(_uuid(questionId)));
@override
Future<void> block(String handle) => _call(() => _client.inbox.block(handle));
/// Runs [call], its refusals and what is missing as this app's own.
static Future<T> _call<T>(Future<T> Function() call) async {
try {
return await call();
} on protocol.DiscussionRefusedException catch (error) {
throw DiscussionRefusedException(_refusal(error.reason));
} on protocol.NotFoundException {
throw DiscussionMissing();
}
}
static protocol.UuidValue _uuid(String id) =>
protocol.UuidValue.withValidation(id);
static protocol.DiscussionTarget _targetOut(DiscussionTarget target) =>
protocol.DiscussionTarget(
kind: target.kind.name,
item: target.item,
version: target.version,
);
static DiscussionTarget? _target(protocol.DiscussionTarget target) =>
switch (DiscussionTargetKind.values.asNameMap()[target.kind]) {
final kind? => DiscussionTarget(
kind: kind,
item: target.item,
version: target.version,
),
null => null,
};
static DiscussionRefusal _refusal(protocol.DiscussionRefusal refusal) =>
DiscussionRefusal.values.asNameMap()[refusal.name] ??
DiscussionRefusal.empty;
static PublicPerson? _person(protocol.FeedbackPerson? person) =>
person == null
? null
: PublicPerson(
handle: person.handle,
displayName: person.displayName,
avatarUrl: person.avatarUrl,
);
DiscussionSummary? _summary(protocol.DiscussionSummary summary) {
final target = _target(summary.target);
if (target == null) {
return null;
}
return DiscussionSummary(
questionId: summary.questionId.uuid,
publicId: summary.publicId.uuid,
studysetTitle: summary.studysetTitle,
title: summary.title,
asker: _person(summary.asker),
askerIsAuthor: summary.askerIsAuthor,
target: target,
createdAt: summary.createdAt,
activeAt: summary.activeAt,
answers: summary.answers,
accepted: summary.accepted,
deleted: summary.deleted,
hidden: summary.hidden,
mine: summary.mine,
unseen: summary.unseen,
);
}
Discussion _discussion(protocol.DiscussionView view) => Discussion(
question: _post(view.question),
publicId: view.publicId.uuid,
studysetTitle: view.studysetTitle,
location: view.location,
target:
_target(view.target) ??
DiscussionTarget(
kind: DiscussionTargetKind.studyset,
item: view.target.item,
version: view.target.version,
),
answers: [for (final answer in view.answers) _post(answer)],
comments: [for (final comment in view.comments) _post(comment)],
acceptedAnswerId: view.acceptedAnswerId?.uuid,
asker: view.asker,
author: view.author,
refusal: switch (view.refusal) {
final refusal? => _refusal(refusal),
null => null,
},
);
DiscussionPost _post(protocol.DiscussionPostView post) {
final kind =
DiscussionPostKind.values.asNameMap()[post.kind] ??
DiscussionPostKind.comment;
return DiscussionPost(
id: post.id.uuid,
kind: kind,
createdAt: post.createdAt,
parentId: post.parentId?.uuid,
by: _person(post.by),
fromAuthor: post.fromAuthor,
title: post.title ?? '',
content: kind == DiscussionPostKind.comment || post.deleted
? null
: _content(post.body),
text: kind == DiscussionPostKind.comment ? post.body : '',
editedAt: post.editedAt,
deleted: post.deleted,
hidden: post.hidden,
mine: post.mine,
);
}
/// [body] as rich text; null for what this app can't read.
static RichContent? _content(String body) {
try {
return RichContent.fromJson(jsonDecode(body) as Map<String, Object?>);
} on Object catch (error) {
log('A post this app can’t read', name: 'Discussions', error: error);
return null;
}
}
}

View file

@ -0,0 +1,210 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
// A published studyset's discussions (ADR 0013, decided 2026-10-15): questions
// about it, a topic in it or one of its cards, notes or steps, answered by
// anyone, the asker accepting one; comments under a question and each
// answer. Held by the instance, read by everyone signed in.
/// What kind of item a question is about.
enum DiscussionTargetKind() {
/// The published studyset itself, or a topic in it.
studyset,
card,
note,
step,
}
/// What a question is about: an item by its id in release [version].
final class const DiscussionTarget({
required final DiscussionTargetKind kind,
required final String item,
required final int version,
}) with Equatable {
@override
List<Object?> get props => [kind, item, version];
}
/// Why writing in a discussion was refused, or why someone may not.
enum DiscussionRefusal() {
/// Signed in without an identity: writing needs one.
noIdentity,
/// No public profile: posts name their writer.
noProfile,
/// The studyset's author keeps the person from writing on their studysets.
blocked,
/// The studyset is off Explore: nothing new is asked about it.
withdrawn,
/// The release doesn't hold what the question is about.
notInRelease,
/// A title or text left empty.
empty,
/// A title, text or comment too long.
tooLong,
/// More posts in the last hour than allowed.
tooMany,
/// Images or sounds in a post: the person's files are theirs alone, so
/// nobody else could see them (the app's own check).
withFiles,
}
/// Writing in a discussion was refused, for [reason]; nothing changed.
final class DiscussionRefusedException(final DiscussionRefusal reason)
implements Exception {
@override
String toString() => 'DiscussionRefusedException: ${reason.name}';
}
/// A question as a list shows it: on which published studyset, its title,
/// who asked it and what about, how many answers it has and whether one is
/// accepted; deleted (its title gone), hidden by the author, the person's
/// own, and whether something in it is new to them.
final class const DiscussionSummary({
required final String questionId,
required final String publicId,
required final String studysetTitle,
required final String title,
required final PublicPerson? asker,
required final bool askerIsAuthor,
required final DiscussionTarget target,
required final DateTime createdAt,
required final DateTime activeAt,
required final int answers,
required final bool accepted,
final bool deleted = false,
final bool hidden = false,
final bool mine = false,
final bool unseen = false,
}) with Equatable {
@override
List<Object?> get props => [
questionId,
publicId,
studysetTitle,
title,
asker,
askerIsAuthor,
target,
createdAt,
activeAt,
answers,
accepted,
deleted,
hidden,
mine,
unseen,
];
}
/// What a post is.
enum DiscussionPostKind() {
question,
answer,
comment,
}
/// A question, an answer or a comment: who wrote it ([by]; null once
/// deleted, or without a public profile), whether the studyset's author did,
/// a question's [title], what it says ([content] for a question or an
/// answer, [text] for a comment), when, and whether it was edited, deleted,
/// hidden by the author or is the person's own. A comment is on [parentId].
final class const DiscussionPost({
required final String id,
required final DiscussionPostKind kind,
required final DateTime createdAt,
final String? parentId,
final PublicPerson? by,
final bool fromAuthor = false,
final String title = '',
final RichContent? content,
final String text = '',
final DateTime? editedAt,
final bool deleted = false,
final bool hidden = false,
final bool mine = false,
}) with Equatable {
@override
List<Object?> get props => [
id,
kind,
createdAt,
parentId,
by,
fromAuthor,
title,
content,
text,
editedAt,
deleted,
hidden,
mine,
];
}
/// A discussion: its [question], on which published studyset (its title and
/// location), about what, its [answers] (the accepted one first, then the
/// oldest first) and the [comments] under both; whether the person asked it
/// (and may accept an answer) or is the studyset's author (and may hide
/// posts), and why they may not write in it ([refusal]; null when they
/// may).
final class const Discussion({
required final DiscussionPost question,
required final String publicId,
required final String studysetTitle,
required final String location,
required final DiscussionTarget target,
final List<DiscussionPost> answers = const [],
final List<DiscussionPost> comments = const [],
final String? acceptedAnswerId,
final bool asker = false,
final bool author = false,
final DiscussionRefusal? refusal,
}) with Equatable {
/// The comments on [postId], the question or an answer, oldest first.
List<DiscussionPost> commentsOn(String postId) => [
for (final comment in comments)
if (comment.parentId == postId) comment,
];
@override
List<Object?> get props => [
question,
publicId,
studysetTitle,
location,
target,
answers,
comments,
acceptedAnswerId,
asker,
author,
refusal,
];
}
/// [target] as a location's `ask` names it: `<kind>:<item>:<version>`.
String targetQuery(DiscussionTarget target) =>
'${target.kind.name}:${target.item}:${target.version}';
/// The target [query] names, as [targetQuery] wrote it; null for none, or
/// what isn't one.
DiscussionTarget? targetFromQuery(String? query) {
final parts = query?.split(':');
if (parts == null || parts.length != 3) {
return null;
}
final kind = DiscussionTargetKind.values.asNameMap()[parts[0]];
final version = int.tryParse(parts[2]);
return kind == null || version == null || parts[1].isEmpty
? null
: DiscussionTarget(kind: kind, item: parts[1], version: version);
}

View file

@ -0,0 +1,69 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
/// The longest a comment may be, as the instance takes it.
const maxCommentLength = 600;
/// Asks for a comment, from [initial] when editing one: plain text, a
/// short remark. Returns it once posted; null when left.
Future<String?> writeDiscussionComment(
BuildContext context, {
String initial = '',
}) => showAdaptiveSheet<String>(
context,
builder: (context) => _CommentSheet(initial: initial),
);
class const _CommentSheet({required final String initial})
extends StatefulWidget {
@override
State<_CommentSheet> createState() => _CommentSheetState();
}
class _CommentSheetState() extends State<_CommentSheet> {
late final _text = TextEditingController(text: widget.initial);
@override
void initState() {
super.initState();
_text.addListener(() => setState(() {}));
}
@override
void dispose() {
_text.dispose();
super.dispose();
}
void _post() => Navigator.of(context).pop(_text.text.trim());
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final ready = _text.text.trim().isNotEmpty;
return SheetScaffold(
title: l10n.discussionComment,
body: TextField(
controller: _text,
autofocus: true,
minLines: 2,
maxLines: 6,
maxLength: maxCommentLength,
buildCounter: nearLimitCounter,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: l10n.discussionCommentHint,
),
),
footer: Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton(
onPressed: ready ? _post : null,
child: Text(l10n.discussionPost),
),
),
);
}
}

View file

@ -0,0 +1,165 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/field_label.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_texts.dart';
import 'package:material_ui/material_ui.dart';
/// What was written to post: a question's title, and its text.
typedef DiscussionDraft = ({String title, RichContent content});
/// Opens the whole window to write a post headed [heading]: a question
/// ([withTitle]), an answer, or one of them anew from [initialTitle] and
/// [initial]. Returns what was written once posted; null when left. The
/// editor inserts no images, sounds or videos, and content holding them
/// (pasted) can't be posted: the person's files are theirs alone.
Future<DiscussionDraft?> composeDiscussionPost(
BuildContext context, {
required String heading,
bool withTitle = false,
String initialTitle = '',
RichContent? initial,
}) => Navigator.of(context).push<DiscussionDraft>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => _Composer(
heading: heading,
withTitle: withTitle,
initialTitle: initialTitle,
initial: initial ?? RichContent.empty(),
),
),
);
class const _Composer({
required final String heading,
required final bool withTitle,
required final String initialTitle,
required final RichContent initial,
}) extends StatefulWidget {
@override
State<_Composer> createState() => _ComposerState();
}
class _ComposerState() extends State<_Composer> {
late final _title = TextEditingController(text: widget.initialTitle);
late var _content = widget.initial;
@override
void initState() {
super.initState();
_title.addListener(() => setState(() {}));
}
@override
void dispose() {
_title.dispose();
super.dispose();
}
bool get _withFiles => _content.hasImages || _content.hasSounds;
bool get _ready =>
(!widget.withTitle || _title.text.trim().isNotEmpty) &&
!_content.isBlank &&
!_withFiles;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final all = blocksOf(context);
// The core's blocks but files and videos, and none of the features':
// a post is read by others, who have neither the person's files nor
// their studysets.
final blocks = EditorBlocks(
builders: all.builders,
insertable: [
for (final block in coreBlocks.insertable)
if (block.group != InsertGroup.media) block,
],
shortcuts: all.shortcuts,
);
return Scaffold(
appBar: AppBar(
title: Text(widget.heading),
actions: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FilledButton(
onPressed: _ready
? () => Navigator.of(
context,
).pop((title: _title.text.trim(), content: _content))
: null,
child: Text(l10n.discussionPost),
),
),
],
),
// The editor gets a height of its own in a scrolling body: stretched
// to fill an unbounded column, it never finishes laying out.
body: LayoutBuilder(
builder: (context, constraints) => Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.withTitle) ...[
TextField(
controller: _title,
autofocus: widget.initialTitle.isEmpty,
maxLength: TextLimits.title,
buildCounter: nearLimitCounter,
textInputAction: TextInputAction.next,
decoration: requiredDecoration(
context,
InputDecoration(
border: const OutlineInputBorder(),
labelText: l10n.discussionTitleField,
hintText: l10n.discussionTitleHint,
),
missing: _title.text.trim().isEmpty,
),
),
const SizedBox(height: 8),
],
if (_withFiles) ...[
Text(
discussionRefusalText(l10n, DiscussionRefusal.withFiles),
style: TextStyle(color: theme.colorScheme.error),
),
const SizedBox(height: 8),
],
SizedBox(
height: math.max(320, constraints.maxHeight - 160),
child: EditorBlocksScope(
blocks: blocks,
child: RichContentEditor(
initialContent: widget.initial,
autofocus: !widget.withTitle,
onChanged: (content) =>
setState(() => _content = content),
formatBar: true,
),
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,63 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/bloc/discussion_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:material_ui/material_ui.dart';
/// The discussions the person follows, in their inbox (ADR 0013): those on
/// their published studysets and those they asked, answered or commented
/// in, the last active first, those with something new marked; each opens.
class const DiscussionInbox({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return BlocProvider(
create: (context) =>
DiscussionListBloc(PersonScope.of(context).discussions)
..add(const DiscussionListEventLoaded()),
child: BlocConsumer<DiscussionListBloc, DiscussionListState>(
// What the badge counts follows what the inbox shows.
listenWhen: (before, now) =>
!identical(before.questions, now.questions),
listener: (context, state) => refreshInboxCount(context),
builder: (context, state) => switch (state.status) {
DiscussionListStatus.loading => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: AppLoadingIndicator()),
),
DiscussionListStatus.unreachable => Text(
l10n.discussionsUnreachable,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
DiscussionListStatus.ready when state.questions.isEmpty => Text(
l10n.inboxDiscussionsEmpty,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
DiscussionListStatus.ready => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final summary in state.questions)
DiscussionTile(
summary: summary,
showStudyset: true,
onTap: () => context.push(
RoutePaths.discussion(summary.publicId, summary.questionId),
),
),
],
),
},
),
);
}
}

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