feat: a round in the order cards come, shared daily limits, and a start screen
Studying: - A round is one list in the order its cards come: those done, the one shown, those to come. A card due again within the day goes back in after 4 more cards when rated Bad, at the end otherwise, so the pills above the card follow it; the buttons say "in 4 cards", "at the end", "now" (round_order.dart). - The studied studyset's daily limits hold for everything inside it together, as Anki's do for subdecks: twenty topics start 20 new cards a day in all, not 20 each. - The study header's studyset name shows whole on hover or long press. - The assistant's button sits at the lower left: in the Library across from Create studyset, in a studyset above its bar, and higher while studying, above the buttons that answer the card. A studyset: - Progress also counts the learning paths' steps done and lists the PDFs not read to their end, begun first, each opening the reader, across its topics (MaterialProgressCubit). - Next up starts with studying for the exam while one is ahead: its reviews due and the new cards its pace leaves for the day. - Learning paths tagged with a level count towards its automatic exam plan and offer it, as cards do. - A step's first heading equal to its title is dropped on import and hidden in paths imported before. Signing out, or a session the server ended, leads to a start screen (GitHub, email, without an account, another instance) rather than a new anonymous account; only the very first start makes one by itself. Settings names the kind of account and its instance. Also: the Watch screen reads the cards once for held words and their progress; its ±10 s buttons tick as the navigation bar does; a map card keeps its view as its caption changes and brings a picked region towards the middle; an empty or failed library can be pulled to sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
238a1a93ae
commit
de6af3efab
59 changed files with 2677 additions and 293 deletions
12
AGENTS.md
12
AGENTS.md
File diff suppressed because one or more lines are too long
|
|
@ -33,9 +33,9 @@ then sync with a server that has forgotten their last hour:
|
||||||
- **Someone who used the instance before the backup** loses nothing: their devices still
|
- **Someone who used the instance before the backup** loses nothing: their devices still
|
||||||
have what the server lost, and send it again.
|
have what the server lost, and send it again.
|
||||||
- **Someone who started after the backup** is unknown to the server. At the next start,
|
- **Someone who started after the backup** is unknown to the server. At the next start,
|
||||||
their app finds its session gone, starts a new anonymous account and deletes the old
|
their app finds its session gone and asks how to start; starting anew deletes the
|
||||||
one's data from the device, the only copy left. The same happens to a device that
|
old account's data from the device, the only copy left. The same happens to a device
|
||||||
signed in after the backup, though a GitHub account can be signed into again.
|
that signed in after the backup, though a GitHub account can be signed into again.
|
||||||
- **An account deleted after the backup** comes back. Delete it again if you know which
|
- **An account deleted after the backup** comes back. Delete it again if you know which
|
||||||
one it was.
|
one it was.
|
||||||
- **A release published after the backup** is gone from the instance; learners who took
|
- **A release published after the backup** is gone from the instance; learners who took
|
||||||
|
|
|
||||||
|
|
@ -577,6 +577,11 @@
|
||||||
"settingsTitle": "Einstellungen",
|
"settingsTitle": "Einstellungen",
|
||||||
"settingsAccount": "Konto",
|
"settingsAccount": "Konto",
|
||||||
"instanceWelcome": "Willkommen bei Halkyon Learn",
|
"instanceWelcome": "Willkommen bei Halkyon Learn",
|
||||||
|
"startSignedOut": "Abgemeldet",
|
||||||
|
"startOn": "Starte auf {instance}:",
|
||||||
|
"startWithoutAccount": "Ohne Konto starten",
|
||||||
|
"startNoGitHubAccount": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. 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.",
|
"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",
|
"instanceAddress": "Adresse der Instanz",
|
||||||
"instanceUseBuiltIn": "{name} verwenden",
|
"instanceUseBuiltIn": "{name} verwenden",
|
||||||
|
|
@ -586,6 +591,10 @@
|
||||||
"instanceUnreachable": "Unter {name} antwortet keine Instanz von Halkyon Learn.",
|
"instanceUnreachable": "Unter {name} antwortet keine Instanz von Halkyon Learn.",
|
||||||
"instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.",
|
"instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.",
|
||||||
"settingsSwitchAccount": "Konto wechseln",
|
"settingsSwitchAccount": "Konto wechseln",
|
||||||
|
"settingsAccountAnonymous": "Anonym auf {instance}",
|
||||||
|
"settingsAccountSignedIn": "Angemeldet auf {instance}",
|
||||||
|
"accountAnonymous": "Anonym",
|
||||||
|
"accountSignedIn": "Angemeldet",
|
||||||
"accountsTitle": "Konten",
|
"accountsTitle": "Konten",
|
||||||
"accountAdd": "Konto hinzufügen",
|
"accountAdd": "Konto hinzufügen",
|
||||||
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
|
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
|
||||||
|
|
@ -987,6 +996,9 @@
|
||||||
"studyBackFirstOff": "Rückseite der Karten zuerst zeigen",
|
"studyBackFirstOff": "Rückseite der Karten zuerst zeigen",
|
||||||
"studyShowAnswer": "Antwort anzeigen",
|
"studyShowAnswer": "Antwort anzeigen",
|
||||||
"studyCheck": "Prüfen",
|
"studyCheck": "Prüfen",
|
||||||
|
"studyComesBackIn": "{count, plural, =1{nach 1 Karte} other{nach {count} Karten}}",
|
||||||
|
"studyComesBackAtEnd": "am Ende",
|
||||||
|
"studyComesBackNow": "sofort",
|
||||||
"studySelectAll": "Wähle alle zutreffenden aus",
|
"studySelectAll": "Wähle alle zutreffenden aus",
|
||||||
"studySelectOne": "Wähle die richtige Antwort",
|
"studySelectOne": "Wähle die richtige Antwort",
|
||||||
"studyMatchingInstructions": "AUF DIE KARTEN OBEN ZIEHEN",
|
"studyMatchingInstructions": "AUF DIE KARTEN OBEN ZIEHEN",
|
||||||
|
|
@ -1906,10 +1918,20 @@
|
||||||
"progressThisWeek": "diese Woche",
|
"progressThisWeek": "diese Woche",
|
||||||
"progressMinutes": "{minutes} Min.",
|
"progressMinutes": "{minutes} Min.",
|
||||||
"progressHoursMinutes": "{hours} Std. {minutes} Min.",
|
"progressHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||||
|
"progressPathsInfo": "Die Schritte der Lernpfade dieses Lernsets und seiner Themen, die du erledigt hast.",
|
||||||
|
"progressStepsDone": "{total, plural, =1{von 1 Schritt erledigt} other{von {total} Schritten erledigt}}",
|
||||||
|
"progressReading": "Lesen",
|
||||||
|
"progressReadingInfo": "Die PDFs dieses Lernsets und seiner Themen, die du noch nicht bis zur letzten Seite gelesen hast. Tippe eins an, um weiterzulesen.",
|
||||||
|
"progressPdfPage": "Seite {page} von {count}",
|
||||||
|
"progressPdfNotBegun": "Noch nicht begonnen",
|
||||||
|
"progressPdfsAllRead": "{count, plural, =1{Das PDF ist zu Ende gelesen.} other{Alle {count} PDFs sind zu Ende gelesen.}}",
|
||||||
|
"progressShowAllPdfs": "Alle {count} zeigen",
|
||||||
|
"progressShowFewerPdfs": "Weniger zeigen",
|
||||||
"planPathsDone": "{count, plural, =1{{done} von 1 Pfad abgeschlossen} other{{done} von {count} Pfaden abgeschlossen}}",
|
"planPathsDone": "{count, plural, =1{{done} von 1 Pfad abgeschlossen} other{{done} von {count} Pfaden abgeschlossen}}",
|
||||||
"planNoPaths": "Noch keine Lernpfade",
|
"planNoPaths": "Noch keine Lernpfade",
|
||||||
"planNextUp": "Als Nächstes",
|
"planNextUp": "Als Nächstes",
|
||||||
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
|
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
|
||||||
|
"planStudyForExam": "{count, plural, =1{1 Karteikarte für {exam} lernen} other{{count} Karteikarten für {exam} lernen}}",
|
||||||
"planContinue": "„{title}“ fortsetzen",
|
"planContinue": "„{title}“ fortsetzen",
|
||||||
"planStart": "„{title}“ beginnen",
|
"planStart": "„{title}“ beginnen",
|
||||||
"planStepOf": "Schritt {number} von {total}",
|
"planStepOf": "Schritt {number} von {total}",
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,12 @@
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"settingsAccount": "Account",
|
"settingsAccount": "Account",
|
||||||
"instanceWelcome": "Welcome to Halkyon Learn",
|
"instanceWelcome": "Welcome to Halkyon Learn",
|
||||||
|
"startSignedOut": "Signed out",
|
||||||
|
"startOn": "Start on {instance}:",
|
||||||
|
"@startOn": {"placeholders": {"instance": {"type": "String"}}},
|
||||||
|
"startWithoutAccount": "Start without an account",
|
||||||
|
"startNoGitHubAccount": "No account here signs in with this GitHub account yet. 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.",
|
"instanceFirstStartSubtitle": "Choose the instance to keep your studysets on. Someone runs each one; you can add others later.",
|
||||||
"instanceAddress": "Instance address",
|
"instanceAddress": "Instance address",
|
||||||
"instanceUseBuiltIn": "Use {name}",
|
"instanceUseBuiltIn": "Use {name}",
|
||||||
|
|
@ -660,6 +666,12 @@
|
||||||
"instanceOpenFailed": "Couldn't open the app on {name}.",
|
"instanceOpenFailed": "Couldn't open the app on {name}.",
|
||||||
"@instanceOpenFailed": {"placeholders": {"name": {"type": "String"}}},
|
"@instanceOpenFailed": {"placeholders": {"name": {"type": "String"}}},
|
||||||
"settingsSwitchAccount": "Switch account",
|
"settingsSwitchAccount": "Switch account",
|
||||||
|
"settingsAccountAnonymous": "Anonymous on {instance}",
|
||||||
|
"@settingsAccountAnonymous": {"placeholders": {"instance": {"type": "String"}}},
|
||||||
|
"settingsAccountSignedIn": "Signed in on {instance}",
|
||||||
|
"@settingsAccountSignedIn": {"placeholders": {"instance": {"type": "String"}}},
|
||||||
|
"accountAnonymous": "Anonymous",
|
||||||
|
"accountSignedIn": "Signed in",
|
||||||
"accountsTitle": "Accounts",
|
"accountsTitle": "Accounts",
|
||||||
"accountAdd": "Add account",
|
"accountAdd": "Add account",
|
||||||
"accountAddSubtitle": "Choose the instance for the account to add.",
|
"accountAddSubtitle": "Choose the instance for the account to add.",
|
||||||
|
|
@ -1100,6 +1112,10 @@
|
||||||
"studyBackFirstOff": "Show the back of cards first",
|
"studyBackFirstOff": "Show the back of cards first",
|
||||||
"studyShowAnswer": "Show answer",
|
"studyShowAnswer": "Show answer",
|
||||||
"studyCheck": "Check",
|
"studyCheck": "Check",
|
||||||
|
"studyComesBackIn": "{count, plural, =1{in 1 card} other{in {count} cards}}",
|
||||||
|
"@studyComesBackIn": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"studyComesBackAtEnd": "at the end",
|
||||||
|
"studyComesBackNow": "now",
|
||||||
"studySelectAll": "Select all that apply",
|
"studySelectAll": "Select all that apply",
|
||||||
"studySelectOne": "Select the correct answer",
|
"studySelectOne": "Select the correct answer",
|
||||||
"studyMatchingInstructions": "DRAG AND DROP ONTO CARDS ABOVE",
|
"studyMatchingInstructions": "DRAG AND DROP ONTO CARDS ABOVE",
|
||||||
|
|
@ -2231,12 +2247,27 @@
|
||||||
"@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}},
|
"@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}},
|
||||||
"progressHoursMinutes": "{hours} h {minutes} min",
|
"progressHoursMinutes": "{hours} h {minutes} min",
|
||||||
"@progressHoursMinutes": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
|
"@progressHoursMinutes": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
|
||||||
|
"progressPathsInfo": "The steps of this studyset's learning paths, and its topics', that you have done.",
|
||||||
|
"progressStepsDone": "{total, plural, =1{of 1 step done} other{of {total} steps done}}",
|
||||||
|
"@progressStepsDone": {"placeholders": {"total": {"type": "int"}}},
|
||||||
|
"progressReading": "Reading",
|
||||||
|
"progressReadingInfo": "The PDFs of this studyset and its topics not read to their last page yet. Tap one to read on.",
|
||||||
|
"progressPdfPage": "Page {page} of {count}",
|
||||||
|
"@progressPdfPage": {"placeholders": {"page": {"type": "int"}, "count": {"type": "int"}}},
|
||||||
|
"progressPdfNotBegun": "Not begun yet",
|
||||||
|
"progressPdfsAllRead": "{count, plural, =1{The PDF is read to its end.} other{All {count} PDFs are read to their end.}}",
|
||||||
|
"@progressPdfsAllRead": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"progressShowAllPdfs": "Show all {count}",
|
||||||
|
"@progressShowAllPdfs": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"progressShowFewerPdfs": "Show fewer",
|
||||||
"planPathsDone": "{count, plural, =1{{done} of 1 path finished} other{{done} of {count} paths finished}}",
|
"planPathsDone": "{count, plural, =1{{done} of 1 path finished} other{{done} of {count} paths finished}}",
|
||||||
"@planPathsDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
|
"@planPathsDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
|
||||||
"planNoPaths": "No learning paths yet",
|
"planNoPaths": "No learning paths yet",
|
||||||
"planNextUp": "Next up",
|
"planNextUp": "Next up",
|
||||||
"planReview": "{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}",
|
"planReview": "{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}",
|
||||||
"@planReview": {"placeholders": {"count": {"type": "int"}}},
|
"@planReview": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"planStudyForExam": "{count, plural, =1{Study 1 flashcard for {exam}} other{Study {count} flashcards for {exam}}}",
|
||||||
|
"@planStudyForExam": {"placeholders": {"count": {"type": "int"}, "exam": {"type": "String"}}},
|
||||||
"planContinue": "Continue “{title}”",
|
"planContinue": "Continue “{title}”",
|
||||||
"@planContinue": {"placeholders": {"title": {"type": "String"}}},
|
"@planContinue": {"placeholders": {"title": {"type": "String"}}},
|
||||||
"planStart": "Start “{title}”",
|
"planStart": "Start “{title}”",
|
||||||
|
|
|
||||||
|
|
@ -3454,6 +3454,36 @@ abstract class AppLocalizations {
|
||||||
/// **'Welcome to Halkyon Learn'**
|
/// **'Welcome to Halkyon Learn'**
|
||||||
String get instanceWelcome;
|
String get instanceWelcome;
|
||||||
|
|
||||||
|
/// No description provided for @startSignedOut.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Signed out'**
|
||||||
|
String get startSignedOut;
|
||||||
|
|
||||||
|
/// No description provided for @startOn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'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 @startNoGitHubAccount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.'**
|
||||||
|
String get startNoGitHubAccount;
|
||||||
|
|
||||||
|
/// No description provided for @startOtherInstance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use another instance'**
|
||||||
|
String get startOtherInstance;
|
||||||
|
|
||||||
/// No description provided for @instanceFirstStartSubtitle.
|
/// No description provided for @instanceFirstStartSubtitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3508,6 +3538,30 @@ abstract class AppLocalizations {
|
||||||
/// **'Switch account'**
|
/// **'Switch account'**
|
||||||
String get settingsSwitchAccount;
|
String get settingsSwitchAccount;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAccountAnonymous.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Anonymous on {instance}'**
|
||||||
|
String settingsAccountAnonymous(String instance);
|
||||||
|
|
||||||
|
/// No description provided for @settingsAccountSignedIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Signed in on {instance}'**
|
||||||
|
String settingsAccountSignedIn(String instance);
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
/// **'Signed in'**
|
||||||
|
String get accountSignedIn;
|
||||||
|
|
||||||
/// No description provided for @accountsTitle.
|
/// No description provided for @accountsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5812,6 +5866,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Check'**
|
/// **'Check'**
|
||||||
String get studyCheck;
|
String get studyCheck;
|
||||||
|
|
||||||
|
/// No description provided for @studyComesBackIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{in 1 card} other{in {count} cards}}'**
|
||||||
|
String studyComesBackIn(int count);
|
||||||
|
|
||||||
|
/// No description provided for @studyComesBackAtEnd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'at the end'**
|
||||||
|
String get studyComesBackAtEnd;
|
||||||
|
|
||||||
|
/// No description provided for @studyComesBackNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'now'**
|
||||||
|
String get studyComesBackNow;
|
||||||
|
|
||||||
/// No description provided for @studySelectAll.
|
/// No description provided for @studySelectAll.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -11253,6 +11325,60 @@ abstract class AppLocalizations {
|
||||||
/// **'{hours} h {minutes} min'**
|
/// **'{hours} h {minutes} min'**
|
||||||
String progressHoursMinutes(int hours, int minutes);
|
String progressHoursMinutes(int hours, int minutes);
|
||||||
|
|
||||||
|
/// No description provided for @progressPathsInfo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The steps of this studyset\'s learning paths, and its topics\', that you have done.'**
|
||||||
|
String get progressPathsInfo;
|
||||||
|
|
||||||
|
/// No description provided for @progressStepsDone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{total, plural, =1{of 1 step done} other{of {total} steps done}}'**
|
||||||
|
String progressStepsDone(int total);
|
||||||
|
|
||||||
|
/// No description provided for @progressReading.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reading'**
|
||||||
|
String get progressReading;
|
||||||
|
|
||||||
|
/// No description provided for @progressReadingInfo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The PDFs of this studyset and its topics not read to their last page yet. Tap one to read on.'**
|
||||||
|
String get progressReadingInfo;
|
||||||
|
|
||||||
|
/// No description provided for @progressPdfPage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Page {page} of {count}'**
|
||||||
|
String progressPdfPage(int page, int count);
|
||||||
|
|
||||||
|
/// No description provided for @progressPdfNotBegun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not begun yet'**
|
||||||
|
String get progressPdfNotBegun;
|
||||||
|
|
||||||
|
/// No description provided for @progressPdfsAllRead.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{The PDF is read to its end.} other{All {count} PDFs are read to their end.}}'**
|
||||||
|
String progressPdfsAllRead(int count);
|
||||||
|
|
||||||
|
/// No description provided for @progressShowAllPdfs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show all {count}'**
|
||||||
|
String progressShowAllPdfs(int count);
|
||||||
|
|
||||||
|
/// No description provided for @progressShowFewerPdfs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show fewer'**
|
||||||
|
String get progressShowFewerPdfs;
|
||||||
|
|
||||||
/// No description provided for @planPathsDone.
|
/// No description provided for @planPathsDone.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -11277,6 +11403,12 @@ abstract class AppLocalizations {
|
||||||
/// **'{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}'**
|
/// **'{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}'**
|
||||||
String planReview(int count);
|
String planReview(int count);
|
||||||
|
|
||||||
|
/// No description provided for @planStudyForExam.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{Study 1 flashcard for {exam}} other{Study {count} flashcards for {exam}}}'**
|
||||||
|
String planStudyForExam(int count, String exam);
|
||||||
|
|
||||||
/// No description provided for @planContinue.
|
/// No description provided for @planContinue.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2113,6 +2113,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get instanceWelcome => 'Willkommen bei Halkyon Learn';
|
String get instanceWelcome => 'Willkommen bei Halkyon Learn';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startSignedOut => 'Abgemeldet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startOn(String instance) {
|
||||||
|
return 'Starte auf $instance:';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startWithoutAccount => 'Ohne Konto starten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startNoGitHubAccount =>
|
||||||
|
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Starte ohne Konto und füge GitHub dann im Profil hinzu.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startOtherInstance => 'Andere Instanz verwenden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get instanceFirstStartSubtitle =>
|
String get instanceFirstStartSubtitle =>
|
||||||
'Wähle die Instanz, auf der deine Lernsets liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
|
'Wähle die Instanz, auf der deine Lernsets liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
|
||||||
|
|
@ -2147,6 +2165,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsSwitchAccount => 'Konto wechseln';
|
String get settingsSwitchAccount => 'Konto wechseln';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsAccountAnonymous(String instance) {
|
||||||
|
return 'Anonym auf $instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsAccountSignedIn(String instance) {
|
||||||
|
return 'Angemeldet auf $instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountAnonymous => 'Anonym';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountSignedIn => 'Angemeldet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accountsTitle => 'Konten';
|
String get accountsTitle => 'Konten';
|
||||||
|
|
||||||
|
|
@ -3572,6 +3606,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studyCheck => 'Prüfen';
|
String get studyCheck => 'Prüfen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studyComesBackIn(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'nach $count Karten',
|
||||||
|
one: 'nach 1 Karte',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyComesBackAtEnd => 'am Ende';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyComesBackNow => 'sofort';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studySelectAll => 'Wähle alle zutreffenden aus';
|
String get studySelectAll => 'Wähle alle zutreffenden aus';
|
||||||
|
|
||||||
|
|
@ -7069,6 +7120,55 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$hours Std. $minutes Min.';
|
return '$hours Std. $minutes Min.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressPathsInfo =>
|
||||||
|
'Die Schritte der Lernpfade dieses Lernsets und seiner Themen, die du erledigt hast.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressStepsDone(int total) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
total,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'von $total Schritten erledigt',
|
||||||
|
one: 'von 1 Schritt erledigt',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressReading => 'Lesen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressReadingInfo =>
|
||||||
|
'Die PDFs dieses Lernsets und seiner Themen, die du noch nicht bis zur letzten Seite gelesen hast. Tippe eins an, um weiterzulesen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressPdfPage(int page, int count) {
|
||||||
|
return 'Seite $page von $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressPdfNotBegun => 'Noch nicht begonnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressPdfsAllRead(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Alle $count PDFs sind zu Ende gelesen.',
|
||||||
|
one: 'Das PDF ist zu Ende gelesen.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressShowAllPdfs(int count) {
|
||||||
|
return 'Alle $count zeigen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressShowFewerPdfs => 'Weniger zeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planPathsDone(int done, int count) {
|
String planPathsDone(int done, int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
|
@ -7097,6 +7197,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planStudyForExam(int count, String exam) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Karteikarten für $exam lernen',
|
||||||
|
one: '1 Karteikarte für $exam lernen',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planContinue(String title) {
|
String planContinue(String title) {
|
||||||
return '„$title“ fortsetzen';
|
return '„$title“ fortsetzen';
|
||||||
|
|
|
||||||
|
|
@ -2093,6 +2093,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get instanceWelcome => 'Welcome to Halkyon Learn';
|
String get instanceWelcome => 'Welcome to Halkyon Learn';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startSignedOut => 'Signed out';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String startOn(String instance) {
|
||||||
|
return 'Start on $instance:';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startWithoutAccount => 'Start without an account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startNoGitHubAccount =>
|
||||||
|
'No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startOtherInstance => 'Use another instance';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get instanceFirstStartSubtitle =>
|
String get instanceFirstStartSubtitle =>
|
||||||
'Choose the instance to keep your studysets on. Someone runs each one; you can add others later.';
|
'Choose the instance to keep your studysets on. Someone runs each one; you can add others later.';
|
||||||
|
|
@ -2127,6 +2145,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get settingsSwitchAccount => 'Switch account';
|
String get settingsSwitchAccount => 'Switch account';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsAccountAnonymous(String instance) {
|
||||||
|
return 'Anonymous on $instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsAccountSignedIn(String instance) {
|
||||||
|
return 'Signed in on $instance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountAnonymous => 'Anonymous';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountSignedIn => 'Signed in';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accountsTitle => 'Accounts';
|
String get accountsTitle => 'Accounts';
|
||||||
|
|
||||||
|
|
@ -3542,6 +3576,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studyCheck => 'Check';
|
String get studyCheck => 'Check';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studyComesBackIn(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'in $count cards',
|
||||||
|
one: 'in 1 card',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyComesBackAtEnd => 'at the end';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studyComesBackNow => 'now';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studySelectAll => 'Select all that apply';
|
String get studySelectAll => 'Select all that apply';
|
||||||
|
|
||||||
|
|
@ -7004,6 +7055,55 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$hours h $minutes min';
|
return '$hours h $minutes min';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressPathsInfo =>
|
||||||
|
'The steps of this studyset\'s learning paths, and its topics\', that you have done.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressStepsDone(int total) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
total,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'of $total steps done',
|
||||||
|
one: 'of 1 step done',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressReading => 'Reading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressReadingInfo =>
|
||||||
|
'The PDFs of this studyset and its topics not read to their last page yet. Tap one to read on.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressPdfPage(int page, int count) {
|
||||||
|
return 'Page $page of $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressPdfNotBegun => 'Not begun yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressPdfsAllRead(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'All $count PDFs are read to their end.',
|
||||||
|
one: 'The PDF is read to its end.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String progressShowAllPdfs(int count) {
|
||||||
|
return 'Show all $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get progressShowFewerPdfs => 'Show fewer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planPathsDone(int done, int count) {
|
String planPathsDone(int done, int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
|
@ -7032,6 +7132,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planStudyForExam(int count, String exam) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Study $count flashcards for $exam',
|
||||||
|
one: 'Study 1 flashcard for $exam',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planContinue(String title) {
|
String planContinue(String title) {
|
||||||
return 'Continue “$title”';
|
return 'Continue “$title”';
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,10 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.profile,
|
path: RoutePaths.profile,
|
||||||
builder: (context, state) => const ProfileScreen(),
|
builder: (context, state) => ProfileScreen(
|
||||||
|
signsInWithEmail:
|
||||||
|
state.uri.queryParameters['signIn'] == 'email',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -217,6 +220,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
person.cardDeletions,
|
person.cardDeletions,
|
||||||
person.reviews,
|
person.reviews,
|
||||||
person.tags,
|
person.tags,
|
||||||
|
person.paths,
|
||||||
person.settings,
|
person.settings,
|
||||||
studysetId,
|
studysetId,
|
||||||
focus: StudyFocus.fromQuery(state.uri.queryParameters),
|
focus: StudyFocus.fromQuery(state.uri.queryParameters),
|
||||||
|
|
|
||||||
|
|
@ -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/core/widget/keyboard_back_on_resume.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/keyboard_back_on_resume.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.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/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';
|
||||||
|
|
@ -44,7 +45,12 @@ class const PersonApp({
|
||||||
|
|
||||||
class _PersonAppState() extends State<PersonApp> {
|
class _PersonAppState() extends State<PersonApp> {
|
||||||
late final GoRouter _router = buildRouter(
|
late final GoRouter _router = buildRouter(
|
||||||
initialLocation: widget.initialLocation,
|
// Started with an email address: Profile asks for it at once.
|
||||||
|
initialLocation: switch (context.read<SessionBloc>().state) {
|
||||||
|
SessionStateActive(signsInWithEmail: true) =>
|
||||||
|
RoutePaths.profileEmailSignIn,
|
||||||
|
_ => widget.initialLocation,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,39 @@ final class RichContent(
|
||||||
|
|
||||||
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
|
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
|
||||||
|
|
||||||
|
/// This content without its first block when that is a heading reading
|
||||||
|
/// [text], whatever its case and spaces (decided 2026-10-16): a heading
|
||||||
|
/// that repeats the title shown above it, as imported lessons often
|
||||||
|
/// open. Unchanged otherwise.
|
||||||
|
RichContent withoutHeading(String text) {
|
||||||
|
String tidy(String text) =>
|
||||||
|
text.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
|
||||||
|
if (editorJson['document']
|
||||||
|
case {
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'type': 'heading',
|
||||||
|
'data': {'delta': final List<Object?> delta},
|
||||||
|
},
|
||||||
|
...final rest,
|
||||||
|
],
|
||||||
|
} &&
|
||||||
|
final Map<String, Object?> document
|
||||||
|
when tidy(text).isNotEmpty) {
|
||||||
|
final heading = [
|
||||||
|
for (final operation in delta)
|
||||||
|
if (operation case {'insert': final String text}) text,
|
||||||
|
].join();
|
||||||
|
if (tidy(heading) == tidy(text)) {
|
||||||
|
return RichContent({
|
||||||
|
...editorJson,
|
||||||
|
'document': {...document, 'children': rest},
|
||||||
|
}, formatVersion: formatVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/// Every block, depth first.
|
/// Every block, depth first.
|
||||||
Iterable<Map<String, Object?>> get _nodes sync* {
|
Iterable<Map<String, Object?>> get _nodes sync* {
|
||||||
Iterable<Map<String, Object?>> visit(Object? node) sync* {
|
Iterable<Map<String, Object?>> visit(Object? node) sync* {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/signed_out_mark.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/card/data/card_deletions.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
|
|
@ -112,6 +113,9 @@ class Dependencies({
|
||||||
/// Who is signed in, and signing in and out.
|
/// Who is signed in, and signing in and out.
|
||||||
required final AuthRepository auth,
|
required final AuthRepository auth,
|
||||||
|
|
||||||
|
/// Whether the person here signed out, so the app asks how to start.
|
||||||
|
required final SignedOutMark signedOut,
|
||||||
|
|
||||||
/// What the instance says about itself, such as where its source is.
|
/// What the instance says about itself, such as where its source is.
|
||||||
required final AboutRepository about,
|
required final AboutRepository about,
|
||||||
|
|
||||||
|
|
@ -233,6 +237,7 @@ class Dependencies({
|
||||||
authGateway,
|
authGateway,
|
||||||
FileSignInAddresses(device.storage.peopleOf(instance)),
|
FileSignInAddresses(device.storage.peopleOf(instance)),
|
||||||
),
|
),
|
||||||
|
signedOut: FileSignedOutMark(device.storage.peopleOf(instance)),
|
||||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||||
dictionaries: DictionaryRepository(
|
dictionaries: DictionaryRepository(
|
||||||
ServerpodDictionaryGateway(client),
|
ServerpodDictionaryGateway(client),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ abstract final class RoutePaths() {
|
||||||
/// The profile tab.
|
/// The profile tab.
|
||||||
static const profile = '/profile';
|
static const profile = '/profile';
|
||||||
|
|
||||||
|
/// Profile, asking for an email address to sign in with at once: as the
|
||||||
|
/// app opens after a start with one (decided 2026-10-16).
|
||||||
|
static const profileEmailSignIn = '$profile?signIn=email';
|
||||||
|
|
||||||
/// Settings, over the tabs.
|
/// Settings, over the tabs.
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,8 +400,10 @@ class const AssistantBar({super.key}) extends StatelessWidget {
|
||||||
|
|
||||||
/// A round button that unfolds into [AssistantField], [expandedWidth] wide,
|
/// A round button that unfolds into [AssistantField], [expandedWidth] wide,
|
||||||
/// and folds back once sent or closed: the assistant's floating button in
|
/// and folds back once sent or closed: the assistant's floating button in
|
||||||
/// the Library, on a studyset and while studying. [below] (the Library's
|
/// the Library, on a studyset and while studying, at the lower left
|
||||||
/// Create studyset) stands under it and makes way while it is unfolded.
|
/// (decided 2026-10-16, as Vaia has it). [below] (a studyset's bar) stands
|
||||||
|
/// under it, [end] (the Library's Create studyset) at the other end of its
|
||||||
|
/// row; either makes way while it is unfolded.
|
||||||
///
|
///
|
||||||
/// Nothing (or only [below]) while asking is off.
|
/// Nothing (or only [below]) while asking is off.
|
||||||
class const AssistantButton({
|
class const AssistantButton({
|
||||||
|
|
@ -417,6 +419,9 @@ class const AssistantButton({
|
||||||
|
|
||||||
/// A floating button under it.
|
/// A floating button under it.
|
||||||
final Widget? below,
|
final Widget? below,
|
||||||
|
|
||||||
|
/// A floating button at the other end of its row.
|
||||||
|
final Widget? end,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -431,8 +436,9 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final below = widget.below;
|
final below = widget.below;
|
||||||
|
final end = widget.end;
|
||||||
if (!assistantOn(context)) {
|
if (!assistantOn(context)) {
|
||||||
return below ?? const SizedBox.shrink();
|
return below ?? end ?? const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -483,12 +489,21 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (end != null) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
button,
|
||||||
|
if (!_expanded) ...[const Spacer(), end],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
if (below == null) {
|
if (below == null) {
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
button,
|
button,
|
||||||
if (!_expanded) ...[const SizedBox(height: 16), below],
|
if (!_expanded) ...[const SizedBox(height: 16), below],
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,56 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
||||||
|
show GitHubSignInRefusal, GitHubSignInRefusedException;
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/auth/data/signed_out_mark.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';
|
||||||
|
|
||||||
/// Keeps a person signed in: follows who is signed in, signs in anonymously
|
/// Keeps a person signed in: follows who is signed in, signs in anonymously
|
||||||
/// whenever nobody is, and signs out on request.
|
/// at a first start, and signs out on request. Once signed out (by the
|
||||||
class SessionBloc(final AuthRepository _auth)
|
/// person, by deleting the account, or by the server ending the session),
|
||||||
|
/// it asks how to start rather than making an account by itself (decided
|
||||||
|
/// 2026-10-16), also at every start of the app until someone starts.
|
||||||
|
class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
||||||
extends Bloc<SessionEvent, SessionState> {
|
extends Bloc<SessionEvent, SessionState> {
|
||||||
this : super(const SessionStateLoading()) {
|
this : super(const SessionStateLoading()) {
|
||||||
on<SessionEventStarted>(_onStarted);
|
on<SessionEventStarted>(_onStarted);
|
||||||
on<SessionEventSignInRequested>(_onSignInRequested);
|
on<SessionEventSignInRequested>(_onSignInRequested);
|
||||||
|
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
|
||||||
|
on<SessionEventGitHubRequested>(_onGitHubRequested);
|
||||||
|
on<SessionEventEmailRequested>(_onEmailRequested);
|
||||||
|
on<SessionEventSignedOut>(
|
||||||
|
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
|
||||||
|
);
|
||||||
on<SessionEventSignOutRequested>(_onSignOutRequested);
|
on<SessionEventSignOutRequested>(_onSignOutRequested);
|
||||||
on<SessionEventDeleteRequested>(_onDeleteRequested);
|
on<SessionEventDeleteRequested>(_onDeleteRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an email sign-in follows the anonymous start under way.
|
||||||
|
var _thenEmail = false;
|
||||||
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
SessionEventStarted event,
|
SessionEventStarted event,
|
||||||
Emitter<SessionState> emit,
|
Emitter<SessionState> emit,
|
||||||
) async {
|
) async {
|
||||||
add(const SessionEventSignInRequested());
|
if (_auth.status is SignedOut && await _mark.read()) {
|
||||||
|
emit(const SessionStateSignedOut());
|
||||||
|
} else {
|
||||||
|
add(const SessionEventSignInRequested());
|
||||||
|
}
|
||||||
await emit.forEach<AuthStatus>(
|
await emit.forEach<AuthStatus>(
|
||||||
_auth.statusChanges,
|
_auth.statusChanges,
|
||||||
onData: (status) {
|
onData: (status) {
|
||||||
if (status is SignedOut) {
|
if (status is SignedOut) {
|
||||||
// Signed out, here or by the server (a revoked session): start anew.
|
// Signed out, here or by the server (a revoked session): ask how
|
||||||
add(const SessionEventSignInRequested());
|
// to start, now and at the next starts.
|
||||||
|
unawaited(_mark.write(signedOut: true));
|
||||||
|
return const SessionStateSignedOut();
|
||||||
}
|
}
|
||||||
return _stateOf(status);
|
unawaited(_mark.write(signedOut: false));
|
||||||
|
return _stateOf(status, signsInWithEmail: _thenEmail);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -41,22 +65,72 @@ class SessionBloc(final AuthRepository _auth)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit(const SessionStateLoading());
|
emit(const SessionStateLoading());
|
||||||
|
// Read before signing in: the status change on the way shows the app
|
||||||
|
// too, and must ask for the address as well.
|
||||||
|
final email = _thenEmail;
|
||||||
try {
|
try {
|
||||||
await _auth.ensureSignedIn();
|
await _auth.ensureSignedIn();
|
||||||
emit(_stateOf(_auth.status));
|
_thenEmail = false;
|
||||||
|
emit(_stateOf(_auth.status, signsInWithEmail: email));
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
|
_thenEmail = false;
|
||||||
addError(error, st);
|
addError(error, st);
|
||||||
emit(const SessionStateFailed());
|
emit(const SessionStateFailed());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onStartedAnonymously(
|
||||||
|
SessionEventStartedAnonymously event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
|
await _mark.write(signedOut: false);
|
||||||
|
add(const SessionEventSignInRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signing in with GitHub needs no account here first: the GitHub
|
||||||
|
/// account's person, if there is one.
|
||||||
|
Future<void> _onGitHubRequested(
|
||||||
|
SessionEventGitHubRequested event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
|
emit(const SessionStateSignedOut(busy: true));
|
||||||
|
try {
|
||||||
|
if (!await _auth.switchToGitHub()) {
|
||||||
|
emit(const SessionStateSignedOut());
|
||||||
|
}
|
||||||
|
// Signed in: the status change shows the person's app.
|
||||||
|
} on GitHubSignInRefusedException catch (e) {
|
||||||
|
emit(
|
||||||
|
SessionStateSignedOut(
|
||||||
|
problem: e.reason == GitHubSignInRefusal.unknownAccount
|
||||||
|
? SignedOutProblem.noGitHubAccount
|
||||||
|
: SignedOutProblem.failed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on Object catch (error, st) {
|
||||||
|
addError(error, st);
|
||||||
|
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Email codes are only taken from someone signed in: an anonymous start
|
||||||
|
/// first, then the address, which switches to its person if it has one.
|
||||||
|
Future<void> _onEmailRequested(
|
||||||
|
SessionEventEmailRequested event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
|
_thenEmail = true;
|
||||||
|
await _mark.write(signedOut: false);
|
||||||
|
add(const SessionEventSignInRequested());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onSignOutRequested(
|
Future<void> _onSignOutRequested(
|
||||||
SessionEventSignOutRequested event,
|
SessionEventSignOutRequested event,
|
||||||
Emitter<SessionState> emit,
|
Emitter<SessionState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(const SessionStateLoading());
|
emit(const SessionStateLoading());
|
||||||
try {
|
try {
|
||||||
// The status change to signed out then starts a new anonymous session.
|
// The status change to signed out then asks how to start.
|
||||||
await _auth.signOut();
|
await _auth.signOut();
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
addError(error, st);
|
addError(error, st);
|
||||||
|
|
@ -70,7 +144,7 @@ class SessionBloc(final AuthRepository _auth)
|
||||||
) async {
|
) async {
|
||||||
emit(const SessionStateLoading());
|
emit(const SessionStateLoading());
|
||||||
try {
|
try {
|
||||||
// The status change to signed out then starts a new anonymous session.
|
// The status change to signed out then asks how to start.
|
||||||
await _auth.deleteAccount();
|
await _auth.deleteAccount();
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
addError(error, st);
|
addError(error, st);
|
||||||
|
|
@ -78,10 +152,14 @@ class SessionBloc(final AuthRepository _auth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionState _stateOf(AuthStatus status) => switch (status) {
|
SessionState _stateOf(AuthStatus status, {bool signsInWithEmail = false}) =>
|
||||||
SignedIn() => SessionStateActive(status),
|
switch (status) {
|
||||||
SignedOut() => const SessionStateLoading(),
|
SignedIn() => SessionStateActive(
|
||||||
};
|
status,
|
||||||
|
signsInWithEmail: signsInWithEmail,
|
||||||
|
),
|
||||||
|
SignedOut() => const SessionStateSignedOut(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What happens to the session.
|
/// What happens to the session.
|
||||||
|
|
@ -97,6 +175,22 @@ final class const SessionEventStarted() extends SessionEvent;
|
||||||
/// Sign in (again): after a failed attempt, or once nobody is signed in.
|
/// Sign in (again): after a failed attempt, or once nobody is signed in.
|
||||||
final class const SessionEventSignInRequested() extends SessionEvent;
|
final class const SessionEventSignInRequested() extends SessionEvent;
|
||||||
|
|
||||||
|
/// Start without an account, once signed out: a new anonymous one.
|
||||||
|
final class const SessionEventStartedAnonymously() extends SessionEvent;
|
||||||
|
|
||||||
|
/// Sign in with GitHub, once signed out.
|
||||||
|
final class const SessionEventGitHubRequested() extends SessionEvent;
|
||||||
|
|
||||||
|
/// Sign in with an email address, once signed out.
|
||||||
|
final class const SessionEventEmailRequested() extends SessionEvent;
|
||||||
|
|
||||||
|
/// Signed out, [problem] if any saying why the last way to start failed.
|
||||||
|
final class const SessionEventSignedOut({final SignedOutProblem? problem})
|
||||||
|
extends SessionEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [problem];
|
||||||
|
}
|
||||||
|
|
||||||
/// The person confirmed that they want to sign out.
|
/// The person confirmed that they want to sign out.
|
||||||
final class const SessionEventSignOutRequested() extends SessionEvent;
|
final class const SessionEventSignOutRequested() extends SessionEvent;
|
||||||
|
|
||||||
|
|
@ -123,8 +217,34 @@ final class const SessionStateFailed() extends SessionState {
|
||||||
/// A person is signed in.
|
/// A person is signed in.
|
||||||
final class const SessionStateActive(
|
final class const SessionStateActive(
|
||||||
/// Who is signed in.
|
/// Who is signed in.
|
||||||
final SignedIn person,
|
final SignedIn person, {
|
||||||
) extends SessionState {
|
|
||||||
|
/// Whether they asked to sign in with an email address, which their app
|
||||||
|
/// asks for once it opens.
|
||||||
|
final bool signsInWithEmail = false,
|
||||||
|
}) extends SessionState {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [person];
|
List<Object?> get props => [person, signsInWithEmail];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nobody is signed in since signing out: the app asks how to start.
|
||||||
|
final class const SessionStateSignedOut({
|
||||||
|
/// Whether a way to start is under way.
|
||||||
|
final bool busy = false,
|
||||||
|
|
||||||
|
/// Why the last way to start failed; null for none.
|
||||||
|
final SignedOutProblem? problem,
|
||||||
|
}) extends SessionState {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [busy, problem];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a way to start failed.
|
||||||
|
enum SignedOutProblem() {
|
||||||
|
/// No account has the GitHub account yet: start without one, then add
|
||||||
|
/// GitHub.
|
||||||
|
noGitHubAccount,
|
||||||
|
|
||||||
|
/// Anything else, as no connection.
|
||||||
|
failed,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// Whether the person on this device signed out of the instance, and
|
||||||
|
/// nobody started since (decided 2026-10-16): the app then asks how to
|
||||||
|
/// start rather than making an anonymous account by itself, at once and at
|
||||||
|
/// every start after.
|
||||||
|
abstract interface class SignedOutMark() {
|
||||||
|
/// Whether the device is marked signed out.
|
||||||
|
Future<bool> read();
|
||||||
|
|
||||||
|
/// Marks the device signed out, or not.
|
||||||
|
Future<void> write({required bool signedOut});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mark as a file in the folder of the instance's people, which
|
||||||
|
/// outlives every person's own.
|
||||||
|
class FileSignedOutMark(final Directory _people) implements SignedOutMark {
|
||||||
|
File get _file => File(p.join(_people.path, 'signed_out'));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> read() async => _file.existsSync();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write({required bool signedOut}) async {
|
||||||
|
if (signedOut) {
|
||||||
|
await _people.create(recursive: true);
|
||||||
|
await _file.writeAsString('');
|
||||||
|
} else if (_file.existsSync()) {
|
||||||
|
await _file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
|
@ -26,7 +28,12 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// address to keep an anonymous one, and the ways an identified person signs
|
/// address to keep an anonymous one, and the ways an identified person signs
|
||||||
/// in) and the person's statistics, which anonymous people have too.
|
/// in) and the person's statistics, which anonymous people have too.
|
||||||
/// Settings are behind the gear.
|
/// Settings are behind the gear.
|
||||||
class const ProfileScreen({super.key}) extends StatelessWidget {
|
class const ProfileScreen({
|
||||||
|
/// Whether it asks for an email address to sign in with at once, as after
|
||||||
|
/// a start with one.
|
||||||
|
final bool signsInWithEmail = false,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
@ -43,43 +50,46 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
||||||
SignInWaysCubit(DependenciesScope.of(context).auth)..load(),
|
SignInWaysCubit(DependenciesScope.of(context).auth)..load(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: TopGradient(
|
child: _AsksForEmail(
|
||||||
color: Brand.primary,
|
asks: signsInWithEmail,
|
||||||
background: Theme.of(context).colorScheme.surfaceContainerLow,
|
child: TopGradient(
|
||||||
child: DefaultTabController(
|
color: Brand.primary,
|
||||||
length: 2,
|
background: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
child: Scaffold(
|
child: DefaultTabController(
|
||||||
backgroundColor: Colors.transparent,
|
length: 2,
|
||||||
appBar: AppBar(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
appBar: AppBar(
|
||||||
title: Text(l10n.profileTitle),
|
backgroundColor: Colors.transparent,
|
||||||
actions: [
|
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||||
IconButton(
|
title: Text(l10n.profileTitle),
|
||||||
icon: const SyncTroubleBadge(
|
actions: [
|
||||||
child: Icon(LucideIcons.settings),
|
IconButton(
|
||||||
|
icon: const SyncTroubleBadge(
|
||||||
|
child: Icon(LucideIcons.settings),
|
||||||
|
),
|
||||||
|
tooltip: l10n.profileSettingsTooltip,
|
||||||
|
onPressed: () => context.push(RoutePaths.settings),
|
||||||
),
|
),
|
||||||
tooltip: l10n.profileSettingsTooltip,
|
const SizedBox(width: 4),
|
||||||
onPressed: () => context.push(RoutePaths.settings),
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
isScrollable: true,
|
||||||
|
tabAlignment: TabAlignment.start,
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: l10n.profileTabProfile),
|
||||||
|
Tab(text: l10n.profileTabStatistics),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
],
|
body: const TabBarView(
|
||||||
bottom: TabBar(
|
children: [
|
||||||
isScrollable: true,
|
_CancelWhenLeft(child: _Account()),
|
||||||
tabAlignment: TabAlignment.start,
|
StatisticsView(),
|
||||||
dividerColor: Colors.transparent,
|
|
||||||
tabs: [
|
|
||||||
Tab(text: l10n.profileTabProfile),
|
|
||||||
Tab(text: l10n.profileTabStatistics),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: const TabBarView(
|
|
||||||
children: [
|
|
||||||
_CancelWhenLeft(child: _Account()),
|
|
||||||
StatisticsView(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -426,3 +436,30 @@ Future<bool> _confirmSwitch(BuildContext context) async {
|
||||||
);
|
);
|
||||||
return confirmed ?? false;
|
return confirmed ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks for an email address to sign in with once its screen shows, when
|
||||||
|
/// [asks] says so: after a start with one.
|
||||||
|
class const _AsksForEmail({
|
||||||
|
required final bool asks,
|
||||||
|
required final Widget child,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_AsksForEmail> createState() => _AsksForEmailState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AsksForEmailState() extends State<_AsksForEmail> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.asks) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(_signInWithEmail(context));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/launch_screen.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/launch_screen.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/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/auth/widget/start_screen.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Shows the app of whoever is signed in, signing in anonymously first if
|
/// Shows the app of whoever is signed in, signing in anonymously at a
|
||||||
/// nobody is.
|
/// first start, and asking how to start once signed out ([StartScreen]).
|
||||||
///
|
///
|
||||||
/// [app] is built anew for every person (keyed by the person), so nothing of
|
/// [app] is built anew for every person (keyed by the person), so nothing of
|
||||||
/// one person's app, such as screens, blocs, streams or caches, reaches
|
/// one person's app, such as screens, blocs, streams or caches, reaches
|
||||||
|
|
@ -28,13 +29,17 @@ class const SessionGate({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => DeviceSettingsScope(
|
Widget build(BuildContext context) => DeviceSettingsScope(
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (context) =>
|
create: (context) => SessionBloc(
|
||||||
SessionBloc(DependenciesScope.of(context).auth)
|
DependenciesScope.of(context).auth,
|
||||||
..add(const SessionEventStarted()),
|
DependenciesScope.of(context).signedOut,
|
||||||
|
)..add(const SessionEventStarted()),
|
||||||
child: BlocBuilder<SessionBloc, SessionState>(
|
child: BlocBuilder<SessionBloc, SessionState>(
|
||||||
builder: (context, state) => switch (state) {
|
builder: (context, state) => switch (state) {
|
||||||
SessionStateLoading() => const _PlainApp(home: LaunchScreen()),
|
SessionStateLoading() => const _PlainApp(home: LaunchScreen()),
|
||||||
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
||||||
|
final SessionStateSignedOut signedOut => _PlainApp(
|
||||||
|
home: StartScreen(state: signedOut),
|
||||||
|
),
|
||||||
SessionStateActive(:final person) => KeyedSubtree(
|
SessionStateActive(:final person) => KeyedSubtree(
|
||||||
key: ValueKey(person.authUserId),
|
key: ValueKey(person.authUserId),
|
||||||
child: app(person),
|
child: app(person),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/instance/widget/account_switcher_sheet.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// How to start once signed out (decided 2026-10-16): on the instance in
|
||||||
|
/// use, with GitHub or an email address where it offers them, or without
|
||||||
|
/// an account; or on another instance. Nothing is made until the person
|
||||||
|
/// chooses.
|
||||||
|
class const StartScreen({required final SessionStateSignedOut state, super.key})
|
||||||
|
extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<StartScreen> createState() => _StartScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartScreenState() extends State<StartScreen> {
|
||||||
|
late final Future<({bool gitHub, bool email})> _offers = () async {
|
||||||
|
final auth = DependenciesScope.of(context).auth;
|
||||||
|
final (gitHub, email) = await (
|
||||||
|
auth.canSignInWithGitHub(),
|
||||||
|
auth.canSignInWithEmail(),
|
||||||
|
).wait;
|
||||||
|
return (gitHub: gitHub, email: email);
|
||||||
|
}();
|
||||||
|
|
||||||
|
void _send(SessionEvent event) => context.read<SessionBloc>().add(event);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final instance =
|
||||||
|
InstanceScope.maybeOf(context)?.choice.current?.name ??
|
||||||
|
DependenciesScope.of(context).serverName;
|
||||||
|
final busy = widget.state.busy;
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: FutureBuilder(
|
||||||
|
future: _offers,
|
||||||
|
builder: (context, offers) {
|
||||||
|
final (:gitHub, :email) =
|
||||||
|
offers.data ?? (gitHub: false, email: false);
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.startSignedOut,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.startOn(instance),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (gitHub) ...[
|
||||||
|
FilledButton(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () => _send(
|
||||||
|
const SessionEventGitHubRequested(),
|
||||||
|
),
|
||||||
|
child: busy
|
||||||
|
? const ButtonLoadingIndicator()
|
||||||
|
: Text(l10n.profileSignInWithGitHub),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
if (email) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () => _send(const SessionEventEmailRequested()),
|
||||||
|
child: Text(l10n.profileContinueWithEmail),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () => _send(
|
||||||
|
const SessionEventStartedAnonymously(),
|
||||||
|
),
|
||||||
|
child: Text(l10n.startWithoutAccount),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.profileAnonymous,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.state.problem case final problem?) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
switch (problem) {
|
||||||
|
SignedOutProblem.noGitHubAccount =>
|
||||||
|
l10n.startNoGitHubAccount,
|
||||||
|
SignedOutProblem.failed => l10n.profileSignInFailed,
|
||||||
|
},
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (InstanceScope.maybeOf(context) != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextButton(
|
||||||
|
onPressed: busy
|
||||||
|
? null
|
||||||
|
: () => showAccountSwitcher(context),
|
||||||
|
child: Text(l10n.startOtherInstance),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,21 +35,27 @@ class DictionaryAdder(
|
||||||
) {
|
) {
|
||||||
final _queue = SerialQueue();
|
final _queue = SerialQueue();
|
||||||
|
|
||||||
/// The entries held in each studyset and inside it, as read once and
|
/// The cards of each studyset and inside it made from dictionary
|
||||||
/// added to since.
|
/// entries, by their entry, as read once and added to since.
|
||||||
final _held = <String, Set<DictionaryRef>>{};
|
final _held = <String, Map<DictionaryRef, String>>{};
|
||||||
|
|
||||||
/// The dictionary entries the cards of [studysetId] and everything inside
|
/// The dictionary entries the cards of [studysetId] and everything inside
|
||||||
/// it were made from.
|
/// it were made from.
|
||||||
Future<Set<DictionaryRef>> entriesIn(String studysetId) =>
|
Future<Set<DictionaryRef>> entriesIn(String studysetId) =>
|
||||||
_queue.run(() async => {...await _entriesIn(studysetId)});
|
_queue.run(() async => {...(await _heldIn(studysetId)).keys});
|
||||||
|
|
||||||
Future<Set<DictionaryRef>> _entriesIn(String studysetId) async =>
|
/// The cards of [studysetId] and everything inside it made from
|
||||||
|
/// dictionary entries, by their entry: which words it holds, and the
|
||||||
|
/// cards to read how far each is learnt from, in one read of the cards.
|
||||||
|
Future<Map<DictionaryRef, String>> heldIn(String studysetId) =>
|
||||||
|
_queue.run(() async => {...await _heldIn(studysetId)});
|
||||||
|
|
||||||
|
Future<Map<DictionaryRef, String>> _heldIn(String studysetId) async =>
|
||||||
_held[studysetId] ??= {
|
_held[studysetId] ??= {
|
||||||
for (final card in await _cards.cardsIn(
|
for (final card in await _cards.cardsIn(
|
||||||
await _studysets.subtree(studysetId),
|
await _studysets.subtree(studysetId),
|
||||||
))
|
))
|
||||||
?card.content.entry,
|
?card.content.entry: card.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The studysets words added from [studysetId] may go to: it and every
|
/// The studysets words added from [studysetId] may go to: it and every
|
||||||
|
|
@ -125,10 +131,10 @@ class DictionaryAdder(
|
||||||
List<DictionaryItem> words,
|
List<DictionaryItem> words,
|
||||||
List<({String name, JellyfinTagId? id})> jellyfinTags,
|
List<({String name, JellyfinTagId? id})> jellyfinTags,
|
||||||
) async {
|
) async {
|
||||||
final there = await _entriesIn(studysetId);
|
final there = await _heldIn(studysetId);
|
||||||
final fresh = [
|
final fresh = [
|
||||||
for (final word in words)
|
for (final word in words)
|
||||||
if (!there.contains(word.ref)) word,
|
if (!there.containsKey(word.ref)) word,
|
||||||
];
|
];
|
||||||
// Each word once, also when it comes twice.
|
// Each word once, also when it comes twice.
|
||||||
final once = <DictionaryRef>{};
|
final once = <DictionaryRef>{};
|
||||||
|
|
@ -140,7 +146,9 @@ class DictionaryAdder(
|
||||||
for (final word in fresh) word.card,
|
for (final word in fresh) word.card,
|
||||||
], origin: fresh.first.ref.source);
|
], origin: fresh.first.ref.source);
|
||||||
// Held from here on, even should tagging them fail.
|
// Held from here on, even should tagging them fail.
|
||||||
there.addAll(once);
|
there.addAll({
|
||||||
|
for (final (index, word) in fresh.indexed) word.ref: ids[index],
|
||||||
|
});
|
||||||
|
|
||||||
// Every name at once: each tag read takes in every card's tags.
|
// Every name at once: each tag read takes in every card's tags.
|
||||||
await _tags.addTagsNamed(
|
await _tags.addTagsNamed(
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,22 @@
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
|
||||||
|
|
||||||
/// How far a studyset's words from the dictionary are learnt: the schedule
|
/// How far a studyset's words from the dictionary are learnt: the schedule
|
||||||
/// of each card made from an entry (front to back), by the entry, so a
|
/// of each card made from an entry (front to back), by the entry, so a
|
||||||
/// word met again shows where its card stands, in the studyset or any of
|
/// word met again shows where its card stands, in the studyset or any of
|
||||||
/// its topics.
|
/// its topics.
|
||||||
class EntryProgress(
|
class EntryProgress(final ReviewRepository _reviews) {
|
||||||
final CardRepository _cards,
|
/// The schedules of the cards [cardOf] names, by their entry (as
|
||||||
final ReviewRepository _reviews,
|
/// `DictionaryAdder.heldIn` gives them, the cards read once for both); a
|
||||||
final StudysetRepository _studysets,
|
/// card never studied has one of no reviews.
|
||||||
) {
|
Future<Map<DictionaryRef, CardSchedule>> of(
|
||||||
/// The schedules of the cards of [studysetId] and inside it made from
|
Map<DictionaryRef, String> cardOf,
|
||||||
/// dictionary entries, by their entry; a card never studied has one of no
|
) async {
|
||||||
/// reviews.
|
final schedules = await _reviews.schedulesOf(cardOf.values.toList());
|
||||||
Future<Map<DictionaryRef, CardSchedule>> of(String studysetId) async {
|
|
||||||
final entryOf = <String, DictionaryRef>{
|
|
||||||
for (final card in await _cards.cardsIn(
|
|
||||||
await _studysets.subtree(studysetId),
|
|
||||||
))
|
|
||||||
card.id: ?card.content.entry,
|
|
||||||
};
|
|
||||||
final schedules = await _reviews.schedulesOf(entryOf.keys.toList());
|
|
||||||
return {
|
return {
|
||||||
for (final MapEntry(key: id, value: ref) in entryOf.entries)
|
for (final MapEntry(key: entry, value: id) in cardOf.entries)
|
||||||
ref: ?schedules[id],
|
entry: ?schedules[id],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue