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:
parent
25237b8011
commit
5efa552489
37 changed files with 28214 additions and 27 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue