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
|
||||
have what the server lost, and send it again.
|
||||
- **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
|
||||
one's data from the device, the only copy left. The same happens to a device that
|
||||
signed in after the backup, though a GitHub account can be signed into again.
|
||||
their app finds its session gone and asks how to start; starting anew deletes the
|
||||
old account's data from the device, the only copy left. The same happens to a device
|
||||
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
|
||||
one it was.
|
||||
- **A release published after the backup** is gone from the instance; learners who took
|
||||
|
|
|
|||
|
|
@ -577,6 +577,11 @@
|
|||
"settingsTitle": "Einstellungen",
|
||||
"settingsAccount": "Konto",
|
||||
"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.",
|
||||
"instanceAddress": "Adresse der Instanz",
|
||||
"instanceUseBuiltIn": "{name} verwenden",
|
||||
|
|
@ -586,6 +591,10 @@
|
|||
"instanceUnreachable": "Unter {name} antwortet keine Instanz von Halkyon Learn.",
|
||||
"instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.",
|
||||
"settingsSwitchAccount": "Konto wechseln",
|
||||
"settingsAccountAnonymous": "Anonym auf {instance}",
|
||||
"settingsAccountSignedIn": "Angemeldet auf {instance}",
|
||||
"accountAnonymous": "Anonym",
|
||||
"accountSignedIn": "Angemeldet",
|
||||
"accountsTitle": "Konten",
|
||||
"accountAdd": "Konto hinzufügen",
|
||||
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
|
||||
|
|
@ -987,6 +996,9 @@
|
|||
"studyBackFirstOff": "Rückseite der Karten zuerst zeigen",
|
||||
"studyShowAnswer": "Antwort anzeigen",
|
||||
"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",
|
||||
"studySelectOne": "Wähle die richtige Antwort",
|
||||
"studyMatchingInstructions": "AUF DIE KARTEN OBEN ZIEHEN",
|
||||
|
|
@ -1906,10 +1918,20 @@
|
|||
"progressThisWeek": "diese Woche",
|
||||
"progressMinutes": "{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}}",
|
||||
"planNoPaths": "Noch keine Lernpfade",
|
||||
"planNextUp": "Als Nächstes",
|
||||
"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",
|
||||
"planStart": "„{title}“ beginnen",
|
||||
"planStepOf": "Schritt {number} von {total}",
|
||||
|
|
|
|||
|
|
@ -648,6 +648,12 @@
|
|||
"settingsTitle": "Settings",
|
||||
"settingsAccount": "Account",
|
||||
"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.",
|
||||
"instanceAddress": "Instance address",
|
||||
"instanceUseBuiltIn": "Use {name}",
|
||||
|
|
@ -660,6 +666,12 @@
|
|||
"instanceOpenFailed": "Couldn't open the app on {name}.",
|
||||
"@instanceOpenFailed": {"placeholders": {"name": {"type": "String"}}},
|
||||
"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",
|
||||
"accountAdd": "Add account",
|
||||
"accountAddSubtitle": "Choose the instance for the account to add.",
|
||||
|
|
@ -1100,6 +1112,10 @@
|
|||
"studyBackFirstOff": "Show the back of cards first",
|
||||
"studyShowAnswer": "Show answer",
|
||||
"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",
|
||||
"studySelectOne": "Select the correct answer",
|
||||
"studyMatchingInstructions": "DRAG AND DROP ONTO CARDS ABOVE",
|
||||
|
|
@ -2231,12 +2247,27 @@
|
|||
"@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}},
|
||||
"progressHoursMinutes": "{hours} h {minutes} min",
|
||||
"@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": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
|
||||
"planNoPaths": "No learning paths yet",
|
||||
"planNextUp": "Next up",
|
||||
"planReview": "{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}",
|
||||
"@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": {"placeholders": {"title": {"type": "String"}}},
|
||||
"planStart": "Start “{title}”",
|
||||
|
|
|
|||
|
|
@ -3454,6 +3454,36 @@ abstract class AppLocalizations {
|
|||
/// **'Welcome to Halkyon Learn'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3508,6 +3538,30 @@ abstract class AppLocalizations {
|
|||
/// **'Switch account'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -5812,6 +5866,24 @@ abstract class AppLocalizations {
|
|||
/// **'Check'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -11253,6 +11325,60 @@ abstract class AppLocalizations {
|
|||
/// **'{hours} h {minutes} min'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -11277,6 +11403,12 @@ abstract class AppLocalizations {
|
|||
/// **'{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2113,6 +2113,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get instanceFirstStartSubtitle =>
|
||||
'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
|
||||
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
|
||||
String get accountsTitle => 'Konten';
|
||||
|
||||
|
|
@ -3572,6 +3606,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get studySelectAll => 'Wähle alle zutreffenden aus';
|
||||
|
||||
|
|
@ -7069,6 +7120,55 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
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
|
||||
String planPathsDone(int done, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
|
|
@ -7097,6 +7197,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
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
|
||||
String planContinue(String title) {
|
||||
return '„$title“ fortsetzen';
|
||||
|
|
|
|||
|
|
@ -2093,6 +2093,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get instanceFirstStartSubtitle =>
|
||||
'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
|
||||
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
|
||||
String get accountsTitle => 'Accounts';
|
||||
|
||||
|
|
@ -3542,6 +3576,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get studySelectAll => 'Select all that apply';
|
||||
|
||||
|
|
@ -7004,6 +7055,55 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
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
|
||||
String planPathsDone(int done, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
|
|
@ -7032,6 +7132,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
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
|
||||
String planContinue(String title) {
|
||||
return 'Continue “$title”';
|
||||
|
|
|
|||
|
|
@ -192,7 +192,10 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
routes: [
|
||||
GoRoute(
|
||||
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.reviews,
|
||||
person.tags,
|
||||
person.paths,
|
||||
person.settings,
|
||||
studysetId,
|
||||
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/theme/app_theme.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/feedback/bloc/inbox_count_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> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -119,6 +119,39 @@ final class RichContent(
|
|||
|
||||
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.
|
||||
Iterable<Map<String, Object?>> get _nodes 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/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/signed_out_mark.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_repository.dart';
|
||||
|
|
@ -112,6 +113,9 @@ class Dependencies({
|
|||
/// Who is signed in, and signing in and out.
|
||||
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.
|
||||
required final AboutRepository about,
|
||||
|
||||
|
|
@ -233,6 +237,7 @@ class Dependencies({
|
|||
authGateway,
|
||||
FileSignInAddresses(device.storage.peopleOf(instance)),
|
||||
),
|
||||
signedOut: FileSignedOutMark(device.storage.peopleOf(instance)),
|
||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||
dictionaries: DictionaryRepository(
|
||||
ServerpodDictionaryGateway(client),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ abstract final class RoutePaths() {
|
|||
/// The profile tab.
|
||||
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.
|
||||
static const settings = '/settings';
|
||||
|
||||
|
|
|
|||
|
|
@ -400,8 +400,10 @@ class const AssistantBar({super.key}) extends StatelessWidget {
|
|||
|
||||
/// A round button that unfolds into [AssistantField], [expandedWidth] wide,
|
||||
/// 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
|
||||
/// Create studyset) stands under it and makes way while it is unfolded.
|
||||
/// the Library, on a studyset and while studying, at the lower left
|
||||
/// (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.
|
||||
class const AssistantButton({
|
||||
|
|
@ -417,6 +419,9 @@ class const AssistantButton({
|
|||
|
||||
/// A floating button under it.
|
||||
final Widget? below,
|
||||
|
||||
/// A floating button at the other end of its row.
|
||||
final Widget? end,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -431,8 +436,9 @@ class _AssistantButtonState() extends State<AssistantButton> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final below = widget.below;
|
||||
final end = widget.end;
|
||||
if (!assistantOn(context)) {
|
||||
return below ?? const SizedBox.shrink();
|
||||
return below ?? end ?? const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.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) {
|
||||
return button;
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
button,
|
||||
if (!_expanded) ...[const SizedBox(height: 16), below],
|
||||
|
|
|
|||
|
|
@ -1,32 +1,56 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.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/signed_out_mark.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
|
||||
/// whenever nobody is, and signs out on request.
|
||||
class SessionBloc(final AuthRepository _auth)
|
||||
/// at a first start, and signs out on request. Once signed out (by the
|
||||
/// 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> {
|
||||
this : super(const SessionStateLoading()) {
|
||||
on<SessionEventStarted>(_onStarted);
|
||||
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<SessionEventDeleteRequested>(_onDeleteRequested);
|
||||
}
|
||||
|
||||
/// Whether an email sign-in follows the anonymous start under way.
|
||||
var _thenEmail = false;
|
||||
|
||||
Future<void> _onStarted(
|
||||
SessionEventStarted event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
if (_auth.status is SignedOut && await _mark.read()) {
|
||||
emit(const SessionStateSignedOut());
|
||||
} else {
|
||||
add(const SessionEventSignInRequested());
|
||||
}
|
||||
await emit.forEach<AuthStatus>(
|
||||
_auth.statusChanges,
|
||||
onData: (status) {
|
||||
if (status is SignedOut) {
|
||||
// Signed out, here or by the server (a revoked session): start anew.
|
||||
add(const SessionEventSignInRequested());
|
||||
// Signed out, here or by the server (a revoked session): ask how
|
||||
// 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;
|
||||
}
|
||||
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 {
|
||||
await _auth.ensureSignedIn();
|
||||
emit(_stateOf(_auth.status));
|
||||
_thenEmail = false;
|
||||
emit(_stateOf(_auth.status, signsInWithEmail: email));
|
||||
} on Object catch (error, st) {
|
||||
_thenEmail = false;
|
||||
addError(error, st);
|
||||
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(
|
||||
SessionEventSignOutRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
emit(const SessionStateLoading());
|
||||
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();
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
|
|
@ -70,7 +144,7 @@ class SessionBloc(final AuthRepository _auth)
|
|||
) async {
|
||||
emit(const SessionStateLoading());
|
||||
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();
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
|
|
@ -78,9 +152,13 @@ class SessionBloc(final AuthRepository _auth)
|
|||
}
|
||||
}
|
||||
|
||||
SessionState _stateOf(AuthStatus status) => switch (status) {
|
||||
SignedIn() => SessionStateActive(status),
|
||||
SignedOut() => const SessionStateLoading(),
|
||||
SessionState _stateOf(AuthStatus status, {bool signsInWithEmail = false}) =>
|
||||
switch (status) {
|
||||
SignedIn() => SessionStateActive(
|
||||
status,
|
||||
signsInWithEmail: signsInWithEmail,
|
||||
),
|
||||
SignedOut() => const SessionStateSignedOut(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +175,22 @@ final class const SessionEventStarted() extends SessionEvent;
|
|||
/// Sign in (again): after a failed attempt, or once nobody is signed in.
|
||||
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.
|
||||
final class const SessionEventSignOutRequested() extends SessionEvent;
|
||||
|
||||
|
|
@ -123,8 +217,34 @@ final class const SessionStateFailed() extends SessionState {
|
|||
/// A person is signed in.
|
||||
final class const SessionStateActive(
|
||||
/// Who is signed in.
|
||||
final SignedIn person,
|
||||
) extends SessionState {
|
||||
final SignedIn person, {
|
||||
|
||||
/// 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
|
||||
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:go_router/go_router.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
|
||||
/// in) and the person's statistics, which anonymous people have too.
|
||||
/// 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
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -43,6 +50,8 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
|||
SignInWaysCubit(DependenciesScope.of(context).auth)..load(),
|
||||
),
|
||||
],
|
||||
child: _AsksForEmail(
|
||||
asks: signsInWithEmail,
|
||||
child: TopGradient(
|
||||
color: Brand.primary,
|
||||
background: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
|
|
@ -83,6 +92,7 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -426,3 +436,30 @@ Future<bool> _confirmSwitch(BuildContext context) async {
|
|||
);
|
||||
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/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/widget/start_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows the app of whoever is signed in, signing in anonymously first if
|
||||
/// nobody is.
|
||||
/// Shows the app of whoever is signed in, signing in anonymously at a
|
||||
/// 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
|
||||
/// one person's app, such as screens, blocs, streams or caches, reaches
|
||||
|
|
@ -28,13 +29,17 @@ class const SessionGate({
|
|||
@override
|
||||
Widget build(BuildContext context) => DeviceSettingsScope(
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
SessionBloc(DependenciesScope.of(context).auth)
|
||||
..add(const SessionEventStarted()),
|
||||
create: (context) => SessionBloc(
|
||||
DependenciesScope.of(context).auth,
|
||||
DependenciesScope.of(context).signedOut,
|
||||
)..add(const SessionEventStarted()),
|
||||
child: BlocBuilder<SessionBloc, SessionState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
SessionStateLoading() => const _PlainApp(home: LaunchScreen()),
|
||||
SessionStateFailed() => const _PlainApp(home: _SignInFailed()),
|
||||
final SessionStateSignedOut signedOut => _PlainApp(
|
||||
home: StartScreen(state: signedOut),
|
||||
),
|
||||
SessionStateActive(:final person) => KeyedSubtree(
|
||||
key: ValueKey(person.authUserId),
|
||||
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();
|
||||
|
||||
/// The entries held in each studyset and inside it, as read once and
|
||||
/// added to since.
|
||||
final _held = <String, Set<DictionaryRef>>{};
|
||||
/// The cards of each studyset and inside it made from dictionary
|
||||
/// entries, by their entry, as read once and added to since.
|
||||
final _held = <String, Map<DictionaryRef, String>>{};
|
||||
|
||||
/// The dictionary entries the cards of [studysetId] and everything inside
|
||||
/// it were made from.
|
||||
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] ??= {
|
||||
for (final card in await _cards.cardsIn(
|
||||
await _studysets.subtree(studysetId),
|
||||
))
|
||||
?card.content.entry,
|
||||
?card.content.entry: card.id,
|
||||
};
|
||||
|
||||
/// The studysets words added from [studysetId] may go to: it and every
|
||||
|
|
@ -125,10 +131,10 @@ class DictionaryAdder(
|
|||
List<DictionaryItem> words,
|
||||
List<({String name, JellyfinTagId? id})> jellyfinTags,
|
||||
) async {
|
||||
final there = await _entriesIn(studysetId);
|
||||
final there = await _heldIn(studysetId);
|
||||
final fresh = [
|
||||
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.
|
||||
final once = <DictionaryRef>{};
|
||||
|
|
@ -140,7 +146,9 @@ class DictionaryAdder(
|
|||
for (final word in fresh) word.card,
|
||||
], origin: fresh.first.ref.source);
|
||||
// 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.
|
||||
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/study/data/review_repository.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
|
||||
/// 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
|
||||
/// its topics.
|
||||
class EntryProgress(
|
||||
final CardRepository _cards,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetRepository _studysets,
|
||||
) {
|
||||
/// The schedules of the cards of [studysetId] and inside it made from
|
||||
/// dictionary entries, by their entry; a card never studied has one of no
|
||||
/// reviews.
|
||||
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());
|
||||
class EntryProgress(final ReviewRepository _reviews) {
|
||||
/// The schedules of the cards [cardOf] names, by their entry (as
|
||||
/// `DictionaryAdder.heldIn` gives them, the cards read once for both); a
|
||||
/// card never studied has one of no reviews.
|
||||
Future<Map<DictionaryRef, CardSchedule>> of(
|
||||
Map<DictionaryRef, String> cardOf,
|
||||
) async {
|
||||
final schedules = await _reviews.schedulesOf(cardOf.values.toList());
|
||||
return {
|
||||
for (final MapEntry(key: id, value: ref) in entryOf.entries)
|
||||
ref: ?schedules[id],
|
||||
for (final MapEntry(key: entry, value: id) in cardOf.entries)
|
||||
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