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:
Tizian 「ティツィアーン」 2026-10-10 01:29:09 +02:00
commit 1c1a1aada8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 2273 additions and 32 deletions

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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

View file

@ -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.';
}

View file

@ -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.';
}

View file

@ -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: [

View file

@ -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(

View file

@ -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,
),
],

View file

@ -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),

View file

@ -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,

View file

@ -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),

View file

@ -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,
),
),
),

View file

@ -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),

View file

@ -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,
),
),
],
],
),
);

View file

@ -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,

View file

@ -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];
}

View file

@ -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;

View file

@ -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);

View file

@ -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