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
29 changed files with 2273 additions and 32 deletions

View file

@ -159,6 +159,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity. - **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. - **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it. - **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.

View file

@ -93,6 +93,21 @@ requests behind a gateway. This ADR decides it, and the order it is built in.
take a studyset off Explore again: nobody can add it then, and copies get no updates; take a studyset off Explore again: nobody can add it then, and copies get no updates;
its releases stay. its releases stay.
## Addendum: stage 4, reports and suggestions (2026-10-09)
11. **Reports and suggestions are public, gathered by the instance from each writer's own
space.** A report or suggestion on a published studyset (or one of its cards, notes or
steps), its replies and its author's decisions are rows of their writers' own synced
spaces, so writing works offline; the instance gathers them when they are read, and
decides then what counts: threads from people with an identity and a public profile the
author hasn't blocked, replies from the writer and the author alone, decisions from the
author alone, no suggestion under "no derivatives". Everyone signed in reads them, each
with its writer's public profile, but for those the author hides (then only writer and
author see them). The author replies, closes, hides, accepts or declines a suggestion,
and blocks people from writing on their studysets. A suggestion is a card, note or step
as its writer has it; accepted, it becomes the author's own edit, its files fetched from
the writer's, and the next release credits the writer (`Co-authored-by`).
## Consequences ## Consequences
- An instance's operator chooses an account domain once, and keeps WebFinger answering - An instance's operator chooses an account domain once, and keeps WebFinger answering

View file

@ -1449,5 +1449,53 @@
"publishLearnersFew": "Weniger als 5 lernen es hier, von denen, die mitgezählt werden möchten.", "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.", "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.", "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": "Its original asks adaptations to stay under {license}: choose it under Edit, for every studyset in it.",
"@publishLicenseMustStay": {"placeholders": {"license": {"type": "String"}}}, "@publishLicenseMustStay": {"placeholders": {"license": {"type": "String"}}},
"exploreBasedOn": "based on {location}", "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.", "licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.",
"publishLicence": "License", "publishLicence": "License",
"publishLicenceNotOpen": "These studysets' licenses don't allow passing them on: {titles}", "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: /// In en, this message translates to:
/// **'based on {location}'** /// **'based on {location}'**
String exploreBasedOn(String 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 class _AppLocalizationsDelegate

View file

@ -5399,4 +5399,165 @@ class AppLocalizationsDe extends AppLocalizations {
String exploreBasedOn(String location) { String exploreBasedOn(String location) {
return 'nach $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) { String exploreBasedOn(String location) {
return 'based on $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`). /// The translations for English, as used in the United Kingdom (`en_GB`).
@ -5439,4 +5599,8 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
@override @override
String get exploreReadLicence => 'Read the license'; 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/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/dictionary/widget/dictionary_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_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/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_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_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, initialLocation: initialLocation,
routes: [ routes: [
StatefulShellRoute( StatefulShellRoute(
builder: (context, state, navigationShell) => builder: (context, state, navigationShell) => AppShell(
AppShell(navigationShell: navigationShell), navigationShell: navigationShell,
// The inbox's threads with something new.
profileBadge: (context) =>
context.select<InboxCountBloc?, int>((bloc) => bloc?.state ?? 0),
),
navigatorContainerBuilder: tabsContainer, navigatorContainerBuilder: tabsContainer,
// In the order of the shell's destinations. // In the order of the shell's destinations.
branches: [ 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/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.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/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/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_presenter.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'; 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) SyncStatusBloc(PersonScope.of(context).sync)
..add(const SyncStatusEventStarted()), ..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. // The Pomodoro timer goes on across every screen.
BlocProvider( BlocProvider(
create: (context) => PomodoroBloc( create: (context) => PomodoroBloc(

View file

@ -14,11 +14,25 @@ import 'package:material_ui/material_ui.dart';
class const AppShell({ class const AppShell({
/// The tabs' navigators, from `StatefulShellRoute`. /// The tabs' navigators, from `StatefulShellRoute`.
required final StatefulNavigationShell navigationShell, 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, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final destinations = _destinations(AppLocalizations.of(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) { if (context.windowSize.isAtLeastMedium) {
return Scaffold( return Scaffold(
body: Row( body: Row(
@ -31,8 +45,8 @@ class const AppShell({
destinations: [ destinations: [
for (final destination in destinations) for (final destination in destinations)
NavigationRailDestination( NavigationRailDestination(
icon: Icon(destination.icon), icon: icon(destination, destination.icon),
selectedIcon: Icon(destination.selectedIcon), selectedIcon: icon(destination, destination.selectedIcon),
label: Text(destination.label), label: Text(destination.label),
), ),
], ],
@ -51,8 +65,8 @@ class const AppShell({
destinations: [ destinations: [
for (final destination in destinations) for (final destination in destinations)
NavigationDestination( NavigationDestination(
icon: Icon(destination.icon), icon: icon(destination, destination.icon),
selectedIcon: Icon(destination.selectedIcon), selectedIcon: icon(destination, destination.selectedIcon),
label: destination.label, 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/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/github_sign_in_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_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:halkyon_learn_flutter/src/feature/profile/widget/public_profile_section.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -109,6 +110,10 @@ class const _Account() extends StatelessWidget {
), ),
), ),
] else ...[ ] else ...[
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 24),
const InboxSection(),
const SizedBox(height: 24), const SizedBox(height: 24),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 24), const SizedBox(height: 24),

View file

@ -77,6 +77,9 @@ class CardComposerBloc(
/// The stored cards, by id, as last read. /// The stored cards, by id, as last read.
var _stored = <String, Card>{}; var _stored = <String, Card>{};
/// The studyset the cards are written in.
String get studysetId => _studysetId;
Future<void> _onStarted( Future<void> _onStarted(
CardComposerEventStarted event, CardComposerEventStarted event,
Emitter<CardComposerState> emit, 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_info.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.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/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/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.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'; 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), tags: tagging.of(card.id),
buried: buried.contains(card.id), buried: buried.contains(card.id),
onReport: () async { onReport: () async {
if (await showReportSheet(context) case final report? final origin = card.content.published;
when context.mounted) { if (await showReportSheet(
context,
offerAuthor: fromRelease(origin) && canWriteToAuthors(context),
)
case (:final report, :final toAuthor) when context.mounted) {
_report(context, card, report); _report(context, card, report);
if (toAuthor && origin != null) {
await sendReportToAuthor(
context,
origin,
FeedbackTargetKind.card,
report,
);
}
} }
}, },
onResolve: () => _report(context, card, null), 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/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_draft_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -264,6 +265,19 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
focusFront: _focusFront, focusFront: _focusFront,
onDuplicate: () => _duplicate(selected), onDuplicate: () => _duplicate(selected),
onDelete: () => _delete(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. /// Deletes the card, asking where it matters.
required final VoidCallback onDelete, 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, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
@ -320,6 +324,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
onDuplicate: widget.onDuplicate, onDuplicate: widget.onDuplicate,
onClear: () => _send(CardComposerEventCleared(draft.id)), onClear: () => _send(CardComposerEventCleared(draft.id)),
onDelete: widget.onDelete, 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), /// 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({ class const _Actions({
required final VoidCallback? onAddHint, required final VoidCallback? onAddHint,
required final VoidCallback? onAddExplanation, required final VoidCallback? onAddExplanation,
required final VoidCallback onDuplicate, required final VoidCallback onDuplicate,
required final VoidCallback onClear, required final VoidCallback onClear,
required final VoidCallback onDelete, required final VoidCallback onDelete,
required final VoidCallback? onSuggest,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -634,6 +641,14 @@ class const _Actions({
onPressed: onAddExplanation, onPressed: onAddExplanation,
child: Text(l10n.cardAddExplanation), 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), const Divider(height: 1),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.copy, size: 18), 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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.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 /// 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 /// [current] report, if any; with [offerAuthor] (a card from a release), also
/// null when the person closes the sheet. /// whether to send it to the author. With [authorOnly], the report goes to
Future<CardReport?> showReportSheet( /// 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, { BuildContext context, {
CardReport? current, CardReport? current,
}) => showAdaptiveSheet<CardReport>( bool offerAuthor = false,
bool authorOnly = false,
}) => showAdaptiveSheet<ReportChoice>(
context, context,
builder: (context) => _ReportSheet(current: current), builder: (context) => _ReportSheet(
current: current,
offerAuthor: offerAuthor,
authorOnly: authorOnly,
),
); );
/// [category]'s name as the person sees it. /// [category]'s name as the person sees it.
@ -25,8 +37,11 @@ String reportCategoryLabel(AppLocalizations l10n, ReportCategory category) =>
ReportCategory.other => l10n.cardReportOther, ReportCategory.other => l10n.cardReportOther,
}; };
class const _ReportSheet({required final CardReport? current}) class const _ReportSheet({
extends StatefulWidget { required final CardReport? current,
required final bool offerAuthor,
required final bool authorOnly,
}) extends StatefulWidget {
@override @override
State<_ReportSheet> createState() => _ReportSheetState(); State<_ReportSheet> createState() => _ReportSheetState();
} }
@ -34,6 +49,7 @@ class const _ReportSheet({required final CardReport? current})
class _ReportSheetState() extends State<_ReportSheet> { class _ReportSheetState() extends State<_ReportSheet> {
late var _category = widget.current?.category ?? ReportCategory.typo; late var _category = widget.current?.category ?? ReportCategory.typo;
late final _note = TextEditingController(text: widget.current?.note); late final _note = TextEditingController(text: widget.current?.note);
var _toAuthor = false;
@override @override
void dispose() { void dispose() {
@ -46,17 +62,18 @@ class _ReportSheetState() extends State<_ReportSheet> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
return SheetScaffold( return SheetScaffold(
title: l10n.cardReportTitle, title: widget.authorOnly ? l10n.reportToAuthor : l10n.cardReportTitle,
footer: FilledButton.icon( footer: FilledButton.icon(
icon: const Icon(LucideIcons.flag, size: 18), icon: const Icon(LucideIcons.flag, size: 18),
label: Text(l10n.cardReportSubmit), label: Text(l10n.cardReportSubmit),
onPressed: () => Navigator.of(context).pop( onPressed: () => Navigator.of(context).pop((
CardReport( report: CardReport(
category: _category, category: _category,
note: _note.text.trim(), note: _note.text.trim(),
at: clock.now().toUtc(), at: clock.now().toUtc(),
), ),
), toAuthor: widget.authorOnly || _toAuthor,
)),
), ),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -94,6 +111,23 @@ class _ReportSheetState() extends State<_ReportSheet> {
hintText: l10n.cardReportNoteHint, 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/bloc/explore_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.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/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/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.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'; import 'package:material_ui/material_ui.dart';
/// Shows the summary of the published [studyset]: its title, author, whole /// 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), const SizedBox(height: 4),
Text(studyset.notes), 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( LicenseFooter(
content: StudysetContent( content: StudysetContent(
title: studyset.title, 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;

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