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:
Tizian 「ティツィアーン」 2026-10-17 00:39:34 +02:00
commit de6af3efab
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
59 changed files with 2677 additions and 293 deletions

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

@ -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}”",

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart'; import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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