feat(feedback): report, suggest, and an inbox in the Profile tab
The app's side of stage 4 (ADR 0005, rule 11). The card's Report sheet offers "Send to the author too" on a card from a release; a copy's menu reports the studyset as a whole and lists what is reported and suggested on it, as Explore's summary does; the editors of cards, notes and steps suggest them to the author as the learner has them, never under "no derivatives". The Profile tab holds the inbox: the threads on the person's published studysets and those they wrote, the newest activity first, with a count on the tab. A thread shows a suggestion beside the author's own item and its replies; its writer and the author reply, and the author accepts (the content becomes their own edit, its files fetched from the writer's), declines, closes, hides or blocks. Writing needs an identity; reading what is public doesn't. ADR 0005 gets an addendum for stage 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9accc95df2
commit
1c1a1aada8
27 changed files with 2273 additions and 32 deletions
|
|
@ -1449,5 +1449,53 @@
|
|||
"publishLearnersFew": "Weniger als 5 lernen es hier, von denen, die mitgezählt werden möchten.",
|
||||
"publishForkForbidden": "Sein Original verbietet, Bearbeitungen weiterzugeben: nur wer es verfasst hat, veröffentlicht es.",
|
||||
"publishLicenseMustStay": "Sein Original verlangt, dass Bearbeitungen unter {license} bleiben: wähle sie unter Bearbeiten, für jedes Lernset darin.",
|
||||
"exploreBasedOn": "nach {location}"
|
||||
"exploreBasedOn": "nach {location}",
|
||||
"feedbackReport": "Meldung",
|
||||
"feedbackSuggestion": "Vorschlag",
|
||||
"feedbackOnStudyset": "Zum Lernset",
|
||||
"feedbackOnCard": "Zu einer Karteikarte",
|
||||
"feedbackOnNote": "Zu einer Notiz",
|
||||
"feedbackOnStep": "Zu einem Schritt",
|
||||
"feedbackByOn": "{name} · {date}",
|
||||
"feedbackStatusClosed": "Geschlossen",
|
||||
"feedbackStatusAccepted": "Angenommen",
|
||||
"feedbackStatusDeclined": "Abgelehnt",
|
||||
"feedbackStatusHidden": "Verborgen",
|
||||
"feedbackReplyHint": "Schreib eine Antwort …",
|
||||
"feedbackSend": "Senden",
|
||||
"feedbackAccept": "Annehmen",
|
||||
"feedbackDecline": "Ablehnen",
|
||||
"feedbackClose": "Schließen",
|
||||
"feedbackReopen": "Wieder öffnen",
|
||||
"feedbackHide": "Verbergen",
|
||||
"feedbackShow": "Zeigen",
|
||||
"feedbackBlock": "{name} blockieren",
|
||||
"feedbackBlockTitle": "{name} blockieren?",
|
||||
"feedbackBlockMessage": "Was zu deinen Lernsets von dort kam und kommt, sieht dann niemand außer der Person selbst.",
|
||||
"feedbackProposed": "Vorgeschlagen",
|
||||
"feedbackYours": "Jetzt bei dir",
|
||||
"feedbackAuthor": "Original",
|
||||
"feedbackAccepted": "In dein Lernset übernommen. Die nächste Veröffentlichung nennt, wer es vorgeschlagen hat.",
|
||||
"feedbackDecided": "Erledigt.",
|
||||
"feedbackReplied": "Antwort gesendet.",
|
||||
"feedbackBlocked": "Blockiert.",
|
||||
"feedbackNotTaken": "Das lässt sich nicht übernehmen: worum es geht, ist nicht mehr in deinem Lernset.",
|
||||
"feedbackFailed": "Deine Instanz war nicht erreichbar. Versuche es noch einmal.",
|
||||
"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.",
|
||||
"inboxEmpty": "Noch nichts.",
|
||||
"studysetFeedback": "Meldungen und Vorschläge",
|
||||
"studysetFeedbackEmpty": "Noch hat niemand etwas gemeldet oder vorgeschlagen.",
|
||||
"reportToAuthor": "Fehler im Original melden",
|
||||
"cardReportToAuthor": "Auch im Original melden",
|
||||
"cardReportToAuthorHint": "Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.",
|
||||
"feedbackReportSent": "Im Original gemeldet.",
|
||||
"suggestToAuthor": "Dem Original vorschlagen",
|
||||
"suggestHint": "Was es ändert, und warum",
|
||||
"suggestIntro": "Im Original sieht man es so, wie du es jetzt hast, neben der eigenen Fassung. Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.",
|
||||
"suggestSent": "Vorschlag gesendet.",
|
||||
"suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.",
|
||||
"feedbackNeedsIdentity": "Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1697,5 +1697,56 @@
|
|||
"publishLicenseMustStay": "Its original asks adaptations to stay under {license}: choose it under Edit, for every studyset in it.",
|
||||
"@publishLicenseMustStay": {"placeholders": {"license": {"type": "String"}}},
|
||||
"exploreBasedOn": "based on {location}",
|
||||
"@exploreBasedOn": {"placeholders": {"location": {"type": "String"}}}
|
||||
"@exploreBasedOn": {"placeholders": {"location": {"type": "String"}}},
|
||||
"feedbackReport": "Report",
|
||||
"feedbackSuggestion": "Suggestion",
|
||||
"feedbackOnStudyset": "On the studyset",
|
||||
"feedbackOnCard": "On a flashcard",
|
||||
"feedbackOnNote": "On a note",
|
||||
"feedbackOnStep": "On a step",
|
||||
"feedbackByOn": "{name} · {date}",
|
||||
"@feedbackByOn": {"placeholders": {"name": {"type": "String"}, "date": {"type": "String"}}},
|
||||
"feedbackStatusClosed": "Closed",
|
||||
"feedbackStatusAccepted": "Accepted",
|
||||
"feedbackStatusDeclined": "Declined",
|
||||
"feedbackStatusHidden": "Hidden",
|
||||
"feedbackReplyHint": "Write a reply…",
|
||||
"feedbackSend": "Send",
|
||||
"feedbackAccept": "Accept",
|
||||
"feedbackDecline": "Decline",
|
||||
"feedbackClose": "Close",
|
||||
"feedbackReopen": "Reopen",
|
||||
"feedbackHide": "Hide",
|
||||
"feedbackShow": "Show",
|
||||
"feedbackBlock": "Block {name}",
|
||||
"@feedbackBlock": {"placeholders": {"name": {"type": "String"}}},
|
||||
"feedbackBlockTitle": "Block {name}?",
|
||||
"@feedbackBlockTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
"feedbackBlockMessage": "What they wrote on your studysets, and write, shows to nobody but them.",
|
||||
"feedbackProposed": "Proposed",
|
||||
"feedbackYours": "Yours now",
|
||||
"feedbackAuthor": "Author",
|
||||
"feedbackAccepted": "Taken into your studyset. The next release credits its writer.",
|
||||
"feedbackDecided": "Done.",
|
||||
"feedbackReplied": "Reply sent.",
|
||||
"feedbackBlocked": "Blocked.",
|
||||
"feedbackNotTaken": "This can't be taken: what it is about isn't in your studyset any more.",
|
||||
"feedbackFailed": "Couldn't reach your instance. Try again.",
|
||||
"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.",
|
||||
"inboxEmpty": "Nothing yet.",
|
||||
"studysetFeedback": "Reports and suggestions",
|
||||
"studysetFeedbackEmpty": "Nobody has reported or suggested anything yet.",
|
||||
"reportToAuthor": "Report to the author",
|
||||
"cardReportToAuthor": "Send to the author too",
|
||||
"cardReportToAuthorHint": "Public: everyone learning it reads it, with your public profile.",
|
||||
"feedbackReportSent": "Sent to the author.",
|
||||
"suggestToAuthor": "Suggest to the author",
|
||||
"suggestHint": "What it changes, and why",
|
||||
"suggestIntro": "The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.",
|
||||
"suggestSent": "Suggestion sent.",
|
||||
"suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.",
|
||||
"feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@
|
|||
"licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.",
|
||||
"publishLicence": "License",
|
||||
"publishLicenceNotOpen": "These studysets' licenses don't allow passing them on: {titles}",
|
||||
"exploreReadLicence": "Read the license"
|
||||
"exploreReadLicence": "Read the license",
|
||||
"suggestNoDerivatives": "Its license forbids adaptations: no suggestions."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8606,6 +8606,294 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'based on {location}'**
|
||||
String exploreBasedOn(String location);
|
||||
|
||||
/// No description provided for @feedbackReport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report'**
|
||||
String get feedbackReport;
|
||||
|
||||
/// No description provided for @feedbackSuggestion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suggestion'**
|
||||
String get feedbackSuggestion;
|
||||
|
||||
/// No description provided for @feedbackOnStudyset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On the studyset'**
|
||||
String get feedbackOnStudyset;
|
||||
|
||||
/// No description provided for @feedbackOnCard.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On a flashcard'**
|
||||
String get feedbackOnCard;
|
||||
|
||||
/// No description provided for @feedbackOnNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On a note'**
|
||||
String get feedbackOnNote;
|
||||
|
||||
/// No description provided for @feedbackOnStep.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On a step'**
|
||||
String get feedbackOnStep;
|
||||
|
||||
/// No description provided for @feedbackByOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name} · {date}'**
|
||||
String feedbackByOn(String name, String date);
|
||||
|
||||
/// No description provided for @feedbackStatusClosed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Closed'**
|
||||
String get feedbackStatusClosed;
|
||||
|
||||
/// No description provided for @feedbackStatusAccepted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Accepted'**
|
||||
String get feedbackStatusAccepted;
|
||||
|
||||
/// No description provided for @feedbackStatusDeclined.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Declined'**
|
||||
String get feedbackStatusDeclined;
|
||||
|
||||
/// No description provided for @feedbackStatusHidden.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hidden'**
|
||||
String get feedbackStatusHidden;
|
||||
|
||||
/// No description provided for @feedbackReplyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Write a reply…'**
|
||||
String get feedbackReplyHint;
|
||||
|
||||
/// No description provided for @feedbackSend.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send'**
|
||||
String get feedbackSend;
|
||||
|
||||
/// No description provided for @feedbackAccept.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Accept'**
|
||||
String get feedbackAccept;
|
||||
|
||||
/// No description provided for @feedbackDecline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Decline'**
|
||||
String get feedbackDecline;
|
||||
|
||||
/// No description provided for @feedbackClose.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close'**
|
||||
String get feedbackClose;
|
||||
|
||||
/// No description provided for @feedbackReopen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reopen'**
|
||||
String get feedbackReopen;
|
||||
|
||||
/// No description provided for @feedbackHide.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide'**
|
||||
String get feedbackHide;
|
||||
|
||||
/// No description provided for @feedbackShow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show'**
|
||||
String get feedbackShow;
|
||||
|
||||
/// No description provided for @feedbackBlock.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Block {name}'**
|
||||
String feedbackBlock(String name);
|
||||
|
||||
/// No description provided for @feedbackBlockTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Block {name}?'**
|
||||
String feedbackBlockTitle(String name);
|
||||
|
||||
/// No description provided for @feedbackBlockMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'What they wrote on your studysets, and write, shows to nobody but them.'**
|
||||
String get feedbackBlockMessage;
|
||||
|
||||
/// No description provided for @feedbackProposed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Proposed'**
|
||||
String get feedbackProposed;
|
||||
|
||||
/// No description provided for @feedbackYours.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Yours now'**
|
||||
String get feedbackYours;
|
||||
|
||||
/// No description provided for @feedbackAuthor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Author'**
|
||||
String get feedbackAuthor;
|
||||
|
||||
/// No description provided for @feedbackAccepted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Taken into your studyset. The next release credits its writer.'**
|
||||
String get feedbackAccepted;
|
||||
|
||||
/// No description provided for @feedbackDecided.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done.'**
|
||||
String get feedbackDecided;
|
||||
|
||||
/// No description provided for @feedbackReplied.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reply sent.'**
|
||||
String get feedbackReplied;
|
||||
|
||||
/// No description provided for @feedbackBlocked.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Blocked.'**
|
||||
String get feedbackBlocked;
|
||||
|
||||
/// No description provided for @feedbackNotTaken.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This can\'t be taken: what it is about isn\'t in your studyset any more.'**
|
||||
String get feedbackNotTaken;
|
||||
|
||||
/// No description provided for @feedbackFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t reach your instance. Try again.'**
|
||||
String get feedbackFailed;
|
||||
|
||||
/// No description provided for @feedbackUnreachable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reports and suggestions can\'t be reached right now.'**
|
||||
String get feedbackUnreachable;
|
||||
|
||||
/// No description provided for @feedbackRetry.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Retry'**
|
||||
String get feedbackRetry;
|
||||
|
||||
/// No description provided for @inboxTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Inbox'**
|
||||
String get inboxTitle;
|
||||
|
||||
/// No description provided for @inboxIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reports and suggestions on your published studysets, and those you wrote.'**
|
||||
String get inboxIntro;
|
||||
|
||||
/// No description provided for @inboxEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing yet.'**
|
||||
String get inboxEmpty;
|
||||
|
||||
/// No description provided for @studysetFeedback.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reports and suggestions'**
|
||||
String get studysetFeedback;
|
||||
|
||||
/// No description provided for @studysetFeedbackEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nobody has reported or suggested anything yet.'**
|
||||
String get studysetFeedbackEmpty;
|
||||
|
||||
/// No description provided for @reportToAuthor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report to the author'**
|
||||
String get reportToAuthor;
|
||||
|
||||
/// No description provided for @cardReportToAuthor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Send to the author too'**
|
||||
String get cardReportToAuthor;
|
||||
|
||||
/// No description provided for @cardReportToAuthorHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Public: everyone learning it reads it, with your public profile.'**
|
||||
String get cardReportToAuthorHint;
|
||||
|
||||
/// No description provided for @feedbackReportSent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sent to the author.'**
|
||||
String get feedbackReportSent;
|
||||
|
||||
/// No description provided for @suggestToAuthor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suggest to the author'**
|
||||
String get suggestToAuthor;
|
||||
|
||||
/// No description provided for @suggestHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'What it changes, and why'**
|
||||
String get suggestHint;
|
||||
|
||||
/// No description provided for @suggestIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.'**
|
||||
String get suggestIntro;
|
||||
|
||||
/// No description provided for @suggestSent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suggestion sent.'**
|
||||
String get suggestSent;
|
||||
|
||||
/// No description provided for @suggestNoDerivatives.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Its licence forbids adaptations: no suggestions.'**
|
||||
String get suggestNoDerivatives;
|
||||
|
||||
/// No description provided for @feedbackNeedsIdentity.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in with GitHub to report to the author and suggest.'**
|
||||
String get feedbackNeedsIdentity;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -5399,4 +5399,165 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String exploreBasedOn(String location) {
|
||||
return 'nach $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedbackReport => 'Meldung';
|
||||
|
||||
@override
|
||||
String get feedbackSuggestion => 'Vorschlag';
|
||||
|
||||
@override
|
||||
String get feedbackOnStudyset => 'Zum Lernset';
|
||||
|
||||
@override
|
||||
String get feedbackOnCard => 'Zu einer Karteikarte';
|
||||
|
||||
@override
|
||||
String get feedbackOnNote => 'Zu einer Notiz';
|
||||
|
||||
@override
|
||||
String get feedbackOnStep => 'Zu einem Schritt';
|
||||
|
||||
@override
|
||||
String feedbackByOn(String name, String date) {
|
||||
return '$name · $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedbackStatusClosed => 'Geschlossen';
|
||||
|
||||
@override
|
||||
String get feedbackStatusAccepted => 'Angenommen';
|
||||
|
||||
@override
|
||||
String get feedbackStatusDeclined => 'Abgelehnt';
|
||||
|
||||
@override
|
||||
String get feedbackStatusHidden => 'Verborgen';
|
||||
|
||||
@override
|
||||
String get feedbackReplyHint => 'Schreib eine Antwort …';
|
||||
|
||||
@override
|
||||
String get feedbackSend => 'Senden';
|
||||
|
||||
@override
|
||||
String get feedbackAccept => 'Annehmen';
|
||||
|
||||
@override
|
||||
String get feedbackDecline => 'Ablehnen';
|
||||
|
||||
@override
|
||||
String get feedbackClose => 'Schließen';
|
||||
|
||||
@override
|
||||
String get feedbackReopen => 'Wieder öffnen';
|
||||
|
||||
@override
|
||||
String get feedbackHide => 'Verbergen';
|
||||
|
||||
@override
|
||||
String get feedbackShow => 'Zeigen';
|
||||
|
||||
@override
|
||||
String feedbackBlock(String name) {
|
||||
return '$name blockieren';
|
||||
}
|
||||
|
||||
@override
|
||||
String feedbackBlockTitle(String name) {
|
||||
return '$name blockieren?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedbackBlockMessage =>
|
||||
'Was zu deinen Lernsets von dort kam und kommt, sieht dann niemand außer der Person selbst.';
|
||||
|
||||
@override
|
||||
String get feedbackProposed => 'Vorgeschlagen';
|
||||
|
||||
@override
|
||||
String get feedbackYours => 'Jetzt bei dir';
|
||||
|
||||
@override
|
||||
String get feedbackAuthor => 'Original';
|
||||
|
||||
@override
|
||||
String get feedbackAccepted =>
|
||||
'In dein Lernset übernommen. Die nächste Veröffentlichung nennt, wer es vorgeschlagen hat.';
|
||||
|
||||
@override
|
||||
String get feedbackDecided => 'Erledigt.';
|
||||
|
||||
@override
|
||||
String get feedbackReplied => 'Antwort gesendet.';
|
||||
|
||||
@override
|
||||
String get feedbackBlocked => 'Blockiert.';
|
||||
|
||||
@override
|
||||
String get feedbackNotTaken =>
|
||||
'Das lässt sich nicht übernehmen: worum es geht, ist nicht mehr in deinem Lernset.';
|
||||
|
||||
@override
|
||||
String get feedbackFailed =>
|
||||
'Deine Instanz war nicht erreichbar. Versuche es noch einmal.';
|
||||
|
||||
@override
|
||||
String get feedbackUnreachable =>
|
||||
'Meldungen und Vorschläge sind gerade nicht erreichbar.';
|
||||
|
||||
@override
|
||||
String get feedbackRetry => 'Erneut versuchen';
|
||||
|
||||
@override
|
||||
String get inboxTitle => 'Posteingang';
|
||||
|
||||
@override
|
||||
String get inboxIntro =>
|
||||
'Meldungen und Vorschläge zu deinen veröffentlichten Lernsets und die, die du geschrieben hast.';
|
||||
|
||||
@override
|
||||
String get inboxEmpty => 'Noch nichts.';
|
||||
|
||||
@override
|
||||
String get studysetFeedback => 'Meldungen und Vorschläge';
|
||||
|
||||
@override
|
||||
String get studysetFeedbackEmpty =>
|
||||
'Noch hat niemand etwas gemeldet oder vorgeschlagen.';
|
||||
|
||||
@override
|
||||
String get reportToAuthor => 'Fehler im Original melden';
|
||||
|
||||
@override
|
||||
String get cardReportToAuthor => 'Auch im Original melden';
|
||||
|
||||
@override
|
||||
String get cardReportToAuthorHint =>
|
||||
'Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.';
|
||||
|
||||
@override
|
||||
String get feedbackReportSent => 'Im Original gemeldet.';
|
||||
|
||||
@override
|
||||
String get suggestToAuthor => 'Dem Original vorschlagen';
|
||||
|
||||
@override
|
||||
String get suggestHint => 'Was es ändert, und warum';
|
||||
|
||||
@override
|
||||
String get suggestIntro =>
|
||||
'Im Original sieht man es so, wie du es jetzt hast, neben der eigenen Fassung. Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.';
|
||||
|
||||
@override
|
||||
String get suggestSent => 'Vorschlag gesendet.';
|
||||
|
||||
@override
|
||||
String get suggestNoDerivatives =>
|
||||
'Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.';
|
||||
|
||||
@override
|
||||
String get feedbackNeedsIdentity =>
|
||||
'Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5351,6 +5351,166 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String exploreBasedOn(String location) {
|
||||
return 'based on $location';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedbackReport => 'Report';
|
||||
|
||||
@override
|
||||
String get feedbackSuggestion => 'Suggestion';
|
||||
|
||||
@override
|
||||
String get feedbackOnStudyset => 'On the studyset';
|
||||
|
||||
@override
|
||||
String get feedbackOnCard => 'On a flashcard';
|
||||
|
||||
@override
|
||||
String get feedbackOnNote => 'On a note';
|
||||
|
||||
@override
|
||||
String get feedbackOnStep => 'On a step';
|
||||
|
||||
@override
|
||||
String feedbackByOn(String name, String date) {
|
||||
return '$name · $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedbackStatusClosed => 'Closed';
|
||||
|
||||
@override
|
||||
String get feedbackStatusAccepted => 'Accepted';
|
||||
|
||||
@override
|
||||
String get feedbackStatusDeclined => 'Declined';
|
||||
|
||||
@override
|
||||
String get feedbackStatusHidden => 'Hidden';
|
||||
|
||||
@override
|
||||
String get feedbackReplyHint => 'Write a reply…';
|
||||
|
||||
@override
|
||||
String get feedbackSend => 'Send';
|
||||
|
||||
@override
|
||||
String get feedbackAccept => 'Accept';
|
||||
|
||||
@override
|
||||
String get feedbackDecline => 'Decline';
|
||||
|
||||
@override
|
||||
String get feedbackClose => 'Close';
|
||||
|
||||
@override
|
||||
String get feedbackReopen => 'Reopen';
|
||||
|
||||
@override
|
||||
String get feedbackHide => 'Hide';
|
||||
|
||||
@override
|
||||
String get feedbackShow => 'Show';
|
||||
|
||||
@override
|
||||
String feedbackBlock(String name) {
|
||||
return 'Block $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String feedbackBlockTitle(String name) {
|
||||
return 'Block $name?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedbackBlockMessage =>
|
||||
'What they wrote on your studysets, and write, shows to nobody but them.';
|
||||
|
||||
@override
|
||||
String get feedbackProposed => 'Proposed';
|
||||
|
||||
@override
|
||||
String get feedbackYours => 'Yours now';
|
||||
|
||||
@override
|
||||
String get feedbackAuthor => 'Author';
|
||||
|
||||
@override
|
||||
String get feedbackAccepted =>
|
||||
'Taken into your studyset. The next release credits its writer.';
|
||||
|
||||
@override
|
||||
String get feedbackDecided => 'Done.';
|
||||
|
||||
@override
|
||||
String get feedbackReplied => 'Reply sent.';
|
||||
|
||||
@override
|
||||
String get feedbackBlocked => 'Blocked.';
|
||||
|
||||
@override
|
||||
String get feedbackNotTaken =>
|
||||
'This can\'t be taken: what it is about isn\'t in your studyset any more.';
|
||||
|
||||
@override
|
||||
String get feedbackFailed => 'Couldn\'t reach your instance. Try again.';
|
||||
|
||||
@override
|
||||
String get feedbackUnreachable =>
|
||||
'Reports and suggestions can\'t be reached right now.';
|
||||
|
||||
@override
|
||||
String get feedbackRetry => 'Retry';
|
||||
|
||||
@override
|
||||
String get inboxTitle => 'Inbox';
|
||||
|
||||
@override
|
||||
String get inboxIntro =>
|
||||
'Reports and suggestions on your published studysets, and those you wrote.';
|
||||
|
||||
@override
|
||||
String get inboxEmpty => 'Nothing yet.';
|
||||
|
||||
@override
|
||||
String get studysetFeedback => 'Reports and suggestions';
|
||||
|
||||
@override
|
||||
String get studysetFeedbackEmpty =>
|
||||
'Nobody has reported or suggested anything yet.';
|
||||
|
||||
@override
|
||||
String get reportToAuthor => 'Report to the author';
|
||||
|
||||
@override
|
||||
String get cardReportToAuthor => 'Send to the author too';
|
||||
|
||||
@override
|
||||
String get cardReportToAuthorHint =>
|
||||
'Public: everyone learning it reads it, with your public profile.';
|
||||
|
||||
@override
|
||||
String get feedbackReportSent => 'Sent to the author.';
|
||||
|
||||
@override
|
||||
String get suggestToAuthor => 'Suggest to the author';
|
||||
|
||||
@override
|
||||
String get suggestHint => 'What it changes, and why';
|
||||
|
||||
@override
|
||||
String get suggestIntro =>
|
||||
'The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.';
|
||||
|
||||
@override
|
||||
String get suggestSent => 'Suggestion sent.';
|
||||
|
||||
@override
|
||||
String get suggestNoDerivatives =>
|
||||
'Its licence forbids adaptations: no suggestions.';
|
||||
|
||||
@override
|
||||
String get feedbackNeedsIdentity =>
|
||||
'Sign in with GitHub to report to the author and suggest.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
@ -5439,4 +5599,8 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
|
|||
|
||||
@override
|
||||
String get exploreReadLicence => 'Read the license';
|
||||
|
||||
@override
|
||||
String get suggestNoDerivatives =>
|
||||
'Its license forbids adaptations: no suggestions.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_scree
|
|||
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/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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
|
||||
|
|
@ -41,8 +42,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
StatefulShellRoute(
|
||||
builder: (context, state, navigationShell) =>
|
||||
AppShell(navigationShell: navigationShell),
|
||||
builder: (context, state, navigationShell) => AppShell(
|
||||
navigationShell: navigationShell,
|
||||
// The inbox's threads with something new.
|
||||
profileBadge: (context) =>
|
||||
context.select<InboxCountBloc?, int>((bloc) => bloc?.state ?? 0),
|
||||
),
|
||||
navigatorContainerBuilder: tabsContainer,
|
||||
// In the order of the shell's destinations.
|
||||
branches: [
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_presenter.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_button_block.dart';
|
||||
|
|
@ -85,6 +86,12 @@ class _PersonAppState() extends State<PersonApp> {
|
|||
SyncStatusBloc(PersonScope.of(context).sync)
|
||||
..add(const SyncStatusEventStarted()),
|
||||
),
|
||||
// What is new in the inbox, for the Profile tab's badge.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
InboxCountBloc(PersonScope.of(context).feedback)
|
||||
..add(const InboxCountEventRefreshed()),
|
||||
),
|
||||
// The Pomodoro timer goes on across every screen.
|
||||
BlocProvider(
|
||||
create: (context) => PomodoroBloc(
|
||||
|
|
|
|||
|
|
@ -14,11 +14,25 @@ import 'package:material_ui/material_ui.dart';
|
|||
class const AppShell({
|
||||
/// The tabs' navigators, from `StatefulShellRoute`.
|
||||
required final StatefulNavigationShell navigationShell,
|
||||
|
||||
/// How many things are new behind the Profile tab, for its badge; none
|
||||
/// without.
|
||||
final int Function(BuildContext context)? profileBadge,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final destinations = _destinations(AppLocalizations.of(context));
|
||||
// The Profile tab says how many things are new behind it.
|
||||
final unseen = profileBadge?.call(context) ?? 0;
|
||||
Widget icon(_Destination destination, IconData data) =>
|
||||
identical(destination, destinations.last)
|
||||
? Badge.count(
|
||||
count: unseen,
|
||||
isLabelVisible: unseen > 0,
|
||||
child: Icon(data),
|
||||
)
|
||||
: Icon(data);
|
||||
if (context.windowSize.isAtLeastMedium) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
|
|
@ -31,8 +45,8 @@ class const AppShell({
|
|||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: Icon(destination.icon),
|
||||
selectedIcon: Icon(destination.selectedIcon),
|
||||
icon: icon(destination, destination.icon),
|
||||
selectedIcon: icon(destination, destination.selectedIcon),
|
||||
label: Text(destination.label),
|
||||
),
|
||||
],
|
||||
|
|
@ -51,8 +65,8 @@ class const AppShell({
|
|||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationDestination(
|
||||
icon: Icon(destination.icon),
|
||||
selectedIcon: Icon(destination.selectedIcon),
|
||||
icon: icon(destination, destination.icon),
|
||||
selectedIcon: icon(destination, destination.selectedIcon),
|
||||
label: destination.label,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -109,6 +110,10 @@ class const _Account() extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 24),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 24),
|
||||
const InboxSection(),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 24),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ class CardComposerBloc(
|
|||
/// The stored cards, by id, as last read.
|
||||
var _stored = <String, Card>{};
|
||||
|
||||
/// The studyset the cards are written in.
|
||||
String get studysetId => _studysetId;
|
||||
|
||||
Future<void> _onStarted(
|
||||
CardComposerEventStarted event,
|
||||
Emitter<CardComposerState> emit,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/manage_tags_sheet.dart';
|
||||
|
|
@ -316,9 +318,21 @@ class const _SliverCards(
|
|||
tags: tagging.of(card.id),
|
||||
buried: buried.contains(card.id),
|
||||
onReport: () async {
|
||||
if (await showReportSheet(context) case final report?
|
||||
when context.mounted) {
|
||||
final origin = card.content.published;
|
||||
if (await showReportSheet(
|
||||
context,
|
||||
offerAuthor: fromRelease(origin) && canWriteToAuthors(context),
|
||||
)
|
||||
case (:final report, :final toAuthor) when context.mounted) {
|
||||
_report(context, card, report);
|
||||
if (toAuthor && origin != null) {
|
||||
await sendReportToAuthor(
|
||||
context,
|
||||
origin,
|
||||
FeedbackTargetKind.card,
|
||||
report,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onResolve: () => _report(context, card, null),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -264,6 +265,19 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
focusFront: _focusFront,
|
||||
onDuplicate: () => _duplicate(selected),
|
||||
onDelete: () => _delete(selected),
|
||||
onSuggest: fromRelease(selected.content.published)
|
||||
? () => suggestToAuthor(
|
||||
context,
|
||||
studysetId: context
|
||||
.read<CardComposerBloc>()
|
||||
.studysetId,
|
||||
send: (feedback, body) =>
|
||||
feedback.suggestCard(
|
||||
selected.content.normalized,
|
||||
body: body,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ class const CardDraftEditor({
|
|||
|
||||
/// Deletes the card, asking where it matters.
|
||||
required final VoidCallback onDelete,
|
||||
|
||||
/// Suggests the card as it is to the author of the published studyset it
|
||||
/// came from; null for a card of the person's own.
|
||||
final VoidCallback? onSuggest,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -320,6 +324,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
onDuplicate: widget.onDuplicate,
|
||||
onClear: () => _send(CardComposerEventCleared(draft.id)),
|
||||
onDelete: widget.onDelete,
|
||||
onSuggest: widget.onSuggest,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -611,13 +616,15 @@ class const _MapAnswer({
|
|||
}
|
||||
|
||||
/// The card's actions: add a hint or the explanation (null while it can't),
|
||||
/// duplicate, clear its fields, delete.
|
||||
/// duplicate, clear its fields, delete; for a card of a copy, suggest it to
|
||||
/// the author.
|
||||
class const _Actions({
|
||||
required final VoidCallback? onAddHint,
|
||||
required final VoidCallback? onAddExplanation,
|
||||
required final VoidCallback onDuplicate,
|
||||
required final VoidCallback onClear,
|
||||
required final VoidCallback onDelete,
|
||||
required final VoidCallback? onSuggest,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -634,6 +641,14 @@ class const _Actions({
|
|||
onPressed: onAddExplanation,
|
||||
child: Text(l10n.cardAddExplanation),
|
||||
),
|
||||
if (onSuggest != null) ...[
|
||||
const Divider(height: 1),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.pencilLine, size: 18),
|
||||
onPressed: onSuggest,
|
||||
child: Text(l10n.suggestToAuthor),
|
||||
),
|
||||
],
|
||||
const Divider(height: 1),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.copy, size: 18),
|
||||
|
|
|
|||
|
|
@ -5,15 +5,27 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A report as the sheet completes with it, and whether it goes to the
|
||||
/// author of the published studyset the card came from too.
|
||||
typedef ReportChoice = ({CardReport report, bool toAuthor});
|
||||
|
||||
/// Asks what is wrong with a card: a category and a note, starting from its
|
||||
/// [current] report, if any. Completes with the report, made now, or with
|
||||
/// null when the person closes the sheet.
|
||||
Future<CardReport?> showReportSheet(
|
||||
/// [current] report, if any; with [offerAuthor] (a card from a release), also
|
||||
/// whether to send it to the author. With [authorOnly], the report goes to
|
||||
/// the author alone (a studyset as a whole). Completes with the report,
|
||||
/// made now, or with null when the person closes the sheet.
|
||||
Future<ReportChoice?> showReportSheet(
|
||||
BuildContext context, {
|
||||
CardReport? current,
|
||||
}) => showAdaptiveSheet<CardReport>(
|
||||
bool offerAuthor = false,
|
||||
bool authorOnly = false,
|
||||
}) => showAdaptiveSheet<ReportChoice>(
|
||||
context,
|
||||
builder: (context) => _ReportSheet(current: current),
|
||||
builder: (context) => _ReportSheet(
|
||||
current: current,
|
||||
offerAuthor: offerAuthor,
|
||||
authorOnly: authorOnly,
|
||||
),
|
||||
);
|
||||
|
||||
/// [category]'s name as the person sees it.
|
||||
|
|
@ -25,8 +37,11 @@ String reportCategoryLabel(AppLocalizations l10n, ReportCategory category) =>
|
|||
ReportCategory.other => l10n.cardReportOther,
|
||||
};
|
||||
|
||||
class const _ReportSheet({required final CardReport? current})
|
||||
extends StatefulWidget {
|
||||
class const _ReportSheet({
|
||||
required final CardReport? current,
|
||||
required final bool offerAuthor,
|
||||
required final bool authorOnly,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_ReportSheet> createState() => _ReportSheetState();
|
||||
}
|
||||
|
|
@ -34,6 +49,7 @@ class const _ReportSheet({required final CardReport? current})
|
|||
class _ReportSheetState() extends State<_ReportSheet> {
|
||||
late var _category = widget.current?.category ?? ReportCategory.typo;
|
||||
late final _note = TextEditingController(text: widget.current?.note);
|
||||
var _toAuthor = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -46,17 +62,18 @@ class _ReportSheetState() extends State<_ReportSheet> {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return SheetScaffold(
|
||||
title: l10n.cardReportTitle,
|
||||
title: widget.authorOnly ? l10n.reportToAuthor : l10n.cardReportTitle,
|
||||
footer: FilledButton.icon(
|
||||
icon: const Icon(LucideIcons.flag, size: 18),
|
||||
label: Text(l10n.cardReportSubmit),
|
||||
onPressed: () => Navigator.of(context).pop(
|
||||
CardReport(
|
||||
onPressed: () => Navigator.of(context).pop((
|
||||
report: CardReport(
|
||||
category: _category,
|
||||
note: _note.text.trim(),
|
||||
at: clock.now().toUtc(),
|
||||
),
|
||||
),
|
||||
toAuthor: widget.authorOnly || _toAuthor,
|
||||
)),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -94,6 +111,23 @@ class _ReportSheetState() extends State<_ReportSheet> {
|
|||
hintText: l10n.cardReportNoteHint,
|
||||
),
|
||||
),
|
||||
if (widget.offerAuthor && !widget.authorOnly)
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.cardReportToAuthor),
|
||||
subtitle: Text(l10n.cardReportToAuthorHint),
|
||||
value: _toAuthor,
|
||||
onChanged: (on) => setState(() => _toAuthor = on),
|
||||
),
|
||||
if (widget.authorOnly) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.cardReportToAuthorHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_threads.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows the summary of the published [studyset]: its title, author, whole
|
||||
|
|
@ -88,7 +90,20 @@ class const _Summary({required final ExploreStudyset studyset})
|
|||
const SizedBox(height: 4),
|
||||
Text(studyset.notes),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.messagesSquare, size: 18),
|
||||
label: Text(l10n.studysetFeedback),
|
||||
onPressed: () => showStudysetFeedback(
|
||||
context,
|
||||
publicId: studyset.publicId,
|
||||
title: studyset.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LicenseFooter(
|
||||
content: StudysetContent(
|
||||
title: studyset.title,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
|
||||
|
||||
/// A list of threads of reports and suggestions: the person's inbox (no
|
||||
/// [_publicId]), or those on the published studyset [_publicId]; and what
|
||||
/// the person does in them. Every action reads the list anew.
|
||||
class FeedbackListBloc(
|
||||
final FeedbackRepository _feedback, {
|
||||
final String? _publicId,
|
||||
}) extends Bloc<FeedbackListEvent, FeedbackListState> {
|
||||
this : super(const FeedbackListState()) {
|
||||
on<FeedbackListEventLoaded>((event, emit) => _load(emit));
|
||||
on<FeedbackListEventOpened>((event, emit) async {
|
||||
final thread = event.thread;
|
||||
if (thread.forMe && thread.kind == FeedbackKind.suggestion) {
|
||||
try {
|
||||
final current = await _feedback.currentOf(thread.target);
|
||||
emit(
|
||||
state.copyWith(
|
||||
current: {...state.current, thread.threadId: current},
|
||||
),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Reading the item failed', name: 'Feedback', error: error);
|
||||
}
|
||||
}
|
||||
if (thread.unseen) {
|
||||
await _act(emit, () => _feedback.markSeen(thread));
|
||||
}
|
||||
});
|
||||
on<FeedbackListEventReplied>(
|
||||
(event, emit) => _act(
|
||||
emit,
|
||||
() => _feedback.reply(event.thread, event.body),
|
||||
outcome: FeedbackOutcome.replied,
|
||||
),
|
||||
);
|
||||
on<FeedbackListEventDecided>(
|
||||
(event, emit) => _act(
|
||||
emit,
|
||||
() => _feedback.decide(event.thread, event.decision),
|
||||
outcome: FeedbackOutcome.decided,
|
||||
),
|
||||
);
|
||||
on<FeedbackListEventAccepted>(
|
||||
(event, emit) => _act(
|
||||
emit,
|
||||
() => _feedback.accept(event.thread),
|
||||
outcome: FeedbackOutcome.accepted,
|
||||
),
|
||||
);
|
||||
on<FeedbackListEventBlocked>(
|
||||
(event, emit) => _act(
|
||||
emit,
|
||||
() => _feedback.block(event.person),
|
||||
outcome: FeedbackOutcome.blocked,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load(Emitter<FeedbackListState> emit) async {
|
||||
try {
|
||||
final threads = switch (_publicId) {
|
||||
final publicId? => await _feedback.threadsOf(publicId),
|
||||
null => await _feedback.inbox(),
|
||||
};
|
||||
emit(
|
||||
FeedbackListState(
|
||||
status: FeedbackListStatus.ready,
|
||||
threads: threads,
|
||||
current: state.current,
|
||||
),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Reading feedback failed', name: 'Feedback', error: error);
|
||||
emit(const FeedbackListState(status: FeedbackListStatus.unreachable));
|
||||
}
|
||||
}
|
||||
|
||||
/// Does [action], says [outcome] once it is done, and reads the list
|
||||
/// anew; says what went wrong otherwise.
|
||||
Future<void> _act(
|
||||
Emitter<FeedbackListState> emit,
|
||||
Future<void> Function() action, {
|
||||
FeedbackOutcome? outcome,
|
||||
}) async {
|
||||
try {
|
||||
await action();
|
||||
} on SuggestionNotTaken {
|
||||
emit(state.copyWith(outcome: FeedbackOutcome.notTaken));
|
||||
return;
|
||||
} on Object catch (error) {
|
||||
log('Acting on feedback failed', name: 'Feedback', error: error);
|
||||
emit(state.copyWith(outcome: FeedbackOutcome.failed));
|
||||
return;
|
||||
}
|
||||
await _load(emit);
|
||||
if (outcome != null) {
|
||||
emit(state.copyWith(outcome: outcome));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the list stands.
|
||||
enum FeedbackListStatus() {
|
||||
loading,
|
||||
ready,
|
||||
unreachable,
|
||||
}
|
||||
|
||||
/// What an action came to, said once.
|
||||
enum FeedbackOutcome() {
|
||||
replied,
|
||||
decided,
|
||||
accepted,
|
||||
blocked,
|
||||
|
||||
/// A suggestion that couldn't be taken.
|
||||
notTaken,
|
||||
|
||||
/// The instance couldn't be reached.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The list as its screen shows it, with the author's own items that
|
||||
/// suggestions opened would change ([current], their payloads' JSON by
|
||||
/// thread, null for one gone).
|
||||
final class const FeedbackListState({
|
||||
final FeedbackListStatus status = FeedbackListStatus.loading,
|
||||
final List<FeedbackThread> threads = const [],
|
||||
final Map<String, Map<String, Object?>?> current = const {},
|
||||
final FeedbackOutcome? outcome,
|
||||
}) with Equatable {
|
||||
/// This state with [current] as given, and [outcome] (cleared unless
|
||||
/// given).
|
||||
FeedbackListState copyWith({
|
||||
Map<String, Map<String, Object?>?>? current,
|
||||
FeedbackOutcome? outcome,
|
||||
}) => FeedbackListState(
|
||||
status: status,
|
||||
threads: threads,
|
||||
current: current ?? this.current,
|
||||
outcome: outcome,
|
||||
);
|
||||
|
||||
/// The thread [threadId] as read last; null when it is gone.
|
||||
FeedbackThread? byId(String threadId) =>
|
||||
threads.where((thread) => thread.threadId == threadId).firstOrNull;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [status, threads, current, outcome];
|
||||
}
|
||||
|
||||
/// What happens to the list.
|
||||
sealed class const FeedbackListEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The list shows, or is asked for anew.
|
||||
final class const FeedbackListEventLoaded() extends FeedbackListEvent;
|
||||
|
||||
/// The person opened [thread]: it is read, and a suggestion to them shows
|
||||
/// beside their own item.
|
||||
final class const FeedbackListEventOpened(final FeedbackThread thread)
|
||||
extends FeedbackListEvent {
|
||||
@override
|
||||
List<Object?> get props => [thread];
|
||||
}
|
||||
|
||||
/// The person replied [body] in [thread].
|
||||
final class const FeedbackListEventReplied(
|
||||
final FeedbackThread thread,
|
||||
final String body,
|
||||
) extends FeedbackListEvent {
|
||||
@override
|
||||
List<Object?> get props => [thread, body];
|
||||
}
|
||||
|
||||
/// The author decided [decision] on [thread].
|
||||
final class const FeedbackListEventDecided(
|
||||
final FeedbackThread thread,
|
||||
final FeedbackDecision decision,
|
||||
) extends FeedbackListEvent {
|
||||
@override
|
||||
List<Object?> get props => [thread, decision];
|
||||
}
|
||||
|
||||
/// The author takes the suggestion [thread] into their studyset.
|
||||
final class const FeedbackListEventAccepted(final FeedbackThread thread)
|
||||
extends FeedbackListEvent {
|
||||
@override
|
||||
List<Object?> get props => [thread];
|
||||
}
|
||||
|
||||
/// The author keeps [person] from writing on their studysets.
|
||||
final class const FeedbackListEventBlocked(final FeedbackPerson person)
|
||||
extends FeedbackListEvent {
|
||||
@override
|
||||
List<Object?> get props => [person];
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
|
||||
|
||||
/// How many threads of the person's inbox have something new, for the
|
||||
/// Profile tab's badge; 0 while the instance can't tell (offline, or a
|
||||
/// person without an identity, who has no inbox).
|
||||
class InboxCountBloc(final FeedbackRepository _feedback)
|
||||
extends Bloc<InboxCountEvent, int> {
|
||||
this : super(0) {
|
||||
on<InboxCountEventRefreshed>((event, emit) async {
|
||||
try {
|
||||
emit(await _feedback.unseen());
|
||||
} on Object {
|
||||
// Nothing to count now; the next refresh tries again.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to the count.
|
||||
sealed class const InboxCountEvent();
|
||||
|
||||
/// Count anew: at start, when the inbox was read or acted on.
|
||||
final class const InboxCountEventRefreshed() extends InboxCountEvent;
|
||||
|
|
@ -207,6 +207,27 @@ class FeedbackRepository(
|
|||
await decide(thread, FeedbackDecision.accepted);
|
||||
}
|
||||
|
||||
/// The author's own item [target] names, as its payload's JSON, for a
|
||||
/// suggestion to show beside; null when it is gone or isn't one.
|
||||
Future<Map<String, Object?>?> currentOf(FeedbackTarget target) async =>
|
||||
switch (target.kind) {
|
||||
FeedbackTargetKind.card => switch (await _cards.byId(target.item)) {
|
||||
final card? => _json(encodeCardPayload(card.content)),
|
||||
null => null,
|
||||
},
|
||||
FeedbackTargetKind.note => switch (await _notes.byId(target.item)) {
|
||||
final note? => _json(encodeNotePayload(note.content)),
|
||||
null => null,
|
||||
},
|
||||
FeedbackTargetKind.step => switch (await _paths.stepById(
|
||||
target.item,
|
||||
)) {
|
||||
(:final step, pathId: _) => _json(encodeStepPayload(step.content)),
|
||||
null => null,
|
||||
},
|
||||
FeedbackTargetKind.studyset => null,
|
||||
};
|
||||
|
||||
/// The threads on the published studyset [publicId] the person may see.
|
||||
Future<List<FeedbackThread>> threadsOf(String publicId) =>
|
||||
_server.threadsOf(publicId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/bloc/inbox_count_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/model/open_license.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// Reporting and suggesting to the author of a published studyset (ADR 0005,
|
||||
// rule 6), from the places a learner meets what they report or would change:
|
||||
// the card's report, the editors, a copy's menu. Writing goes into the
|
||||
// person's own synced space, offline too; the person needs an identity, as
|
||||
// anything involving other people does.
|
||||
|
||||
/// Whether the person may write to authors: they have an identity.
|
||||
bool canWriteToAuthors(BuildContext context) =>
|
||||
switch (context.read<SessionBloc>().state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Whether [origin] names an item of a copy the person may report on or
|
||||
/// suggest a change to: from a release, and not withdrawn by its author.
|
||||
bool fromRelease(PublishedRef? origin) => origin != null && !origin.withdrawn;
|
||||
|
||||
/// Sends [report] on the item [origin] names, of [kind], to the author of
|
||||
/// the published studyset it came from; says how it went.
|
||||
Future<void> sendReportToAuthor(
|
||||
BuildContext context,
|
||||
PublishedRef origin,
|
||||
FeedbackTargetKind kind,
|
||||
CardReport report,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final feedback = PersonScope.of(context).feedback;
|
||||
await feedback.report(
|
||||
origin,
|
||||
kind: kind,
|
||||
category: report.category,
|
||||
body: report.note,
|
||||
);
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.feedbackReportSent)));
|
||||
}
|
||||
|
||||
/// Asks what a suggestion changes and why, then suggests it through [send]
|
||||
/// to the author of the published studyset the studyset [studysetId] came
|
||||
/// from; says how it went. Says instead why it can't: the person has no
|
||||
/// identity, or the studyset's licence forbids adaptations.
|
||||
Future<void> suggestToAuthor(
|
||||
BuildContext context, {
|
||||
required String studysetId,
|
||||
required Future<void> Function(FeedbackRepository feedback, String body) send,
|
||||
}) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final person = PersonScope.of(context);
|
||||
void say(String message) => messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
if (!canWriteToAuthors(context)) {
|
||||
say(l10n.feedbackNeedsIdentity);
|
||||
return;
|
||||
}
|
||||
final license = (await person.studysets.all())
|
||||
.where((studyset) => studyset.id == studysetId)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.license;
|
||||
if (license != null && forbidsAdaptations(license)) {
|
||||
say(l10n.suggestNoDerivatives);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final body = await showFeedbackMessageSheet(
|
||||
context,
|
||||
title: l10n.suggestToAuthor,
|
||||
intro: l10n.suggestIntro,
|
||||
hint: l10n.suggestHint,
|
||||
);
|
||||
if (body == null) {
|
||||
return;
|
||||
}
|
||||
await send(person.feedback, body);
|
||||
say(l10n.suggestSent);
|
||||
}
|
||||
|
||||
/// Asks for a message: [title] on top, [intro] above the field, [hint] in
|
||||
/// it; completes with what was written (perhaps empty), or null when the
|
||||
/// person closes the sheet.
|
||||
Future<String?> showFeedbackMessageSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String hint,
|
||||
String? intro,
|
||||
}) => showAdaptiveSheet<String>(
|
||||
context,
|
||||
builder: (context) => _MessageSheet(title: title, intro: intro, hint: hint),
|
||||
);
|
||||
|
||||
class const _MessageSheet({
|
||||
required final String title,
|
||||
required final String? intro,
|
||||
required final String hint,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_MessageSheet> createState() => _MessageSheetState();
|
||||
}
|
||||
|
||||
class _MessageSheetState() extends State<_MessageSheet> {
|
||||
final _text = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_text.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return SheetScaffold(
|
||||
title: widget.title,
|
||||
footer: FilledButton.icon(
|
||||
icon: const Icon(LucideIcons.send, size: 18),
|
||||
label: Text(l10n.feedbackSend),
|
||||
onPressed: () => Navigator.of(context).pop(_text.text.trim()),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.intro case final intro?) ...[
|
||||
Text(
|
||||
intro,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextField(
|
||||
controller: _text,
|
||||
minLines: 3,
|
||||
maxLines: 8,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: widget.hint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the inbox anew, where the Profile tab's badge shows it.
|
||||
void refreshInboxCount(BuildContext context) =>
|
||||
context.read<InboxCountBloc?>()?.add(const InboxCountEventRefreshed());
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue