feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)

Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 14:50:55 +02:00
commit 5b9df73756
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
32 changed files with 981 additions and 3339 deletions

View file

@ -469,29 +469,16 @@
"statisticsNothingYet": "Noch nichts gelernt. Deine Lernzeit und Bewertungen erscheinen hier, sobald du Karteikarten lernst.",
"profileSettingsTooltip": "Einstellungen",
"profileIdentified": "Dein Fortschritt wird in deinem Konto gespeichert.",
"profileAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
"profileAnonymousSimpleLogin": "Dein Fortschritt wird auf diesem Gerät gespeichert. Füge SimpleLogin hinzu, um ihn zu behalten.",
"profileSignInWithGitHub": "Mit GitHub anmelden",
"profileSignInFailed": "Anmeldung mit GitHub fehlgeschlagen.",
"profileContinueInBrowser": "Fahre in deinem Browser fort.",
"profileSwitchTitle": "Zu deinem GitHub-Konto wechseln?",
"profileSwitchMessage": "Dieses GitHub-Konto hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"profileSwitchStay": "Anonym bleiben",
"profileSwitchConfirm": "Wechseln",
"profileSignInWays": "Anmeldewege",
"profileWayGitHub": "GitHub",
"profileWayEmail": "E-Mail-Adresse",
"profileWayLinked": "Meldet dich an",
"profileAddGitHub": "GitHub hinzufügen",
"profileGitHubTaken": "Mit diesem GitHub-Konto meldet sich schon ein anderes Konto an.",
"emailSignInSwitchMessage": "Diese Adresse hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.",
"publicProfileTitle": "Öffentliches Profil",
"profileSheetTitle": "Profil",
"profileSheetNotFound": "Niemand auf dieser Instanz hat diesen Handle.",
"profileSheetLinkVerified": "Verlinkt zurück auf dieses Profil",
"profileSheetUnreachable": "Das Profil konnte nicht geladen werden. Prüfe deine Verbindung.",
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.",
"publicProfileAnonymousHint": "Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publicProfileDisplayName": "Anzeigename",
"publicProfileDisplayNameHint": "Der Name neben deinem Handle",
"publicProfileHandle": "Handle",
@ -554,8 +541,6 @@
"instanceWelcome": "Willkommen bei Halkyon Learn",
"startSignedOut": "Abgemeldet",
"startOn": "Starte auf {instance}:",
"startWithoutAccount": "Ohne Konto starten",
"startThenAddGitHub": "Starte ohne Konto und füge GitHub dann im Profil hinzu.",
"startOtherInstance": "Andere Instanz verwenden",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Lernsets liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"instanceAddress": "Adresse der Instanz",
@ -573,7 +558,6 @@
"instanceAddressInvalid": "Das ist keine Adresse.",
"instanceUnreachable": "Unter {name} antwortet keine Instanz von Halkyon Learn.",
"instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.",
"accountAnonymous": "Anonym",
"accountSignedIn": "Angemeldet",
"accountsTitle": "Konten",
"accountAdd": "Konto hinzufügen",
@ -582,7 +566,6 @@
"accountRemoveTitle": "Konto entfernen?",
"accountRemoveMessage": "Entfernt das Konto auf {name} von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf {name}: Melde dich dort wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.",
"accountRemoveConfirm": "Entfernen",
"settingsSignOutAnonymousMessage": "Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden samt Fortschritt gelöscht.",
"settingsDeleteMessage": "Alles, was du hast, also deine Lernsets, Karten und Wiederholungen, wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"settingsAbout": "Über Halkyon Learn",
"aboutLegalese": "Freie Software: Die App steht unter der GNU Affero General Public License, Version 3 oder jeder späteren Version, der Server unter der Server Side Public License, Version 1.",
@ -1606,7 +1589,6 @@
"publishPrivate": "Nicht veröffentlicht · nur du siehst es in Learn",
"publishPublished": "Veröffentlicht · v{version} · {date}",
"publishIntro": "Eine Veröffentlichung ist dieses Lernset mit allem darin, so wie es jetzt ist. Andere können es finden, damit lernen und es kopieren. Jede Veröffentlichung bleibt, wie sie war; spätere Änderungen kommen in die nächste.",
"publishAnonymous": "Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
"publishName": "Name",
"publishNameHint": "japanisch",
"publishLocation": "Zu finden unter {location}",
@ -1696,7 +1678,7 @@
"cardBrowserStatusWithdrawn": "Im Original zurückgezogen",
"settingsPrivacy": "Privatsphäre",
"settingsCountLearning": "Mein Lernen in Entdecken mitzählen",
"settingsCountLearningSubtitle": "Entdecken zeigt, wie viele auf dieser Instanz ein veröffentlichtes Lernset lernen: Personen mit einer Identität, die das gewählt und es in den letzten 30 Tagen an 3 oder mehr Tagen gelernt haben, gerundet, ab 5. Nichts über dich wird gezeigt, und nichts verlässt die Instanz.",
"settingsCountLearningSubtitle": "Entdecken zeigt, wie viele auf dieser Instanz ein veröffentlichtes Lernset lernen: Personen, die das gewählt und es in den letzten 30 Tagen an 3 oder mehr Tagen gelernt haben, gerundet, ab 5. Nichts über dich wird gezeigt, und nichts verlässt die Instanz.",
"exploreLearners": "{count}+ lernen das hier",
"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.",
@ -1816,7 +1798,6 @@
"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 an, um im Original zu melden und vorzuschlagen.",
"kanjiExampleWords": "Wörter damit",
"settingsAssistant": "Websuche",

View file

@ -534,29 +534,16 @@
"statisticsNothingYet": "Nothing studied yet. Your study time and ratings show here once you study flashcards.",
"profileSettingsTooltip": "Settings",
"profileIdentified": "Your progress is kept with your account.",
"profileAnonymous": "Progress is saved on this device. Sign in to keep it.",
"profileAnonymousSimpleLogin": "Progress is saved on this device. Add SimpleLogin to keep it.",
"profileSignInWithGitHub": "Sign in with GitHub",
"profileSignInFailed": "Couldn't sign in with GitHub.",
"profileContinueInBrowser": "Continue in your browser.",
"profileSwitchTitle": "Switch to your GitHub account?",
"profileSwitchMessage": "This GitHub account already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"profileSwitchStay": "Stay anonymous",
"profileSwitchConfirm": "Switch",
"profileSignInWays": "Ways to sign in",
"profileWayGitHub": "GitHub",
"profileWayEmail": "Email address",
"profileWayLinked": "Signs you in",
"profileAddGitHub": "Add GitHub",
"profileGitHubTaken": "This GitHub account already signs in to another account.",
"emailSignInSwitchMessage": "This address already has progress from another device. The studysets on this device can't be combined with it yet: if you switch, they are deleted.",
"publicProfileTitle": "Public profile",
"profileSheetTitle": "Profile",
"profileSheetNotFound": "Nobody on this instance has this handle.",
"profileSheetLinkVerified": "Links back to this profile",
"profileSheetUnreachable": "The profile couldn’t be loaded. Check your connection.",
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.",
"publicProfileAnonymousHint": "Sign in to have a public profile and publish.",
"publicProfileDisplayName": "Display name",
"publicProfileDisplayNameHint": "The name shown beside your handle",
"publicProfileHandle": "Handle",
@ -626,8 +613,6 @@
"startSignedOut": "Signed out",
"startOn": "Start on {instance}:",
"@startOn": {"placeholders": {"instance": {"type": "String"}}},
"startWithoutAccount": "Start without an account",
"startThenAddGitHub": "Start without an account, then add GitHub in Profile.",
"startOtherInstance": "Use another instance",
"instanceFirstStartSubtitle": "Choose the instance to keep your studysets on. Someone runs each one; you can add others later.",
"instanceAddress": "Instance address",
@ -651,7 +636,6 @@
"@instanceUnreachable": {"placeholders": {"name": {"type": "String"}}},
"instanceOpenFailed": "Couldn't open the app on {name}.",
"@instanceOpenFailed": {"placeholders": {"name": {"type": "String"}}},
"accountAnonymous": "Anonymous",
"accountSignedIn": "Signed in",
"accountsTitle": "Accounts",
"accountAdd": "Add account",
@ -661,7 +645,6 @@
"accountRemoveMessage": "Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there again to have it. An anonymous account can't be signed into again.",
"@accountRemoveMessage": {"placeholders": {"name": {"type": "String"}}},
"accountRemoveConfirm": "Remove",
"settingsSignOutAnonymousMessage": "This account can't be signed into again, so signing out deletes it with its progress.",
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
"settingsAbout": "About Halkyon Learn",
"aboutLegalese": "Free software: the app is under the GNU Affero General Public License, version 3 or any later version, the server under the Server Side Public License, version 1.",
@ -1890,7 +1873,6 @@
"publishPublished": "Published · v{version} · {date}",
"@publishPublished": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"publishIntro": "A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.",
"publishAnonymous": "Sign in to have a public profile and publish.",
"publishName": "Name",
"publishNameHint": "japanese",
"publishLocation": "Found at {location}",
@ -1998,7 +1980,7 @@
"cardBrowserStatusWithdrawn": "Withdrawn by the author",
"settingsPrivacy": "Privacy",
"settingsCountLearning": "Count my learning in Explore",
"settingsCountLearningSubtitle": "Explore shows how many people on this instance learn a published studyset: those with an identity who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.",
"settingsCountLearningSubtitle": "Explore shows how many people on this instance learn a published studyset: those who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.",
"exploreLearners": "{count}+ learn this here",
"@exploreLearners": {"placeholders": {"count": {"type": "int"}}},
"publishLearnersFew": "Fewer than 5 learn it here, of those who chose to be counted.",
@ -2124,7 +2106,6 @@
"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 to report to the author and suggest.",
"kanjiExampleWords": "Words with it",
"settingsAssistant": "Web search",

View file

@ -2824,60 +2824,6 @@ abstract class AppLocalizations {
/// **'Your progress is kept with your account.'**
String get profileIdentified;
/// No description provided for @profileAnonymous.
///
/// In en, this message translates to:
/// **'Progress is saved on this device. Sign in to keep it.'**
String get profileAnonymous;
/// No description provided for @profileAnonymousSimpleLogin.
///
/// In en, this message translates to:
/// **'Progress is saved on this device. Add SimpleLogin to keep it.'**
String get profileAnonymousSimpleLogin;
/// No description provided for @profileSignInWithGitHub.
///
/// In en, this message translates to:
/// **'Sign in with GitHub'**
String get profileSignInWithGitHub;
/// No description provided for @profileSignInFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t sign in with GitHub.'**
String get profileSignInFailed;
/// No description provided for @profileContinueInBrowser.
///
/// In en, this message translates to:
/// **'Continue in your browser.'**
String get profileContinueInBrowser;
/// No description provided for @profileSwitchTitle.
///
/// In en, this message translates to:
/// **'Switch to your GitHub account?'**
String get profileSwitchTitle;
/// No description provided for @profileSwitchMessage.
///
/// In en, this message translates to:
/// **'This GitHub account already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.'**
String get profileSwitchMessage;
/// No description provided for @profileSwitchStay.
///
/// In en, this message translates to:
/// **'Stay anonymous'**
String get profileSwitchStay;
/// No description provided for @profileSwitchConfirm.
///
/// In en, this message translates to:
/// **'Switch'**
String get profileSwitchConfirm;
/// No description provided for @profileSignInWays.
///
/// In en, this message translates to:
@ -2902,24 +2848,6 @@ abstract class AppLocalizations {
/// **'Signs you in'**
String get profileWayLinked;
/// No description provided for @profileAddGitHub.
///
/// In en, this message translates to:
/// **'Add GitHub'**
String get profileAddGitHub;
/// No description provided for @profileGitHubTaken.
///
/// In en, this message translates to:
/// **'This GitHub account already signs in to another account.'**
String get profileGitHubTaken;
/// No description provided for @emailSignInSwitchMessage.
///
/// In en, this message translates to:
/// **'This address already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.'**
String get emailSignInSwitchMessage;
/// No description provided for @publicProfileTitle.
///
/// In en, this message translates to:
@ -2956,12 +2884,6 @@ abstract class AppLocalizations {
/// **'How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.'**
String get publicProfileIntro;
/// No description provided for @publicProfileAnonymousHint.
///
/// In en, this message translates to:
/// **'Sign in to have a public profile and publish.'**
String get publicProfileAnonymousHint;
/// No description provided for @publicProfileDisplayName.
///
/// In en, this message translates to:
@ -3316,18 +3238,6 @@ abstract class AppLocalizations {
/// **'Start on {instance}:'**
String startOn(String instance);
/// No description provided for @startWithoutAccount.
///
/// In en, this message translates to:
/// **'Start without an account'**
String get startWithoutAccount;
/// No description provided for @startThenAddGitHub.
///
/// In en, this message translates to:
/// **'Start without an account, then add GitHub in Profile.'**
String get startThenAddGitHub;
/// No description provided for @startOtherInstance.
///
/// In en, this message translates to:
@ -3430,12 +3340,6 @@ abstract class AppLocalizations {
/// **'Couldn\'t open the app on {name}.'**
String instanceOpenFailed(String name);
/// No description provided for @accountAnonymous.
///
/// In en, this message translates to:
/// **'Anonymous'**
String get accountAnonymous;
/// No description provided for @accountSignedIn.
///
/// In en, this message translates to:
@ -3484,12 +3388,6 @@ abstract class AppLocalizations {
/// **'Remove'**
String get accountRemoveConfirm;
/// No description provided for @settingsSignOutAnonymousMessage.
///
/// In en, this message translates to:
/// **'This account can\'t be signed into again, so signing out deletes it with its progress.'**
String get settingsSignOutAnonymousMessage;
/// No description provided for @settingsDeleteMessage.
///
/// In en, this message translates to:
@ -9501,12 +9399,6 @@ abstract class AppLocalizations {
/// **'A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.'**
String get publishIntro;
/// No description provided for @publishAnonymous.
///
/// In en, this message translates to:
/// **'Sign in to have a public profile and publish.'**
String get publishAnonymous;
/// No description provided for @publishName.
///
/// In en, this message translates to:
@ -10044,7 +9936,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsCountLearningSubtitle.
///
/// In en, this message translates to:
/// **'Explore shows how many people on this instance learn a published studyset: those with an identity who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.'**
/// **'Explore shows how many people on this instance learn a published studyset: those who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.'**
String get settingsCountLearningSubtitle;
/// No description provided for @exploreLearners.
@ -10737,12 +10629,6 @@ abstract class AppLocalizations {
/// **'Its licence forbids adaptations: no suggestions.'**
String get suggestNoDerivatives;
/// No description provided for @feedbackNeedsIdentity.
///
/// In en, this message translates to:
/// **'Sign in to report to the author and suggest.'**
String get feedbackNeedsIdentity;
/// No description provided for @kanjiExampleWords.
///
/// In en, this message translates to:

View file

@ -1715,36 +1715,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get profileIdentified =>
'Dein Fortschritt wird in deinem Konto gespeichert.';
@override
String get profileAnonymous =>
'Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.';
@override
String get profileAnonymousSimpleLogin =>
'Dein Fortschritt wird auf diesem Gerät gespeichert. Füge SimpleLogin hinzu, um ihn zu behalten.';
@override
String get profileSignInWithGitHub => 'Mit GitHub anmelden';
@override
String get profileSignInFailed => 'Anmeldung mit GitHub fehlgeschlagen.';
@override
String get profileContinueInBrowser => 'Fahre in deinem Browser fort.';
@override
String get profileSwitchTitle => 'Zu deinem GitHub-Konto wechseln?';
@override
String get profileSwitchMessage =>
'Dieses GitHub-Konto hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.';
@override
String get profileSwitchStay => 'Anonym bleiben';
@override
String get profileSwitchConfirm => 'Wechseln';
@override
String get profileSignInWays => 'Anmeldewege';
@ -1757,17 +1727,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get profileWayLinked => 'Meldet dich an';
@override
String get profileAddGitHub => 'GitHub hinzufügen';
@override
String get profileGitHubTaken =>
'Mit diesem GitHub-Konto meldet sich schon ein anderes Konto an.';
@override
String get emailSignInSwitchMessage =>
'Diese Adresse hat bereits Fortschritt von einem anderen Gerät. Die Lernsets auf diesem Gerät lassen sich noch nicht damit zusammenführen: Wenn du wechselst, werden sie gelöscht.';
@override
String get publicProfileTitle => 'Öffentliches Profil';
@ -1789,10 +1748,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get publicProfileIntro =>
'Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.';
@override
String get publicProfileAnonymousHint =>
'Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
@override
String get publicProfileDisplayName => 'Anzeigename';
@ -2004,13 +1959,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Starte auf $instance:';
}
@override
String get startWithoutAccount => 'Ohne Konto starten';
@override
String get startThenAddGitHub =>
'Starte ohne Konto und füge GitHub dann im Profil hinzu.';
@override
String get startOtherInstance => 'Andere Instanz verwenden';
@ -2075,9 +2023,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Die App auf $name konnte nicht geöffnet werden.';
}
@override
String get accountAnonymous => 'Anonym';
@override
String get accountSignedIn => 'Angemeldet';
@ -2105,10 +2050,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get accountRemoveConfirm => 'Entfernen';
@override
String get settingsSignOutAnonymousMessage =>
'Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden samt Fortschritt gelöscht.';
@override
String get settingsDeleteMessage =>
'Alles, was du hast, also deine Lernsets, Karten und Wiederholungen, wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.';
@ -5975,10 +5916,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get publishIntro =>
'Eine Veröffentlichung ist dieses Lernset mit allem darin, so wie es jetzt ist. Andere können es finden, damit lernen und es kopieren. Jede Veröffentlichung bleibt, wie sie war; spätere Änderungen kommen in die nächste.';
@override
String get publishAnonymous =>
'Melde dich an, um ein öffentliches Profil zu haben und zu veröffentlichen.';
@override
String get publishName => 'Name';
@ -6319,7 +6256,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsCountLearningSubtitle =>
'Entdecken zeigt, wie viele auf dieser Instanz ein veröffentlichtes Lernset lernen: Personen mit einer Identität, die das gewählt und es in den letzten 30 Tagen an 3 oder mehr Tagen gelernt haben, gerundet, ab 5. Nichts über dich wird gezeigt, und nichts verlässt die Instanz.';
'Entdecken zeigt, wie viele auf dieser Instanz ein veröffentlichtes Lernset lernen: Personen, die das gewählt und es in den letzten 30 Tagen an 3 oder mehr Tagen gelernt haben, gerundet, ab 5. Nichts über dich wird gezeigt, und nichts verlässt die Instanz.';
@override
String exploreLearners(int count) {
@ -6718,10 +6655,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get suggestNoDerivatives =>
'Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.';
@override
String get feedbackNeedsIdentity =>
'Melde dich an, um im Original zu melden und vorzuschlagen.';
@override
String get kanjiExampleWords => 'Wörter damit';

View file

@ -1699,36 +1699,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileIdentified => 'Your progress is kept with your account.';
@override
String get profileAnonymous =>
'Progress is saved on this device. Sign in to keep it.';
@override
String get profileAnonymousSimpleLogin =>
'Progress is saved on this device. Add SimpleLogin to keep it.';
@override
String get profileSignInWithGitHub => 'Sign in with GitHub';
@override
String get profileSignInFailed => 'Couldn\'t sign in with GitHub.';
@override
String get profileContinueInBrowser => 'Continue in your browser.';
@override
String get profileSwitchTitle => 'Switch to your GitHub account?';
@override
String get profileSwitchMessage =>
'This GitHub account already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String get profileSwitchStay => 'Stay anonymous';
@override
String get profileSwitchConfirm => 'Switch';
@override
String get profileSignInWays => 'Ways to sign in';
@ -1741,17 +1711,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileWayLinked => 'Signs you in';
@override
String get profileAddGitHub => 'Add GitHub';
@override
String get profileGitHubTaken =>
'This GitHub account already signs in to another account.';
@override
String get emailSignInSwitchMessage =>
'This address already has progress from another device. The studysets on this device can\'t be combined with it yet: if you switch, they are deleted.';
@override
String get publicProfileTitle => 'Public profile';
@ -1772,10 +1731,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get publicProfileIntro =>
'How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.';
@override
String get publicProfileAnonymousHint =>
'Sign in to have a public profile and publish.';
@override
String get publicProfileDisplayName => 'Display name';
@ -1985,13 +1940,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Start on $instance:';
}
@override
String get startWithoutAccount => 'Start without an account';
@override
String get startThenAddGitHub =>
'Start without an account, then add GitHub in Profile.';
@override
String get startOtherInstance => 'Use another instance';
@ -2056,9 +2004,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Couldn\'t open the app on $name.';
}
@override
String get accountAnonymous => 'Anonymous';
@override
String get accountSignedIn => 'Signed in';
@ -2086,10 +2031,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get accountRemoveConfirm => 'Remove';
@override
String get settingsSignOutAnonymousMessage =>
'This account can\'t be signed into again, so signing out deletes it with its progress.';
@override
String get settingsDeleteMessage =>
'Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can\'t be undone.';
@ -5921,10 +5862,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get publishIntro =>
'A release is this studyset with everything inside it, as it is now. Others can find it, learn from it and copy it. Each release stays as it was; later changes go into the next one.';
@override
String get publishAnonymous =>
'Sign in to have a public profile and publish.';
@override
String get publishName => 'Name';
@ -6258,7 +6195,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsCountLearningSubtitle =>
'Explore shows how many people on this instance learn a published studyset: those with an identity who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.';
'Explore shows how many people on this instance learn a published studyset: those who chose this and studied it on 3 or more days in the last 30, rounded, from 5 on. Nothing about you is shown, and nothing leaves the instance.';
@override
String exploreLearners(int count) {
@ -6655,10 +6592,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get suggestNoDerivatives =>
'Its licence forbids adaptations: no suggestions.';
@override
String get feedbackNeedsIdentity =>
'Sign in to report to the author and suggest.';
@override
String get kanjiExampleWords => 'Words with it';

View file

@ -7,16 +7,14 @@ import 'package:material_ui/material_ui.dart';
/// Halkyon Learn as halkyon_app's own screens know it (the first start, the
/// start screen, the account switcher, signing in): its name and word, its
/// kingfisher, its look and languages, and the three sentences only it can
/// say, in its own words, so every text reads as it did before they moved.
/// kingfisher, its look and languages, and the sentence only it can say,
/// in its own words, so every text reads as it did before they moved.
const learnFrame = AppFrame(
name: _name,
product: _product,
icon: AssetImage('assets/icon/halkyon_icon.png'),
exampleAddress: 'learn.example.org',
firstStartSubtitle: _firstStartSubtitle,
switchLeavesBehind: _switchLeavesBehind,
thenAddGitHub: _thenAddGitHub,
theme: learnTheme,
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
@ -31,9 +29,3 @@ String _product(BuildContext context) =>
String _firstStartSubtitle(BuildContext context) =>
AppLocalizations.of(context).instanceFirstStartSubtitle;
String _switchLeavesBehind(BuildContext context) =>
AppLocalizations.of(context).emailSignInSwitchMessage;
String _thenAddGitHub(BuildContext context) =>
AppLocalizations.of(context).startThenAddGitHub;

View file

@ -109,9 +109,6 @@ class Dependencies({
/// Who is signed in, and signing in and out.
@override required final AuthRepository auth,
/// Whether the person here signed out, so the app asks how to start.
@override required final SignedOutMark signedOut,
/// Signing in with a password and TOTP, and what keeps an account (ADR
/// 0017).
@override required final PasswordRepository passwords,
@ -224,8 +221,8 @@ class Dependencies({
minimumAppBuild: () async =>
(await client.instance.info()).minimumAppBuild,
);
// Restores a stored session before anything asks who is signed in;
// otherwise the first launch logic would sign in a second anonymous person.
// Restores a stored session before anything asks who is signed in, so
// the app doesn't ask how to start while someone is.
await client.auth.initialize();
final authGateway = ServerpodAuthGateway(
@ -272,7 +269,6 @@ class Dependencies({
return Dependencies(
serverName: instance.name,
auth: AuthRepository(authGateway, addresses),
signedOut: FileSignedOutMark(device.storage.peopleOf(instance)),
passwords: passwords,
simpleLogin: simpleLogin,
sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl,
@ -315,21 +311,7 @@ class Dependencies({
compatibility,
person,
await folders.openOnly(person.authUserId),
accountKeys: () async {
final kept = await accountKeys.read(person.authUserId);
if (kept != null ||
person.isIdentified ||
// Kept but unreadable now: never replaced, or their
// private studysets would be locked for good.
await accountKeys.holds(person.authUserId)) {
return kept;
}
// An anonymous person's device makes the keys their private
// studysets are encrypted with (ADR 0017, 9; ADR 0019).
final made = await accountCrypto.newDeviceKeys();
await accountKeys.keep(person.authUserId, made);
return made;
},
accountKeys: () => accountKeys.read(person.authUserId),
onClosed: closed.complete,
);
} on Object {
@ -459,10 +441,9 @@ Future<PersonDependencies> _openPersonData(
seen: privacyLedger,
// Whether the account has keys is the register's to say (a password
// wraps them), not the key rows the instance hands over (the short
// reading of 22 Oct); an anonymous person's are this device's own.
registerHasKeys: person.isIdentified
? () async => (await client.modules.base.account.signInWays()).password
: () async => null,
// reading of 22 Oct).
registerHasKeys: () async =>
(await client.modules.base.account.signInWays()).password,
);
final privacy = Privacy(db, contentKeys)..requestSync = sync.requestSync;
await contentKeys.refresh();
@ -490,15 +471,13 @@ Future<PersonDependencies> _openPersonData(
final settings = SettingsRepository(SyncSettingsGateway(db, sync, privacy));
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
// Count me and the copies' publications, which the instance can't read
// any more (ADR 0019, D3), for people with an identity: only they count.
final counting = person.isIdentified
? (LearningCounting(
ServerpodLearningCountGateway(client),
settings,
studysets,
canCount: () => contentKeys.canEncrypt || !contentKeys.accountHasKeys,
)..start(sync.synced))
: null;
// any more (ADR 0019, D3).
final counting = LearningCounting(
ServerpodLearningCountGateway(client),
settings,
studysets,
canCount: () => contentKeys.canEncrypt || !contentKeys.accountHasKeys,
)..start(sync.synced);
final assets = AssetRepository(
SyncAssetGateway(db, sync, privacy),
ServerpodAssetServerGateway(client),
@ -626,7 +605,7 @@ Future<PersonDependencies> _openPersonData(
await stepDeletions.dispose();
await pdfDeletions.dispose();
await discussions.dispose();
await counting?.dispose();
await counting.dispose();
await studysetProgress.dispose();
await studysets.dispose();
await tags.dispose();

View file

@ -117,12 +117,6 @@ class PersonDependencies({
static PersonDependencies of(BuildContext context) =>
PersonScope.of<PersonDependencies>(context);
/// Whether this device holds content of the person that switching to
/// another account would leave behind: any studyset, those waiting in
/// Recently deleted too. Signing in asks first only then.
Future<bool> holdsContent() async =>
(await studysets.withDeleted()).isNotEmpty;
/// Closes what was opened for the person.
@override
Future<void> dispose() async {

View file

@ -8,10 +8,9 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart'
/// The person's data keys on this device (ADR 0019): each private
/// studyset's and their personal key, sealed to their account and synced,
/// opened here with the account's keys (an anonymous person's device keys,
/// ADR 0017, 9). Without those keys on the device (an account signed in
/// without its password here, or one that has none yet, D8) nothing opens
/// and nothing new is made private.
/// opened here with the account's keys. Without those keys on the device (an
/// account signed in without its password here, or one that has none yet,
/// D8) nothing opens and nothing new is made private.
///
/// What a key is for is its sealed ID's to say ([ContentKeyId]), never its
/// row's: so the keys that open are also the signed facts about which

View file

@ -1,11 +1,8 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/widget/discussion_inbox.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart';
@ -16,27 +13,16 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The profile tab, in two tabs as Vaia has it (decided 2026-10-12): the
/// profile (what the account means, keeping an anonymous one with GitHub or
/// a password, the ways an identified person signs in, and their password
/// and authenticators, ADR 0017) and the person's statistics, which
/// anonymous people have too. Settings are behind the gear.
/// profile (the ways the person signs in, SimpleLogin, ADR 0020, and their
/// password and authenticators, ADR 0017) and the person's statistics.
/// Settings are behind the gear.
class const ProfileScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => GitHubSignInBloc(
Dependencies.of(context).auth,
deviceHoldsContent: PersonDependencies.of(context).holdsContent,
)..add(const GitHubSignInEventStarted()),
),
BlocProvider(
create: (context) =>
SignInWaysCubit(Dependencies.of(context).auth)..load(),
),
],
return BlocProvider(
create: (context) =>
SignInWaysCubit(Dependencies.of(context).auth)..load(),
child: TopGradient(
color: Brand.primary,
background: Theme.of(context).colorScheme.surfaceContainerLow,
@ -68,12 +54,7 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
],
),
),
body: const TabBarView(
children: [
_CancelWhenLeft(child: _Account()),
StatisticsView(),
],
),
body: const TabBarView(children: [_Account(), StatisticsView()]),
),
),
),
@ -81,225 +62,74 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
}
}
/// Cancels a sign-in waiting for GitHub in the browser once another tab is
/// chosen: the person has left it behind (decided 2026-10-01). Settings,
/// opened over this tab, doesn't count as leaving.
class const _CancelWhenLeft({required final Widget child})
extends StatefulWidget {
@override
State<_CancelWhenLeft> createState() => _CancelWhenLeftState();
}
class _CancelWhenLeftState() extends State<_CancelWhenLeft> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!TabVisibility.of(context)) {
final bloc = context.read<GitHubSignInBloc>();
if (bloc.state case GitHubSignInStateBusy(cancellable: true)) {
bloc.add(const GitHubSignInEventCancelled());
}
}
}
@override
Widget build(BuildContext context) => widget.child;
}
class const _Account() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final isIdentified = switch (context.watch<SessionBloc>().state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
return MultiBlocListener(
listeners: [
BlocListener<GitHubSignInBloc, GitHubSignInState>(
listenWhen: (previous, current) =>
current is GitHubSignInStateConfirmSwitch,
listener: (context, state) async {
final bloc = context.read<GitHubSignInBloc>();
final confirmed = await _confirmSwitch(context);
bloc.add(
confirmed
? const GitHubSignInEventSwitchConfirmed()
: const GitHubSignInEventSwitchDeclined(),
);
},
),
// How the person signs in changed.
BlocListener<GitHubSignInBloc, GitHubSignInState>(
listenWhen: (previous, current) => current is GitHubSignInStateLinked,
listener: (context, state) => context.read<SignInWaysCubit>().load(),
),
BlocListener<SessionBloc, SessionState>(
listenWhen: (previous, current) =>
_identifiedIn(previous) != _identifiedIn(current),
listener: (context, state) => context.read<SignInWaysCubit>().load(),
),
],
// On the screen itself, in the middle, as wide as readable text.
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
// A card for each part, as Settings groups its own.
child: ListView(
// Room at the end for the navigation bar floating over the foot.
padding: EdgeInsets.fromLTRB(
12,
12,
12,
12 + MediaQuery.paddingOf(context).bottom,
),
children: [
SectionCard(
// Where the person stands with SimpleLogin (ADR 0020, D21,
// D23), read once for the card's parts that show it.
child: SimpleLoginStandingScope(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.settingsAccount,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
_AccountLine(identified: isIdentified),
if (isIdentified) const _SignInWays(),
if (!isIdentified) ...[
const _GitHubSignIn(),
// SimpleLogin keeps the account, where the instance
// offers it.
const SimpleLoginSection(gapAbove: 16),
const SizedBox(height: 16),
// A password keeps the account too (ADR 0017).
const AccountSecuritySection(),
const SizedBox(height: 16),
Text(
l10n.publicProfileAnonymousHint,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
],
),
),
),
if (isIdentified) ...[
const SizedBox(height: 12),
const SectionCard(
child: InboxSection(discussions: DiscussionInbox()),
),
const SizedBox(height: 12),
const SectionCard(child: PublicProfileSection()),
],
],
// On the screen itself, in the middle, as wide as readable text.
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
// A card for each part, as Settings groups its own.
child: ListView(
// Room at the end for the navigation bar floating over the foot.
padding: EdgeInsets.fromLTRB(
12,
12,
12,
12 + MediaQuery.paddingOf(context).bottom,
),
children: [
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.settingsAccount,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(l10n.profileIdentified),
const _SignInWays(),
],
),
),
const SizedBox(height: 12),
const SectionCard(
child: InboxSection(discussions: DiscussionInbox()),
),
const SizedBox(height: 12),
const SectionCard(child: PublicProfileSection()),
],
),
),
);
}
}
/// What the account card says first: that the account keeps the progress,
/// or that only this device does, with what keeps it (SimpleLogin, where
/// the instance offers it).
class const _AccountLine({required final bool identified})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (identified) {
return Text(l10n.profileIdentified);
}
final simpleLogin = context.watch<SimpleLoginStandingCubit>().state;
return Text(
simpleLogin is SimpleLoginMissing
? l10n.profileAnonymousSimpleLogin
: l10n.profileAnonymous,
);
}
}
bool _identifiedIn(SessionState state) => switch (state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
/// How an identified person signs in: GitHub, with a way to add it where
/// the instance adds it (not where people sign in with a password, ADR
/// 0017, D2: there only one who has it sees it), and an email address,
/// with the password and authenticators beneath.
/// How the person signs in: GitHub where they have it (it is added to
/// nobody any more, ADR 0020, D25), an email address, and SimpleLogin
/// (D21), with the password and authenticators beneath.
class const _SignInWays() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final ways = context.watch<SignInWaysCubit>().state;
final gitHub = context.watch<GitHubSignInBloc>().state;
final offersGitHub = switch (gitHub) {
GitHubSignInStateChecking() ||
GitHubSignInStateUnavailable() ||
GitHubSignInStateReady(linking: false) => false,
_ => true,
};
final busy = gitHub is GitHubSignInStateBusy;
Widget way(String label, {String? linkedAs, Widget? add}) => ListTile(
Widget way(String label, String linkedAs) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(label),
subtitle: linkedAs == null
? null
: Text(linkedAs, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: linkedAs != null
? Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary)
: add,
);
final note = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
subtitle: Text(linkedAs, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
Text(l10n.profileSignInWays, style: theme.textTheme.titleSmall),
if (ways.ways.gitHub || offersGitHub)
way(
l10n.profileWayGitHub,
linkedAs: ways.ways.gitHub ? l10n.profileWayLinked : null,
add: FilledButton.tonal(
onPressed: busy
? null
: () => context.read<GitHubSignInBloc>().add(
const GitHubSignInEventRequested(),
),
child: Text(l10n.profileAddGitHub),
),
),
if (gitHub case GitHubSignInStateBusy(cancellable: true))
const _GitHubWaiting(),
if (gitHub is GitHubSignInStateTaken)
Text(l10n.profileGitHubTaken, style: note),
if (gitHub case GitHubSignInStateReady(failed: true, :final problem))
Text(
gitHubProblemText(
HalkyonAppLocalizations.of(context),
problem,
anonymous: false,
) ??
l10n.profileSignInFailed,
style: note,
),
if (ways.ways.gitHub) way(l10n.profileWayGitHub, l10n.profileWayLinked),
if (ways.ways.email)
way(
l10n.profileWayEmail,
linkedAs: ways.emailAddress ?? l10n.profileWayLinked,
),
way(l10n.profileWayEmail, ways.emailAddress ?? l10n.profileWayLinked),
// SimpleLogin (ADR 0020, D21): once added, one more way in here.
const SimpleLoginSection(gapAbove: 16),
const SizedBox(height: 16),
@ -310,124 +140,3 @@ class const _SignInWays() extends StatelessWidget {
);
}
}
/// The way to keep an anonymous account: signing in with GitHub, where the
/// instance offers it. Where people sign in with a password it reaches only
/// a GitHub account someone has (ADR 0017), and the password beneath keeps
/// the account instead. Not where the instance offers SimpleLogin, which
/// alone keeps it there (ADR 0020, D23), nor until the instance has said.
class const _GitHubSignIn() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final simpleLogin = context.watch<SimpleLoginStandingCubit>().state;
if (simpleLogin is SimpleLoginChecking ||
simpleLogin is SimpleLoginMissing) {
return const SizedBox.shrink();
}
return BlocBuilder<GitHubSignInBloc, GitHubSignInState>(
builder: (context, state) => switch (state) {
GitHubSignInStateChecking() ||
GitHubSignInStateUnavailable() ||
GitHubSignInStateLinked() => const SizedBox.shrink(),
GitHubSignInStateReady(:final failed, :final problem) => _button(
context,
enabled: true,
failed: failed,
problem: problem,
),
// Only for someone identified, who sees their ways in instead.
GitHubSignInStateTaken() => _button(context, enabled: true),
GitHubSignInStateBusy(cancellable: true) => const Padding(
padding: EdgeInsets.only(top: 16),
child: _GitHubWaiting(),
),
GitHubSignInStateBusy() ||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
},
);
}
Widget _button(
BuildContext context, {
required bool enabled,
bool failed = false,
GitHubSignInProblem? problem,
}) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilledButton(
onPressed: enabled
? () => context.read<GitHubSignInBloc>().add(
const GitHubSignInEventRequested(),
)
: null,
child: Text(l10n.profileSignInWithGitHub),
),
if (failed) ...[
const SizedBox(height: 8),
Text(
gitHubProblemText(
HalkyonAppLocalizations.of(context),
problem,
anonymous: true,
) ??
l10n.profileSignInFailed,
),
],
],
),
);
}
}
/// While GitHub's page is open in the browser, where only the app can stop
/// waiting for it.
class const _GitHubWaiting() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.profileContinueInBrowser),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => context.read<GitHubSignInBloc>().add(
const GitHubSignInEventCancelled(),
),
child: Text(l10n.commonCancel),
),
],
);
}
}
/// Asks before switching to a GitHub account that already has progress,
/// leaving this device's studysets behind.
Future<bool> _confirmSwitch(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.profileSwitchTitle),
content: Text(l10n.profileSwitchMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.profileSwitchStay),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.profileSwitchConfirm),
),
],
),
);
return confirmed ?? false;
}

View file

@ -360,7 +360,7 @@ class const _SliverCards(
final origin = card.content.published;
if (await showReportSheet(
context,
offerAuthor: fromRelease(origin) && canWriteToAuthors(context),
offerAuthor: fromRelease(origin),
)
case (:final report, :final toAuthor) when context.mounted) {
_report(context, card, report);

View file

@ -1,5 +1,4 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.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_dependencies.dart';
@ -15,15 +14,7 @@ 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,
};
// person's own synced space, offline too.
/// 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.
@ -53,8 +44,8 @@ Future<void> sendReportToAuthor(
/// 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.
/// from; says how it went. Says instead why it can't: the studyset's
/// licence forbids adaptations.
Future<void> suggestToAuthor(
BuildContext context, {
required String studysetId,
@ -66,10 +57,6 @@ Future<void> suggestToAuthor(
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

View file

@ -6,12 +6,9 @@ import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repositor
/// Which of the person's studysets are out on Explore, by id, for the
/// library to mark them public: as the device last knew at once, then as
/// the instance says, and every publish and withdrawal after. Only a person
/// with an identity publishes; for anyone else the instance isn't asked.
class PublishedCubit(
final PublishRepository _publishing, {
required final bool identified,
}) extends Cubit<Set<String>> {
/// the instance says, and every publish and withdrawal after.
class PublishedCubit(final PublishRepository _publishing)
extends Cubit<Set<String>> {
this : super(const {}) {
_subscription = _publishing.watchPublished().listen(emit);
unawaited(refresh());
@ -22,9 +19,6 @@ class PublishedCubit(
/// Asks the instance again; what the device knew stands when it can't be
/// reached.
Future<void> refresh() async {
if (!identified) {
return;
}
try {
await _publishing.refreshPublished();
} on Object catch (error) {

View file

@ -24,44 +24,23 @@ import 'package:material_ui/material_ui.dart';
/// studysets: whether it is published and where, its name, whether its
/// licence allows publishing, what changed, and its releases so far, with
/// Publish below. Publishing goes through [bloc], or else the [PublishBloc]
/// above [context]; without one (a person without an identity) the sheet
/// says how to have one.
/// above [context].
Future<void> showPublishSheet(
BuildContext context, {
required Studyset studyset,
required List<Studyset> all,
PublishBloc? bloc,
}) {
bloc ??= context.read<PublishBloc?>();
final publishing = bloc ?? context.read<PublishBloc>();
return showAdaptiveSheet<void>(
context,
builder: (sheetContext) => bloc == null
? const _Anonymous()
: BlocProvider.value(
value: bloc,
child: _PublishSheet(studyset: studyset, all: all),
),
builder: (sheetContext) => BlocProvider.value(
value: publishing,
child: _PublishSheet(studyset: studyset, all: all),
),
);
}
class const _Anonymous() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.publishTitle,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _Status(publication: null),
const SizedBox(height: 12),
Text(l10n.publishAnonymous),
],
),
);
}
}
class const _PublishSheet({
required final Studyset studyset,
required final List<Studyset> all,

View file

@ -15,9 +15,6 @@ import 'package:material_ui/material_ui.dart';
/// do ([visibilityIcon]), and opens what explains and changes it
/// ([openVisibility]). Only an icon below medium windows, so the row fits a
/// phone's app bar.
///
/// Only a person with an identity can publish; for anyone else the sheet
/// says how to have one, and the instance isn't asked.
class const ShareButton({
required final Studyset studyset,
required final List<Studyset> all,
@ -25,9 +22,6 @@ class const ShareButton({
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!_identified(context, listen: true)) {
return _Button(studyset: studyset, all: all, published: false);
}
return BlocProvider(
// Keyed by the studyset, so another one's screen reads its own.
key: ValueKey(studyset.id),
@ -99,10 +93,6 @@ Future<void> showShareSheet(
required Studyset studyset,
required List<Studyset> all,
}) async {
if (!_identified(context)) {
await showPublishSheet(context, studyset: studyset, all: all);
return;
}
final bloc = _publishBloc(context, studyset.id);
try {
await showPublishSheet(context, studyset: studyset, all: all, bloc: bloc);
@ -111,15 +101,6 @@ Future<void> showShareSheet(
}
}
/// Whether the person has an identity, which publishing takes.
bool _identified(BuildContext context, {bool listen = false}) => switch ((listen
? context.watch<SessionBloc>()
: context.read<SessionBloc>())
.state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
/// Publishing [studysetId], started.
PublishBloc _publishBloc(BuildContext context, String studysetId) {
final person = PersonDependencies.of(context);

View file

@ -74,12 +74,7 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
// 2026-10-16), switching, signing out and deleting it:
// halkyon_app's, in Learn's words where only Learn can
// say it.
AccountTiles(
anonymousNote: l10n.profileAnonymous,
signOutAnonymousMessage:
l10n.settingsSignOutAnonymousMessage,
deleteMessage: l10n.settingsDeleteMessage,
),
AccountTiles(deleteMessage: l10n.settingsDeleteMessage),
],
),
),

View file

@ -383,7 +383,7 @@ class const _Showing(
if (await showReportSheet(
context,
current: card.content.report,
offerAuthor: fromRelease(origin) && canWriteToAuthors(context),
offerAuthor: fromRelease(origin),
)
case (:final report, :final toAuthor)) {
bloc.add(StudySessionEventReported(report));

View file

@ -41,12 +41,6 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final person = PersonDependencies.of(context);
final identified = context.select<SessionBloc, bool>(
(bloc) => switch (bloc.state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
},
);
return BlocProvider(
create: (context) => StudysetListBloc(
person.studysets,
@ -58,10 +52,7 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
person.pathProgress,
)..add(const StudysetListEventStarted()),
child: BlocProvider(
// Anew once the person has an identity, which publishing takes.
key: ValueKey(identified),
create: (context) =>
PublishedCubit(person.publishing, identified: identified),
create: (context) => PublishedCubit(person.publishing),
child: const _Library(),
),
);

View file

@ -336,16 +336,6 @@ Future<void> reportStudysetToAuthor(
BuildContext context,
PublishedRef origin,
) async {
if (!canWriteToAuthors(context)) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).feedbackNeedsIdentity),
),
);
return;
}
if (await showReportSheet(context, authorOnly: true)
case (:final report, toAuthor: _) when context.mounted) {
await sendReportToAuthor(

View file

@ -1,302 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeAuthGateway auth;
setUp(() => auth = FakeAuthGateway());
/// Signs in anonymously and opens the profile tab; this device holds
/// [studysets] studysets.
Future<void> openProfile(WidgetTester tester, {int studysets = 0}) async {
final gateway = FakeStudysetGateway();
for (var i = 1; i <= studysets; i++) {
gateway.receiveRemoteChange(
'studyset-$i',
encodeStudysetPayload(
StudysetContent(
title: 'Studyset $i',
description: RichContent.empty(),
),
),
);
}
await pumpApp(
tester,
testDependencies(authGateway: auth, studysetGateway: gateway),
);
await openTab(tester, 'Profile');
}
testWidgets('offers GitHub sign-in to anonymous people', (tester) async {
await openProfile(tester);
expect(find.text('Sign in with GitHub'), findsOneWidget);
});
testWidgets('offers none where the instance has no GitHub sign-in', (
tester,
) async {
auth.offersGitHub = false;
await openProfile(tester);
expect(find.text('Sign in with GitHub'), findsNothing);
});
testWidgets('signing in with GitHub keeps the progress with the account', (
tester,
) async {
await openProfile(tester);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text('Your progress is kept with your account.'),
findsOneWidget,
);
expect(find.text('Sign in with GitHub'), findsNothing);
});
testWidgets('the account keeps its width once signed in', (tester) async {
await openProfile(tester);
Finder account() => find.ancestor(
of: find.text('Sign in with GitHub'),
matching: find.byType(ListView),
);
final anonymous = tester.getSize(account()).width;
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
tester.getSize(find.byType(ListView).first).width,
anonymous,
);
});
group("while GitHub's page is open in the browser", () {
setUp(() => auth.gitHubPageOpens = true);
/// Opens the profile and starts signing in with GitHub.
Future<void> startSignIn(WidgetTester tester) async {
await openProfile(tester);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
}
testWidgets('offers to cancel; cancelling changes nothing', (tester) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
expect(find.text('Continue in your browser.'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(auth.gitHubCancels, 1);
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text("Couldn't sign in with GitHub."), findsNothing);
expect(
find.text('Progress is saved on this device. Sign in to keep it.'),
findsOneWidget,
);
});
testWidgets("offers no cancel where the system's sign-in screen has its "
'own', (tester) async {
await startSignIn(tester);
expect(find.text('Continue in your browser.'), findsNothing);
expect(find.text('Cancel'), findsNothing);
});
testWidgets('signs in once GitHub answers', (tester) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
auth.answerGitHub();
await tester.pumpAndSettle();
expect(
find.text('Your progress is kept with your account.'),
findsOneWidget,
);
expect(find.text('Continue in your browser.'), findsNothing);
});
testWidgets('choosing another tab cancels it', (tester) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
await openTab(tester, 'Home');
expect(auth.gitHubCancels, 1);
await openTab(tester, 'Profile');
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text("Couldn't sign in with GitHub."), findsNothing);
});
testWidgets('settings, opened over the profile, keep it waiting', (
tester,
) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(auth.gitHubCancels, 0);
expect(find.text('Continue in your browser.'), findsOneWidget);
});
testWidgets('a change of person cancels it', (tester) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
auth.revokeSession();
await tester.pumpAndSettle();
expect(auth.gitHubCancels, 1);
});
});
testWidgets("asks before leaving this device's studysets behind; staying "
'keeps them', (tester) async {
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
await openProfile(tester, studysets: 1);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(find.text('Switch to your GitHub account?'), findsOneWidget);
await tester.tap(find.text('Stay anonymous'));
await tester.pumpAndSettle();
expect(auth.gitHubSwitches, 0);
expect(
find.text('Progress is saved on this device. Sign in to keep it.'),
findsOneWidget,
);
});
testWidgets('switches once confirmed, and the app starts anew at home', (
tester,
) async {
auth.gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson;
await openProfile(tester, studysets: 1);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
await tester.tap(find.text('Switch'));
await tester.pumpAndSettle();
expect(auth.gitHubSwitches, 1);
expect(find.text('Switch to your GitHub account?'), findsNothing);
expect(find.byType(HomeScreen), findsOneWidget);
});
group('where people sign in with a password (ADR 0017)', () {
setUp(() => auth.linksGitHub = false);
testWidgets('anonymous people may still sign in with the GitHub account '
'they have; one nobody has is said so, with the password in its '
'place', (tester) async {
await openProfile(tester);
expect(find.text('Sign in with GitHub'), findsOneWidget);
auth.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.deprecated,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text(
'No account here signs in with this GitHub account yet. To keep '
'your progress, set up a password instead.',
),
findsOneWidget,
);
expect(find.text("Couldn't sign in with GitHub."), findsNothing);
expect(
find.text('Progress is saved on this device. Sign in to keep it.'),
findsOneWidget,
);
});
testWidgets('a GitHub account that signs in with its password now is '
'said so', (tester) async {
auth
..gitHubLinkOutcome = GitHubLinkOutcome.takenByAnotherPerson
..gitHubSwitchError = const GitHubSignInFailed(
GitHubSignInProblem.usePassword,
);
await openProfile(tester);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text(
'This account signs in with its email address and password now, '
'not with GitHub.',
),
findsOneWidget,
);
expect(auth.gitHubSwitches, 0);
});
});
group('someone identified', () {
setUp(() {
auth = FakeAuthGateway(
status: const SignedIn(authUserId: 'ada', isIdentified: true),
);
auth.addressOwners['ada@example.org'] = 'ada';
});
testWidgets('is offered to add GitHub where the instance adds it', (
tester,
) async {
await openProfile(tester);
expect(find.text('Add GitHub'), findsOneWidget);
});
testWidgets('is offered no GitHub to add where people sign in with a '
'password (ADR 0017): the instance would refuse it', (tester) async {
auth.linksGitHub = false;
await openProfile(tester);
expect(find.text('Add GitHub'), findsNothing);
expect(find.text('GitHub'), findsNothing);
expect(find.text('Email address'), findsOneWidget);
});
testWidgets('who has GitHub there sees it signs them in, with nothing to '
'add', (tester) async {
auth
..linksGitHub = false
..gitHubPeople.add('ada');
await openProfile(tester);
expect(find.text('GitHub'), findsOneWidget);
expect(find.text('Signs you in'), findsWidgets);
expect(find.text('Add GitHub'), findsNothing);
});
});
}

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