feat(app): study with faces on the ratings, centred cards and the session's figures

The study session, reworked:

- Ratings are coloured tiles with a face each (frown, meh, smile,
  laugh), when the card comes back below them with a clock ("in 1
  min", "in 3 days"), always shown, so a card can be rated without
  revealing it; at most 540 dp wide, centred.
- Cards are centred in a section card: the question large, then a line
  with 24 dp on either side and the answer a size smaller, or "Show
  answer" 40 dp below; a tap anywhere on the card shows or hides the
  answer. Read-only rich text takes a text style and an alignment.
- Words show their reading small above the expression once answered,
  and several meanings numbered.
- Choice, matching and map cards: Check below the card, the ratings in
  its place once checked; options marked before their text; matching
  lists the categories first, then what is left to place; the map
  fills the card with one caption below it.
- Header and progress on a band of their own; title start-aligned,
  the Pomodoro timer only to watch, the back-first toggle in every
  state; leaving a round under way asks first.
- Cramming goes round the round; back is there with two cards or more,
  skipping always.
- The end of a round: the cards done of those due, accuracy (OK counts
  half) and time, over the whole session.

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-07 17:40:32 +02:00
commit 369ce4ade1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 2145 additions and 643 deletions

View file

@ -439,6 +439,17 @@
"studyBackFirstOff": "Rückseite der Karten zuerst zeigen",
"studyShowAnswer": "Antwort anzeigen",
"studyCheck": "Prüfen",
"studySelectAll": "Wähle alle zutreffenden aus",
"studySelectOne": "Wähle die richtige Antwort",
"studyMatchingInstructions": "AUF DIE KARTEN OBEN ZIEHEN",
"studyMatchingAllPlaced": "Alle platziert",
"studyMatchingDropHere": "Hier ablegen",
"studyMapTapPrompt": "Tippe auf eine Region, um zu antworten",
"studyMapSelected": "Ausgewählt: {label}",
"studyCorrect": "Richtig",
"studyCorrectLabel": "Richtig — {label}",
"studyMapAnswer": "Antwort: {answer}",
"studyMapPickedAnswer": "Du hast {picked} gewählt · Antwort: {answer}",
"studyRatingBad": "Schlecht",
"studyRatingOk": "OK",
"studyRatingGood": "Gut",
@ -447,13 +458,28 @@
"studyNothingBody": "Komm später wieder, wenn mehr Karten fällig sind.",
"studyNextDue": "Bis zur nächsten fälligen Karte: {interval}.",
"studyDone": "Fertig",
"studyRoundDone": "Runde abgeschlossen",
"studySessionComplete": "Sitzung abgeschlossen",
"studyProgressReviewed": "{reviewed} / {total} wiederholt",
"studyReviewedCount": "{reviewed} wiederholt",
"studyStatCards": "Karten",
"studyStatAccuracy": "Genauigkeit",
"studyStatTime": "Zeit",
"studyDurationSec": "{sec} Sek.",
"studyDurationMin": "{min} Min.",
"studyDurationMinSec": "{min} Min. {sec} Sek.",
"studyLeaveTitle": "Diese Lernsitzung verlassen?",
"studyLeaveRated": "{count, plural, =1{Deine bisherigen Bewertungen sind gespeichert, aber die letzte Karte in dieser Runde wird verworfen, wenn du jetzt gehst.} other{Deine bisherigen Bewertungen sind gespeichert, aber die {count} noch verbleibenden Karten in dieser Runde werden verworfen, wenn du jetzt gehst.}}",
"studyLeaveUnrated": "{count, plural, =1{Du hast diese Karte noch nicht bewertet — beim Verlassen endet die Sitzung, ohne Fortschritt für diese Runde zu speichern.} other{Du hast noch keine der {count} Karten in dieser Runde bewertet — beim Verlassen endet die Sitzung, ohne Fortschritt dafür zu speichern.}}",
"studyLeaveConfirm": "Verlassen",
"studyKeepStudying": "Weiter lernen",
"studyInMinutes": "{count, plural, =1{in 1 Min.} other{in {count} Min.}}",
"studyInHours": "{count, plural, =1{in 1 Stunde} other{in {count} Stunden}}",
"studyInDays": "{count, plural, =1{in 1 Tag} other{in {count} Tagen}}",
"studyInMonths": "{count, plural, =1{in 1 Monat} other{in {count} Monaten}}",
"studyInYears": "{count, plural, =1{in 1 Jahr} other{in {count} Jahren}}",
"studyContinue": "Weiterlernen",
"studyPrevious": "Vorherige Karte",
"studyNext": "Nächste Karte",
"studyReading": "Lesung",
"studyMeanings": "Bedeutungen",
"studyPlaceItems": "Ziehe jedes Element auf seine Kategorie oder tippe auf ein Element und dann auf eine Kategorie.",
"studySkipCard": "Karte überspringen",
"studyMinutes": "{count} Min.",
"studyHours": "{count} Std.",
"studyDays": "{count, plural, =1{1 Tag} other{{count} Tage}}",

View file

@ -484,6 +484,21 @@
"studyBackFirstOff": "Show the back of cards first",
"studyShowAnswer": "Show answer",
"studyCheck": "Check",
"studySelectAll": "Select all that apply",
"studySelectOne": "Select the correct answer",
"studyMatchingInstructions": "DRAG AND DROP ONTO CARDS ABOVE",
"studyMatchingAllPlaced": "All placed",
"studyMatchingDropHere": "Drop here",
"studyMapTapPrompt": "Tap a region to answer",
"studyMapSelected": "Selected: {label}",
"@studyMapSelected": {"placeholders": {"label": {"type": "String"}}},
"studyCorrect": "Correct",
"studyCorrectLabel": "Correct — {label}",
"@studyCorrectLabel": {"placeholders": {"label": {"type": "String"}}},
"studyMapAnswer": "Answer: {answer}",
"@studyMapAnswer": {"placeholders": {"answer": {"type": "String"}}},
"studyMapPickedAnswer": "You picked {picked} · Answer: {answer}",
"@studyMapPickedAnswer": {"placeholders": {"picked": {"type": "String"}, "answer": {"type": "String"}}},
"studyRatingBad": "Bad",
"studyRatingOk": "OK",
"studyRatingGood": "Good",
@ -493,13 +508,40 @@
"studyNextDue": "The next card is due in {interval}.",
"@studyNextDue": {"placeholders": {"interval": {"type": "String"}}},
"studyDone": "Done",
"studyRoundDone": "Round complete",
"studySessionComplete": "Session complete",
"studyProgressReviewed": "{reviewed} / {total} reviewed",
"@studyProgressReviewed": {"placeholders": {"reviewed": {"type": "int"}, "total": {"type": "int"}}},
"studyReviewedCount": "{reviewed} reviewed",
"@studyReviewedCount": {"placeholders": {"reviewed": {"type": "int"}}},
"studyStatCards": "Cards",
"studyStatAccuracy": "Accuracy",
"studyStatTime": "Time",
"studyDurationSec": "{sec} sec",
"@studyDurationSec": {"placeholders": {"sec": {"type": "int"}}},
"studyDurationMin": "{min} min",
"@studyDurationMin": {"placeholders": {"min": {"type": "int"}}},
"studyDurationMinSec": "{min} min {sec} sec",
"@studyDurationMinSec": {"placeholders": {"min": {"type": "int"}, "sec": {"type": "int"}}},
"studyLeaveTitle": "Leave this study session?",
"studyLeaveRated": "{count, plural, =1{Your ratings so far are saved, but the last card in this round will be discarded if you leave now.} other{Your ratings so far are saved, but the {count} cards still left in this round will be discarded if you leave now.}}",
"@studyLeaveRated": {"placeholders": {"count": {"type": "int"}}},
"studyLeaveUnrated": "{count, plural, =1{You haven’t rated this card yet — leaving ends the session without recording any progress for this round.} other{You haven’t rated any of the {count} cards in this round yet — leaving ends the session without recording any progress for it.}}",
"@studyLeaveUnrated": {"placeholders": {"count": {"type": "int"}}},
"studyLeaveConfirm": "Leave",
"studyKeepStudying": "Keep studying",
"studyInMinutes": "{count, plural, =1{in 1 min} other{in {count} min}}",
"@studyInMinutes": {"placeholders": {"count": {"type": "int"}}},
"studyInHours": "{count, plural, =1{in 1 hour} other{in {count} hours}}",
"@studyInHours": {"placeholders": {"count": {"type": "int"}}},
"studyInDays": "{count, plural, =1{in 1 day} other{in {count} days}}",
"@studyInDays": {"placeholders": {"count": {"type": "int"}}},
"studyInMonths": "{count, plural, =1{in 1 month} other{in {count} months}}",
"@studyInMonths": {"placeholders": {"count": {"type": "int"}}},
"studyInYears": "{count, plural, =1{in 1 year} other{in {count} years}}",
"@studyInYears": {"placeholders": {"count": {"type": "int"}}},
"studyContinue": "Continue learning",
"studyPrevious": "Previous card",
"studyNext": "Next card",
"studyReading": "Reading",
"studyMeanings": "Meanings",
"studyPlaceItems": "Drag each item onto its category, or tap an item and then a category.",
"studySkipCard": "Skip card",
"studyMinutes": "{count} min",
"@studyMinutes": {"placeholders": {"count": {"type": "int"}}},
"studyHours": "{count} h",

View file

@ -2650,6 +2650,72 @@ abstract class AppLocalizations {
/// **'Check'**
String get studyCheck;
/// No description provided for @studySelectAll.
///
/// In en, this message translates to:
/// **'Select all that apply'**
String get studySelectAll;
/// No description provided for @studySelectOne.
///
/// In en, this message translates to:
/// **'Select the correct answer'**
String get studySelectOne;
/// No description provided for @studyMatchingInstructions.
///
/// In en, this message translates to:
/// **'DRAG AND DROP ONTO CARDS ABOVE'**
String get studyMatchingInstructions;
/// No description provided for @studyMatchingAllPlaced.
///
/// In en, this message translates to:
/// **'All placed'**
String get studyMatchingAllPlaced;
/// No description provided for @studyMatchingDropHere.
///
/// In en, this message translates to:
/// **'Drop here'**
String get studyMatchingDropHere;
/// No description provided for @studyMapTapPrompt.
///
/// In en, this message translates to:
/// **'Tap a region to answer'**
String get studyMapTapPrompt;
/// No description provided for @studyMapSelected.
///
/// In en, this message translates to:
/// **'Selected: {label}'**
String studyMapSelected(String label);
/// No description provided for @studyCorrect.
///
/// In en, this message translates to:
/// **'Correct'**
String get studyCorrect;
/// No description provided for @studyCorrectLabel.
///
/// In en, this message translates to:
/// **'Correct — {label}'**
String studyCorrectLabel(String label);
/// No description provided for @studyMapAnswer.
///
/// In en, this message translates to:
/// **'Answer: {answer}'**
String studyMapAnswer(String answer);
/// No description provided for @studyMapPickedAnswer.
///
/// In en, this message translates to:
/// **'You picked {picked} · Answer: {answer}'**
String studyMapPickedAnswer(String picked, String answer);
/// No description provided for @studyRatingBad.
///
/// In en, this message translates to:
@ -2698,11 +2764,119 @@ abstract class AppLocalizations {
/// **'Done'**
String get studyDone;
/// No description provided for @studyRoundDone.
/// No description provided for @studySessionComplete.
///
/// In en, this message translates to:
/// **'Round complete'**
String get studyRoundDone;
/// **'Session complete'**
String get studySessionComplete;
/// No description provided for @studyProgressReviewed.
///
/// In en, this message translates to:
/// **'{reviewed} / {total} reviewed'**
String studyProgressReviewed(int reviewed, int total);
/// No description provided for @studyReviewedCount.
///
/// In en, this message translates to:
/// **'{reviewed} reviewed'**
String studyReviewedCount(int reviewed);
/// No description provided for @studyStatCards.
///
/// In en, this message translates to:
/// **'Cards'**
String get studyStatCards;
/// No description provided for @studyStatAccuracy.
///
/// In en, this message translates to:
/// **'Accuracy'**
String get studyStatAccuracy;
/// No description provided for @studyStatTime.
///
/// In en, this message translates to:
/// **'Time'**
String get studyStatTime;
/// No description provided for @studyDurationSec.
///
/// In en, this message translates to:
/// **'{sec} sec'**
String studyDurationSec(int sec);
/// No description provided for @studyDurationMin.
///
/// In en, this message translates to:
/// **'{min} min'**
String studyDurationMin(int min);
/// No description provided for @studyDurationMinSec.
///
/// In en, this message translates to:
/// **'{min} min {sec} sec'**
String studyDurationMinSec(int min, int sec);
/// No description provided for @studyLeaveTitle.
///
/// In en, this message translates to:
/// **'Leave this study session?'**
String get studyLeaveTitle;
/// No description provided for @studyLeaveRated.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Your ratings so far are saved, but the last card in this round will be discarded if you leave now.} other{Your ratings so far are saved, but the {count} cards still left in this round will be discarded if you leave now.}}'**
String studyLeaveRated(int count);
/// No description provided for @studyLeaveUnrated.
///
/// In en, this message translates to:
/// **'{count, plural, =1{You haven’t rated this card yet — leaving ends the session without recording any progress for this round.} other{You haven’t rated any of the {count} cards in this round yet — leaving ends the session without recording any progress for it.}}'**
String studyLeaveUnrated(int count);
/// No description provided for @studyLeaveConfirm.
///
/// In en, this message translates to:
/// **'Leave'**
String get studyLeaveConfirm;
/// No description provided for @studyKeepStudying.
///
/// In en, this message translates to:
/// **'Keep studying'**
String get studyKeepStudying;
/// No description provided for @studyInMinutes.
///
/// In en, this message translates to:
/// **'{count, plural, =1{in 1 min} other{in {count} min}}'**
String studyInMinutes(int count);
/// No description provided for @studyInHours.
///
/// In en, this message translates to:
/// **'{count, plural, =1{in 1 hour} other{in {count} hours}}'**
String studyInHours(int count);
/// No description provided for @studyInDays.
///
/// In en, this message translates to:
/// **'{count, plural, =1{in 1 day} other{in {count} days}}'**
String studyInDays(int count);
/// No description provided for @studyInMonths.
///
/// In en, this message translates to:
/// **'{count, plural, =1{in 1 month} other{in {count} months}}'**
String studyInMonths(int count);
/// No description provided for @studyInYears.
///
/// In en, this message translates to:
/// **'{count, plural, =1{in 1 year} other{in {count} years}}'**
String studyInYears(int count);
/// No description provided for @studyContinue.
///
@ -2716,29 +2890,11 @@ abstract class AppLocalizations {
/// **'Previous card'**
String get studyPrevious;
/// No description provided for @studyNext.
/// No description provided for @studySkipCard.
///
/// In en, this message translates to:
/// **'Next card'**
String get studyNext;
/// No description provided for @studyReading.
///
/// In en, this message translates to:
/// **'Reading'**
String get studyReading;
/// No description provided for @studyMeanings.
///
/// In en, this message translates to:
/// **'Meanings'**
String get studyMeanings;
/// No description provided for @studyPlaceItems.
///
/// In en, this message translates to:
/// **'Drag each item onto its category, or tap an item and then a category.'**
String get studyPlaceItems;
/// **'Skip card'**
String get studySkipCard;
/// No description provided for @studyMinutes.
///

View file

@ -1527,6 +1527,47 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studyCheck => 'Prüfen';
@override
String get studySelectAll => 'Wähle alle zutreffenden aus';
@override
String get studySelectOne => 'Wähle die richtige Antwort';
@override
String get studyMatchingInstructions => 'AUF DIE KARTEN OBEN ZIEHEN';
@override
String get studyMatchingAllPlaced => 'Alle platziert';
@override
String get studyMatchingDropHere => 'Hier ablegen';
@override
String get studyMapTapPrompt => 'Tippe auf eine Region, um zu antworten';
@override
String studyMapSelected(String label) {
return 'Ausgewählt: $label';
}
@override
String get studyCorrect => 'Richtig';
@override
String studyCorrectLabel(String label) {
return 'Richtig — $label';
}
@override
String studyMapAnswer(String answer) {
return 'Antwort: $answer';
}
@override
String studyMapPickedAnswer(String picked, String answer) {
return 'Du hast $picked gewählt · Antwort: $answer';
}
@override
String get studyRatingBad => 'Schlecht';
@ -1555,7 +1596,129 @@ class AppLocalizationsDe extends AppLocalizations {
String get studyDone => 'Fertig';
@override
String get studyRoundDone => 'Runde abgeschlossen';
String get studySessionComplete => 'Sitzung abgeschlossen';
@override
String studyProgressReviewed(int reviewed, int total) {
return '$reviewed / $total wiederholt';
}
@override
String studyReviewedCount(int reviewed) {
return '$reviewed wiederholt';
}
@override
String get studyStatCards => 'Karten';
@override
String get studyStatAccuracy => 'Genauigkeit';
@override
String get studyStatTime => 'Zeit';
@override
String studyDurationSec(int sec) {
return '$sec Sek.';
}
@override
String studyDurationMin(int min) {
return '$min Min.';
}
@override
String studyDurationMinSec(int min, int sec) {
return '$min Min. $sec Sek.';
}
@override
String get studyLeaveTitle => 'Diese Lernsitzung verlassen?';
@override
String studyLeaveRated(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Deine bisherigen Bewertungen sind gespeichert, aber die $count noch verbleibenden Karten in dieser Runde werden verworfen, wenn du jetzt gehst.',
one: 'Deine bisherigen Bewertungen sind gespeichert, aber die letzte Karte in dieser Runde wird verworfen, wenn du jetzt gehst.',
);
return '$_temp0';
}
@override
String studyLeaveUnrated(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Du hast noch keine der $count Karten in dieser Runde bewertet — beim Verlassen endet die Sitzung, ohne Fortschritt dafür zu speichern.',
one: 'Du hast diese Karte noch nicht bewertet — beim Verlassen endet die Sitzung, ohne Fortschritt für diese Runde zu speichern.',
);
return '$_temp0';
}
@override
String get studyLeaveConfirm => 'Verlassen';
@override
String get studyKeepStudying => 'Weiter lernen';
@override
String studyInMinutes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count Min.',
one: 'in 1 Min.',
);
return '$_temp0';
}
@override
String studyInHours(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count Stunden',
one: 'in 1 Stunde',
);
return '$_temp0';
}
@override
String studyInDays(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count Tagen',
one: 'in 1 Tag',
);
return '$_temp0';
}
@override
String studyInMonths(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count Monaten',
one: 'in 1 Monat',
);
return '$_temp0';
}
@override
String studyInYears(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count Jahren',
one: 'in 1 Jahr',
);
return '$_temp0';
}
@override
String get studyContinue => 'Weiterlernen';
@ -1564,17 +1727,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get studyPrevious => 'Vorherige Karte';
@override
String get studyNext => 'Nächste Karte';
@override
String get studyReading => 'Lesung';
@override
String get studyMeanings => 'Bedeutungen';
@override
String get studyPlaceItems =>
'Ziehe jedes Element auf seine Kategorie oder tippe auf ein Element und dann auf eine Kategorie.';
String get studySkipCard => 'Karte überspringen';
@override
String studyMinutes(int count) {

View file

@ -1516,6 +1516,47 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyCheck => 'Check';
@override
String get studySelectAll => 'Select all that apply';
@override
String get studySelectOne => 'Select the correct answer';
@override
String get studyMatchingInstructions => 'DRAG AND DROP ONTO CARDS ABOVE';
@override
String get studyMatchingAllPlaced => 'All placed';
@override
String get studyMatchingDropHere => 'Drop here';
@override
String get studyMapTapPrompt => 'Tap a region to answer';
@override
String studyMapSelected(String label) {
return 'Selected: $label';
}
@override
String get studyCorrect => 'Correct';
@override
String studyCorrectLabel(String label) {
return 'Correct — $label';
}
@override
String studyMapAnswer(String answer) {
return 'Answer: $answer';
}
@override
String studyMapPickedAnswer(String picked, String answer) {
return 'You picked $picked · Answer: $answer';
}
@override
String get studyRatingBad => 'Bad';
@ -1543,7 +1584,129 @@ class AppLocalizationsEn extends AppLocalizations {
String get studyDone => 'Done';
@override
String get studyRoundDone => 'Round complete';
String get studySessionComplete => 'Session complete';
@override
String studyProgressReviewed(int reviewed, int total) {
return '$reviewed / $total reviewed';
}
@override
String studyReviewedCount(int reviewed) {
return '$reviewed reviewed';
}
@override
String get studyStatCards => 'Cards';
@override
String get studyStatAccuracy => 'Accuracy';
@override
String get studyStatTime => 'Time';
@override
String studyDurationSec(int sec) {
return '$sec sec';
}
@override
String studyDurationMin(int min) {
return '$min min';
}
@override
String studyDurationMinSec(int min, int sec) {
return '$min min $sec sec';
}
@override
String get studyLeaveTitle => 'Leave this study session?';
@override
String studyLeaveRated(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Your ratings so far are saved, but the $count cards still left in this round will be discarded if you leave now.',
one: 'Your ratings so far are saved, but the last card in this round will be discarded if you leave now.',
);
return '$_temp0';
}
@override
String studyLeaveUnrated(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'You haven’t rated any of the $count cards in this round yet — leaving ends the session without recording any progress for it.',
one: 'You haven’t rated this card yet — leaving ends the session without recording any progress for this round.',
);
return '$_temp0';
}
@override
String get studyLeaveConfirm => 'Leave';
@override
String get studyKeepStudying => 'Keep studying';
@override
String studyInMinutes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count min',
one: 'in 1 min',
);
return '$_temp0';
}
@override
String studyInHours(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count hours',
one: 'in 1 hour',
);
return '$_temp0';
}
@override
String studyInDays(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count days',
one: 'in 1 day',
);
return '$_temp0';
}
@override
String studyInMonths(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count months',
one: 'in 1 month',
);
return '$_temp0';
}
@override
String studyInYears(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'in $count years',
one: 'in 1 year',
);
return '$_temp0';
}
@override
String get studyContinue => 'Continue learning';
@ -1552,17 +1715,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get studyPrevious => 'Previous card';
@override
String get studyNext => 'Next card';
@override
String get studyReading => 'Reading';
@override
String get studyMeanings => 'Meanings';
@override
String get studyPlaceItems =>
'Drag each item onto its category, or tap an item and then a category.';
String get studySkipCard => 'Skip card';
@override
String studyMinutes(int count) {

View file

@ -88,21 +88,31 @@ class const EditorBlocksScope({
/// English.
Map<String, BlockComponentBuilder> buildersWithUnknown(
EditorBlocks blocks,
AppLocalizations l10n,
) => {
AppLocalizations l10n, {
TextAlign? textAlign,
}) => {
for (final MapEntry(key: type, value: builder) in blocks.builders.entries)
type: _withPlaceholder(builder, l10n),
type: _withPlaceholder(builder, l10n, textAlign),
errorBlockComponentBuilderKey: _UnknownBlockBuilder(),
};
/// [builder] alike, but saying what its block is in [l10n]'s language while
/// empty, for the editor's own text blocks; others as they are.
/// empty, for the editor's own text blocks, and aligning their text as
/// [textAlign] says where the text doesn't say otherwise; others as they
/// are.
BlockComponentBuilder _withPlaceholder(
BlockComponentBuilder builder,
AppLocalizations l10n,
TextAlign? textAlign,
) {
BlockComponentConfiguration saying(String Function(Node node) text) =>
builder.configuration.copyWith(placeholderText: text);
builder.configuration.copyWith(
placeholderText: text,
textAlign: switch (textAlign) {
final align? => (_) => align,
null => null,
},
);
return switch (builder) {
final ParagraphBlockComponentBuilder paragraph =>
ParagraphBlockComponentBuilder(

View file

@ -12,11 +12,18 @@ import 'package:material_ui/material_ui.dart';
/// Phones get the touch variant, with handles to drag the selection. Both
/// draw superscript and subscript text (`withScripts`), and formulas within
/// lines (`withInlineMath`): as their source while [editing], else set.
EditorStyle editorStyleOf(BuildContext context, {bool editing = true}) {
/// The text is the theme's `bodyLarge` unless [textStyle] says otherwise.
EditorStyle editorStyleOf(
BuildContext context, {
bool editing = true,
TextStyle? textStyle,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = TextStyleConfiguration(
text: theme.textTheme.bodyLarge!.copyWith(color: colors.onSurface),
text: (textStyle ?? theme.textTheme.bodyLarge!).copyWith(
color: colors.onSurface,
),
href: TextStyle(
color: colors.primary,
decoration: TextDecoration.underline,

View file

@ -20,6 +20,12 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart
class const RichContentView({
/// The content to show.
required final RichContent content,
/// The text's style; the theme's `bodyLarge` without.
final TextStyle? textStyle,
/// How the text aligns where it doesn't say itself; at the start without.
final TextAlign? textAlign,
super.key,
}) extends StatefulWidget {
@override
@ -54,10 +60,15 @@ class _RichContentViewState() extends State<RichContentView> {
child: AppFlowyEditor(
key: ObjectKey(_editorState),
editorState: _editorState,
editorStyle: editorStyleOf(context, editing: false),
editorStyle: editorStyleOf(
context,
editing: false,
textStyle: widget.textStyle,
),
blockComponentBuilders: buildersWithUnknown(
blocksOf(context),
AppLocalizations.of(context),
textAlign: widget.textAlign,
),
editable: false,
disableSelectionService: true,

View file

@ -1,5 +1,4 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
@ -7,8 +6,9 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The PDF page [source] something was made from, by the PDF's title and the
/// page; a tap opens the PDF there. Nothing shows for a PDF that is gone.
/// The PDF [source] something was made from, small: a document and the
/// PDF's title, cut short (the tooltip has it whole); a tap opens the PDF at
/// the page. Nothing shows for a PDF that is gone.
class const PdfPageChip({required final PdfPageRef source, super.key})
extends StatefulWidget {
@override
@ -28,19 +28,41 @@ class _PdfPageChipState() extends State<PdfPageChip> {
if (pdf == null) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
final page = widget.source.page;
return Align(
alignment: AlignmentDirectional.centerStart,
child: Tooltip(
message: l10n.imageOpenPdfPage,
child: ActionChip(
avatar: const Icon(LucideIcons.fileText, size: 16),
label: Text(
'${pdf.content.title} · ${l10n.pdfPageShort(page)}',
overflow: TextOverflow.ellipsis,
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final title = pdf.content.title;
return Tooltip(
message: title,
waitDuration: const Duration(milliseconds: 300),
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => context.push(
RoutePaths.pdf(pdf.id, page: widget.source.page),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.fileText, size: 14, color: muted),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: muted,
),
),
),
],
),
),
onPressed: () => context.push(RoutePaths.pdf(pdf.id, page: page)),
),
),
);

View file

@ -1,13 +1,13 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/bloc/pomodoro_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_card.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_texts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The Pomodoro timer, small, while studying: the time left of its phase; a
/// tap pauses it or goes on. Nothing while no timer is set.
/// The Pomodoro timer, small, while studying: the time left of its phase
/// beside a timer (or a pause while paused), in the primary colour while
/// focusing and teal on a break. Only to watch: the timer is worked from its
/// card and its notification. Nothing while no timer is set.
class const PomodoroChip({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -15,26 +15,28 @@ class const PomodoroChip({super.key}) extends StatelessWidget {
if (run == null) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Tooltip(
message: run.isRunning
? l10n.pomodoroPhaseTitle(run)
: '${l10n.pomodoroPhaseTitle(run)} · ${l10n.pomodoroPaused}',
child: ActionChip(
avatar: Icon(
run.isRunning
? (run.phase.isBreak ? LucideIcons.coffee : LucideIcons.timer)
: LucideIcons.pause,
size: 18,
final color = run.phase.isBreak ? Colors.teal : theme.colorScheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
run.isRunning ? LucideIcons.timer : LucideIcons.pause,
size: 16,
color: color,
),
label: PomodoroCountdown(run: run, style: theme.textTheme.labelLarge),
onPressed: () => context.read<PomodoroBloc>().add(
run.isRunning
? const PomodoroEventPauseRequested()
: const PomodoroEventResumeRequested(),
const SizedBox(width: 4),
PomodoroCountdown(
run: run,
style: theme.textTheme.labelLarge?.copyWith(
color: color,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
);
}
}

View file

@ -44,10 +44,15 @@ class StudySessionBloc(
StudyFocus? focus,
}) extends Bloc<StudySessionEvent, StudySessionState> {
this : super(const StudySessionStateLoading()) {
on<StudySessionEventStarted>((event, emit) => _startRound(emit));
on<StudySessionEventSettingsChanged>((event, emit) => _startRound(emit));
on<StudySessionEventStarted>((event, emit) => _startSession(emit));
on<StudySessionEventSettingsChanged>((event, emit) => _startSession(emit));
on<StudySessionEventContinued>((event, emit) => _startRound(emit));
on<StudySessionEventAnswerShown>(_onAnswerShown);
on<StudySessionEventAnswerShown>(
(event, emit) => _showAnswer(emit, shown: true),
);
on<StudySessionEventAnswerToggled>(
(event, emit) => _showAnswer(emit, toggle: true),
);
on<StudySessionEventRated>(_onRated);
on<StudySessionEventMoved>(_onMoved);
on<StudySessionEventBackFirstToggled>((event, emit) async {
@ -61,7 +66,7 @@ class StudySessionBloc(
),
);
}
await _startRound(emit);
await _startSession(emit);
});
}
@ -81,14 +86,28 @@ class StudySessionBloc(
/// The cards crammed in earlier rounds of this session.
final _crammed = <String>{};
// How the session goes, over all its rounds: when it began, every rating
// given, and the cards done.
var _startedAt = DateTime.utc(0);
final _given = <Rating>[];
final _done = <String>{};
// Each studyset's cards in the round under way, in tree order, with each
// one's study options, and the order the studied studyset asks for.
var _groupIds = <List<String>>[];
var _groupOptions = <StudysetStudyOptions>[];
var _order = NewCardOrder.reviewsFirst;
/// Begins the session anew: from now, with nothing rated yet.
Future<void> _startSession(Emitter<StudySessionState> emit) {
_startedAt = clock.now().toUtc();
_given.clear();
_done.clear();
return _startRound(emit);
}
Future<void> _startRound(Emitter<StudySessionState> emit) async {
emit(const StudySessionStateLoading());
emit(StudySessionStateLoading(backFirst: _options.backFirst));
_options = (await _settings.read()).study;
if (_focus case final focus?) {
_options = _options.copyWith(backFirst: focus.backFirst);
@ -110,7 +129,7 @@ class StudySessionBloc(
];
_round = _ordered(all).take(_options.cardsPerRound).toList();
if (_round.isEmpty) {
emit(const StudySessionStateNothing());
emit(StudySessionStateNothing(backFirst: _options.backFirst));
return;
}
_crammed.addAll(_round);
@ -127,7 +146,12 @@ class StudySessionBloc(
final upcoming = [
for (final schedule in schedules.values) ?schedule.due,
]..sort();
emit(StudySessionStateNothing(next: upcoming.firstOrNull));
emit(
StudySessionStateNothing(
next: upcoming.firstOrNull,
backFirst: _options.backFirst,
),
);
return;
}
await _showNext(emit, now);
@ -274,13 +298,18 @@ class StudySessionBloc(
asksBack: _asksBack(card),
schedule: schedule,
shownAt: now,
nextDue: switch (schedule) {
final schedule? => nextDueDates(card.id, schedule, now),
null => const {},
},
cram: _options.mode == LearnMode.cram,
pills: _pills(id),
cramTally: _tally(),
cramUnrated: _round.length - _ratings.length,
canGoBack: _options.mode == LearnMode.cram && _cramIndex > 0,
canGoForward:
_options.mode == LearnMode.cram && _cramIndex < _round.length - 1,
// Cramming goes round the round: back from the first card is the
// last, and skipping is always there.
canGoBack: _options.mode == LearnMode.cram && _round.length > 1,
canGoForward: _options.mode == LearnMode.cram,
tagFilterCount: _tagFilterCount,
backFirst: _options.backFirst,
);
@ -307,32 +336,46 @@ class StudySessionBloc(
};
Future<StudySessionStateRoundDone> _roundDone() async {
final bool more;
if (_options.mode == LearnMode.cram) {
more = _cardsById.keys.any((id) => !_crammed.contains(id));
} else {
final now = clock.now().toUtc();
more = _queue(await _schedules([..._cardsById.keys]), now).isNotEmpty;
final int left;
if (_options.mode == LearnMode.cram) {
left = _cardsById.keys.where((id) => !_crammed.contains(id)).length;
} else {
left = _queue(await _schedules([..._cardsById.keys]), now).length;
}
return StudySessionStateRoundDone(tally: _tally(), more: more);
return StudySessionStateRoundDone(
tally: _tally(),
more: left > 0,
reviewed: _done.length,
total: _done.length + left,
accuracy: _given.isEmpty
? 0
: _given.map(_credit).reduce((a, b) => a + b) / _given.length,
duration: now.difference(_startedAt),
backFirst: _options.backFirst,
);
}
void _onAnswerShown(
StudySessionEventAnswerShown event,
Emitter<StudySessionState> emit,
) {
if (state case final StudySessionStateShowing showing
when !showing.answerShown) {
emit(
showing.withAnswer(switch (showing.schedule) {
final schedule? => nextDueDates(
showing.card.id,
schedule,
clock.now().toUtc(),
),
null => const {},
}),
);
/// How much [rating] counts towards the session's accuracy: recalled
/// fully for Good and Perfect, half for OK, not for Bad.
static double _credit(Rating rating) => switch (rating) {
Rating.again => 0,
Rating.hard => 0.5,
Rating.good || Rating.easy => 1,
};
/// Shows the answer of the card on screen, or hides it again when
/// [toggle] asks to.
void _showAnswer(
Emitter<StudySessionState> emit, {
bool shown = false,
bool toggle = false,
}) {
if (state case final StudySessionStateShowing showing) {
final answer = toggle ? !showing.answerShown : shown;
if (answer != showing.answerShown) {
emit(showing.withAnswerShown(answer));
}
}
}
@ -340,8 +383,9 @@ class StudySessionBloc(
StudySessionEventRated event,
Emitter<StudySessionState> emit,
) async {
if (state case final StudySessionStateShowing showing
when showing.answerShown) {
// A card may be rated before its answer shows: whoever knows it needn't
// look.
if (state case final StudySessionStateShowing showing) {
final card = showing.card;
final now = clock.now().toUtc();
await _reviews.record(
@ -352,7 +396,9 @@ class StudySessionBloc(
mode: _modeOf(card),
);
_ratings[card.id] = event.rating;
_given.add(event.rating);
if (_options.mode == LearnMode.cram) {
_done.add(card.id);
// The next card not rated yet, after this one and then from the
// start; the round is done once every card has a rating.
final next = [
@ -371,6 +417,7 @@ class StudySessionBloc(
// A day or more away, the card is done for this round.
if (!schedule.due!.isBefore(now.add(const Duration(days: 1)))) {
_graduated.add(card.id);
_done.add(card.id);
}
await _showNext(emit, now);
}
@ -380,11 +427,9 @@ class StudySessionBloc(
if (_options.mode != LearnMode.cram || state is! StudySessionStateShowing) {
return;
}
final index = _cramIndex + event.by;
if (index < 0 || index >= _round.length) {
return;
}
_cramIndex = index;
// Round the round: back from the first card is the last.
_cramIndex = (_cramIndex + event.by) % _round.length;
final index = _cramIndex;
emit(_showing(_round[index], clock.now().toUtc(), schedule: null));
}
}
@ -408,6 +453,10 @@ final class const StudySessionEventContinued() extends StudySessionEvent;
/// whether it was right.
final class const StudySessionEventAnswerShown() extends StudySessionEvent;
/// Show the answer of the card on screen, or hide it again: a tap on the
/// card.
final class const StudySessionEventAnswerToggled() extends StudySessionEvent;
/// The person rated the card on screen.
final class const StudySessionEventRated(
/// How well they recalled it.
@ -420,7 +469,8 @@ final class const StudySessionEventRated(
/// Ask the back of cards first, or the front again: a new round so.
final class const StudySessionEventBackFirstToggled() extends StudySessionEvent;
/// Cramming, go [by] cards on (back when negative), to skip or revisit.
/// Cramming, go [by] cards on (back when negative), round the round, to
/// skip or revisit.
final class const StudySessionEventMoved(
/// How many cards on.
final int by,
@ -454,15 +504,24 @@ sealed class const StudySessionState() with Equatable {
}
/// Working out what to study.
final class const StudySessionStateLoading() extends StudySessionState;
final class const StudySessionStateLoading({
/// Whether cards ask their back first, as far as known yet.
final bool backFirst = false,
}) extends StudySessionState {
@override
List<Object?> get props => [backFirst];
}
/// Nothing to study now.
final class const StudySessionStateNothing({
/// When the next card is due; null without any studied card.
final DateTime? next,
/// Whether cards ask their back first.
final bool backFirst = false,
}) extends StudySessionState {
@override
List<Object?> get props => [next];
List<Object?> get props => [next, backFirst];
}
/// A card on screen.
@ -506,12 +565,11 @@ final class const StudySessionStateShowing({
/// Whether the answer shows.
final bool answerShown = false,
/// When the card would be due after each rating; filled once the answer
/// shows, empty while cramming.
/// When the card would be due after each rating; empty while cramming.
final Map<Rating, DateTime> nextDue = const {},
}) extends StudySessionState {
/// This state with the answer shown and [nextDue].
StudySessionStateShowing withAnswer(Map<Rating, DateTime> nextDue) =>
/// This state with the answer [shown] or hidden.
StudySessionStateShowing withAnswerShown(bool shown) =>
StudySessionStateShowing(
card: card,
asksBack: asksBack,
@ -525,7 +583,7 @@ final class const StudySessionStateShowing({
canGoForward: canGoForward,
tagFilterCount: tagFilterCount,
backFirst: backFirst,
answerShown: true,
answerShown: shown,
nextDue: nextDue,
);
@ -548,14 +606,40 @@ final class const StudySessionStateShowing({
];
}
/// The round is done.
/// The round is done, and how the session went over all its rounds.
final class const StudySessionStateRoundDone({
/// How many cards got each rating, last ratings counted.
/// How many cards of the round got each rating, last ratings counted.
required final Map<Rating, int> tally,
/// Whether another round has cards.
required final bool more,
/// How many cards the session is done with: a day or more away, or
/// crammed.
final int reviewed = 0,
/// How many cards the session had to study: those done and those still
/// due.
final int total = 0,
/// How well the cards were recalled, from 0 to 1, over every rating
/// given: Good and Perfect fully, OK half, Bad not.
final double accuracy = 0,
/// How long the session took so far.
final Duration duration = Duration.zero,
/// Whether cards ask their back first.
final bool backFirst = false,
}) extends StudySessionState {
@override
List<Object?> get props => [tally, more];
List<Object?> get props => [
tally,
more,
reviewed,
total,
accuracy,
duration,
backFirst,
];
}

View file

@ -1,10 +1,12 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The four ratings, each in its colour and, in spaced repetition, with when
/// the card would come back.
/// The four ratings, side by side: each a tile in its colour with a face
/// and its name, and below it, in spaced repetition, when the card would
/// come back.
class const RatingRow({
/// When the card would be due after each rating; none while cramming.
required final Map<Rating, DateTime> nextDue,
@ -19,34 +21,116 @@ class const RatingRow({
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final rating in Rating.values)
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: ratingColor(context, rating),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
child: _RatingTile(
label: ratingLabel(l10n, rating),
icon: ratingIcon(rating),
color: ratingColor(context, rating),
wait: switch (nextDue[rating]) {
final due? => inWaitLabel(l10n, due.difference(shownAt)),
null => null,
},
onTap: () => onRated(rating),
),
onPressed: () => onRated(rating),
),
],
);
}
}
/// A rating's tile: a face and the name in white on its colour, outlined
/// lighter under the pointer; below it the [wait], if any.
class const _RatingTile({
required final String label,
required final IconData icon,
required final Color color,
required final String? wait,
required final VoidCallback onTap,
}) extends StatefulWidget {
@override
State<_RatingTile> createState() => _RatingTileState();
}
class _RatingTileState() extends State<_RatingTile> {
var _hovering = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final wait = widget.wait;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: Material(
color: widget.color,
shape: RoundedRectangleBorder(
side: _hovering
? BorderSide(
color: Colors.white.withValues(alpha: 0.65),
width: 2,
)
: BorderSide.none,
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(ratingLabel(l10n, rating)),
if (nextDue[rating] case final due?)
Icon(widget.icon, color: Colors.white, size: 28),
const SizedBox(height: 2),
Text(
waitLabel(l10n, due.difference(shownAt)),
style: theme.textTheme.labelSmall?.copyWith(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
],
),
),
),
),
),
if (wait != null) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.clock,
size: 12,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Flexible(
child: Text(
wait,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
],
);
}
@ -60,6 +144,14 @@ String ratingLabel(AppLocalizations l10n, Rating rating) => switch (rating) {
Rating.easy => l10n.studyRatingPerfect,
};
/// [rating]'s face: from a frown for Bad to a laugh for Perfect.
IconData ratingIcon(Rating rating) => switch (rating) {
Rating.again => LucideIcons.frown,
Rating.hard => LucideIcons.meh,
Rating.good => LucideIcons.smile,
Rating.easy => LucideIcons.laugh,
};
/// [wait] the short way: minutes, hours, days, months or years.
String waitLabel(AppLocalizations l10n, Duration wait) => switch (wait) {
< const Duration(hours: 1) => l10n.studyMinutes(wait.inMinutes.clamp(1, 59)),
@ -68,3 +160,14 @@ String waitLabel(AppLocalizations l10n, Duration wait) => switch (wait) {
< const Duration(days: 365) => l10n.studyMonths(wait.inDays ~/ 30),
_ => l10n.studyYears(wait.inDays ~/ 365),
};
/// When a card comes back after [wait]: "in 10 min", "in 3 days".
String inWaitLabel(AppLocalizations l10n, Duration wait) => switch (wait) {
< const Duration(hours: 1) => l10n.studyInMinutes(
wait.inMinutes.clamp(1, 59),
),
< const Duration(days: 1) => l10n.studyInHours(wait.inHours),
< const Duration(days: 30) => l10n.studyInDays(wait.inDays),
< const Duration(days: 365) => l10n.studyInMonths(wait.inDays ~/ 30),
_ => l10n.studyInYears(wait.inDays ~/ 365),
};

View file

@ -16,7 +16,8 @@ Color ratingColor(BuildContext context, Rating rating) {
/// The round's progress in spaced repetition: a pill per card, in round
/// order. Done cards are filled with their rating's colour, cards coming
/// back in a muted one, the card on screen outlined, the rest grey.
/// back in a muted one, the card on screen outlined (and muted too when it
/// is back), the rest grey.
class const PillProgress({required final List<RoundPill> pills, super.key})
extends StatelessWidget {
@override
@ -36,12 +37,15 @@ class const PillProgress({required final List<RoundPill> pills, super.key})
context,
rating,
),
(state: PillState.again, rating: final rating?) =>
(
state: PillState.again || PillState.active,
rating: final rating?,
) =>
Color.alphaBlend(
ratingColor(context, rating).withValues(alpha: 0.4),
ratingColor(context, rating).withValues(alpha: 0.5),
colors.surface,
),
(state: PillState.active, rating: _) => Colors.transparent,
(state: PillState.active, rating: null) => Colors.transparent,
_ => colors.outline,
},
border: switch (pill.state) {
@ -85,7 +89,7 @@ class const CramProgress({
if (unrated > 0)
Expanded(
flex: unrated,
child: ColoredBox(color: colors.outlineVariant),
child: ColoredBox(color: colors.outline),
),
],
),

View file

@ -1,14 +1,10 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_page_chip.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_chip.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
@ -16,10 +12,11 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/study_settings_sh
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Studies a studyset, a round at a time: a header (close, the title,
/// asking the back first, the session's settings), the round's progress, the
/// card as its type asks it, and the ratings once it is answered; at the end
/// of a round, how it went.
/// Studies a studyset, a round at a time: a header (close, the title, the
/// Pomodoro timer, asking the back first, the session's settings), the
/// round's progress, the card as its type asks it, and the ratings; at the
/// end of a round, how the session went. Leaving in the middle of a round
/// asks first.
class const StudyScreen({
/// The studyset studied.
required final String studysetId,
@ -29,33 +26,93 @@ class const StudyScreen({
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
Widget build(BuildContext context) =>
BlocBuilder<StudySessionBloc, StudySessionState>(
builder: (context, state) {
final header = _Header(
studysetId: studysetId,
title: title,
state: state,
);
return PopScope(
canPop: state is! StudySessionStateShowing,
onPopInvokedWithResult: (didPop, _) async {
if (!didPop && await _confirmLeave(context) && context.mounted) {
Navigator.of(context).pop();
}
},
child: switch (state) {
final StudySessionStateShowing showing => _Showing(
showing,
header: header,
),
_ => Scaffold(
body: SafeArea(
child: BlocBuilder<StudySessionBloc, StudySessionState>(
builder: (context, state) => Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(studysetId: studysetId, title: title, state: state),
header,
Expanded(
child: switch (state) {
StudySessionStateLoading() => const Center(
child: AppLoadingIndicator(),
StudySessionStateNothing(:final next) => _Nothing(
next: next,
),
StudySessionStateNothing(:final next) => _Nothing(next: next),
final StudySessionStateShowing showing => _Showing(showing),
final StudySessionStateRoundDone done => _RoundDone(done),
final StudySessionStateRoundDone done => _RoundDone(
done,
),
_ => const Center(child: AppLoadingIndicator()),
},
),
],
),
),
),
},
);
},
);
}
/// Close on the left, the title in the middle, the Pomodoro timer if one is
/// set, asking the back first and the settings on the right; the settings
/// show how many tags narrow the session.
/// Asks whether to leave the round under way, saying what is lost: true to
/// leave.
Future<bool> _confirmLeave(BuildContext context) async {
final state = context.read<StudySessionBloc>().state;
if (state is! StudySessionStateShowing) {
return true;
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final left = state.cram
? state.cramUnrated
: state.pills.where((pill) => pill.state != PillState.done).length;
final rated = state.pills.any((pill) => pill.rating != null);
final leave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.studyLeaveTitle),
content: Text(
rated ? l10n.studyLeaveRated(left) : l10n.studyLeaveUnrated(left),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
child: Text(l10n.studyLeaveConfirm),
),
FilledButton(
autofocus: true,
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.studyKeepStudying),
),
],
),
);
return leave ?? false;
}
/// Close and the title on the left; the Pomodoro timer if one is set,
/// asking the back first and the settings on the right, the settings with
/// how many tags narrow the session.
class const _Header({
required final String studysetId,
required final String title,
@ -65,12 +122,20 @@ class const _Header({
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final showing = switch (state) {
final StudySessionStateShowing showing => showing,
_ => null,
final backFirst = switch (state) {
StudySessionStateLoading(:final backFirst) ||
StudySessionStateNothing(:final backFirst) ||
StudySessionStateShowing(:final backFirst) ||
StudySessionStateRoundDone(:final backFirst) => backFirst,
};
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
final tagFilterCount = switch (state) {
StudySessionStateShowing(:final tagFilterCount) => tagFilterCount,
_ => 0,
};
return SizedBox(
height: 56,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
IconButton(
@ -78,38 +143,43 @@ class const _Header({
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.of(context).maybePop(),
),
const SizedBox(width: 4),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const PomodoroChip(),
if (showing != null)
IconButton(
tooltip: showing.backFirst
tooltip: backFirst
? l10n.studyBackFirstOn
: l10n.studyBackFirstOff,
isSelected: showing.backFirst,
icon: const Icon(LucideIcons.copyCheck),
style: showing.backFirst
isSelected: backFirst,
icon: Icon(
LucideIcons.copyCheck,
color: backFirst ? theme.colorScheme.primary : null,
),
style: backFirst
? IconButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
)
: null,
onPressed: () => context.read<StudySessionBloc>().add(
const StudySessionEventBackFirstToggled(),
),
),
Badge(
isLabelVisible: (showing?.tagFilterCount ?? 0) > 0,
label: Text('${showing?.tagFilterCount ?? 0}'),
child: IconButton(
IconButton(
tooltip: l10n.studySettingsTitle,
icon: const Icon(LucideIcons.settings2),
icon: Badge(
isLabelVisible: tagFilterCount > 0,
label: Text('$tagFilterCount'),
child: const Icon(LucideIcons.settings),
),
onPressed: () async {
final bloc = context.read<StudySessionBloc>();
if (await showStudySettingsSheet(
@ -120,124 +190,173 @@ class const _Header({
}
},
),
),
],
),
),
);
}
}
/// A card on screen: the round's progress, the card, and below it showing
/// the answer or rating it; cramming, going back and on beside the ratings.
class const _Showing(final StudySessionStateShowing state)
extends StatelessWidget {
/// A card on screen: the header and the round's progress on a band of
/// their own, the card below on the lighter background, and the ratings
/// beneath it; cramming, going back and skipping beside them.
class const _Showing(
final StudySessionStateShowing state, {
required final Widget header,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final card = state.card;
final key = ValueKey((card.id, state.shownAt));
void check() => _send(context, const StudySessionEventAnswerShown());
void show() => _send(context, const StudySessionEventAnswerShown());
void toggle() => _send(context, const StudySessionEventAnswerToggled());
final ratings = RatingRow(
nextDue: state.nextDue,
shownAt: state.shownAt,
onRated: (rating) => _send(context, StudySessionEventRated(rating)),
);
final body = switch (card.content.type) {
CardType.multipleChoice when card.content.map != null => MapBody(
key: key,
card: card,
checked: state.answerShown,
onCheck: check,
onCheck: show,
ratings: ratings,
),
CardType.multipleChoice => MultipleChoiceBody(
key: key,
card: card,
checked: state.answerShown,
onCheck: check,
onCheck: show,
ratings: ratings,
),
CardType.matching => MatchingBody(
key: key,
card: card,
checked: state.answerShown,
onCheck: check,
onCheck: show,
ratings: ratings,
),
CardType.vocabulary => VocabularyBody(
key: key,
card: card,
asksBack: state.asksBack,
answerShown: state.answerShown,
),
CardType.frontBack => FrontBackBody(
key: key,
card: card,
asksBack: state.asksBack,
answerShown: state.answerShown,
),
};
final revealsByButton = switch (card.content.type) {
CardType.multipleChoice || CardType.matching => false,
CardType.frontBack || CardType.vocabulary => true,
};
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
CardType.vocabulary => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (state.cram)
CramProgress(tally: state.cramTally, unrated: state.cramUnrated)
else
PillProgress(pills: state.pills),
const SizedBox(height: 16),
Expanded(
child: SectionCard(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: VocabularyBody(
key: key,
card: card,
asksBack: state.asksBack,
answerShown: state.answerShown,
onToggle: toggle,
onShow: show,
),
),
const SizedBox(height: 16),
],
),
CardType.frontBack => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: FrontBackBody(
key: key,
card: card,
asksBack: state.asksBack,
answerShown: state.answerShown,
onToggle: toggle,
onShow: show,
),
),
const SizedBox(height: 16),
],
),
};
// Cards answered by picking or sorting have the ratings in their own
// bodies, once checked; the others always below.
final ratedBelow = switch (card.content.type) {
CardType.frontBack || CardType.vocabulary => true,
CardType.multipleChoice || CardType.matching => false,
};
return Scaffold(
backgroundColor: theme.colorScheme.surfaceContainerLow,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// A card made from a PDF leads back to its page.
if (card.content.source case final source?) ...[
PdfPageChip(key: ValueKey(card.id), source: source),
ColoredBox(
color: theme.colorScheme.surfaceContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: state.cram
? CramProgress(
tally: state.cramTally,
unrated: state.cramUnrated,
)
: PillProgress(pills: state.pills),
),
const SizedBox(height: 12),
],
body,
],
),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: body,
),
),
),
),
const SizedBox(height: 16),
Row(
if (ratedBelow)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Center(
child: state.cram
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (state.cram)
IconButton(
IconButton.filledTonal(
tooltip: l10n.studyPrevious,
onPressed: state.canGoBack
? () => _send(context, const StudySessionEventMoved(-1))
? () => _send(
context,
const StudySessionEventMoved(-1),
)
: null,
icon: const Icon(LucideIcons.chevronLeft),
),
Expanded(
child: state.answerShown
? RatingRow(
nextDue: state.nextDue,
shownAt: state.shownAt,
onRated: (rating) =>
_send(context, StudySessionEventRated(rating)),
)
: revealsByButton
? FilledButton(
onPressed: check,
child: Text(l10n.studyShowAnswer),
)
: const SizedBox.shrink(),
const SizedBox(width: 12),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 540,
),
child: ratings,
),
),
const SizedBox(width: 12),
IconButton.filledTonal(
tooltip: l10n.studySkipCard,
onPressed: () => _send(
context,
const StudySessionEventMoved(1),
),
if (state.cram)
IconButton(
tooltip: l10n.studyNext,
onPressed: state.canGoForward
? () => _send(context, const StudySessionEventMoved(1))
: null,
icon: const Icon(LucideIcons.chevronRight),
),
],
)
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: ratings,
),
),
),
],
),
@ -258,18 +377,12 @@ class const _Nothing({required final DateTime? next}) extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.partyPopper,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(l10n.studyNothingTitle, style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(l10n.studyNothingTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.studyNothingBody,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
if (next != null) ...[
const SizedBox(height: 4),
@ -277,10 +390,13 @@ class const _Nothing({required final DateTime? next}) extends StatelessWidget {
l10n.studyNextDue(
waitLabel(l10n, next!.difference(clock.now().toUtc())),
),
style: theme.textTheme.bodySmall,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
const SizedBox(height: 16),
FilledButton(
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.studyDone),
@ -292,56 +408,122 @@ class const _Nothing({required final DateTime? next}) extends StatelessWidget {
}
}
/// A round done: how many cards got each rating, going on when another
/// round has cards, and done.
/// A round done: a check, how many cards of those due the session is done
/// with, its cards, accuracy and time, then going on when more are due, and
/// done.
class const _RoundDone(final StudySessionStateRoundDone state)
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
void done() => Navigator.of(context).maybePop();
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.check,
size: 32,
color: colors.onPrimaryContainer,
),
),
const SizedBox(height: 16),
Text(
l10n.studyRoundDone,
l10n.studySessionComplete,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.layers,
size: 16,
color: colors.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
state.total > 0
? l10n.studyProgressReviewed(
state.reviewed,
state.total,
)
: l10n.studyReviewedCount(state.reviewed),
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 24),
Row(
spacing: 8,
spacing: 12,
children: [
for (final rating in Rating.values)
Expanded(
child: _Stat(
count: state.tally[rating] ?? 0,
label: ratingLabel(l10n, rating),
color: ratingColor(context, rating),
icon: LucideIcons.layers,
label: l10n.studyStatCards,
value: '${state.reviewed}',
),
),
Expanded(
child: _Stat(
icon: LucideIcons.target,
label: l10n.studyStatAccuracy,
value: '${(state.accuracy * 100).round()}%',
),
),
Expanded(
child: _Stat(
icon: LucideIcons.clock,
label: l10n.studyStatTime,
value: _duration(l10n, state.duration),
),
),
],
),
const SizedBox(height: 24),
if (state.more) ...[
FilledButton(
onPressed: () => context.read<StudySessionBloc>().add(
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => _send(
context,
const StudySessionEventContinued(),
),
child: Text(l10n.studyContinue),
),
),
const SizedBox(height: 8),
],
OutlinedButton(
onPressed: () => Navigator.of(context).maybePop(),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: done,
child: Text(l10n.studyDone),
),
),
] else
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: done,
child: Text(l10n.studyDone),
),
),
],
),
),
@ -350,27 +532,52 @@ class const _RoundDone(final StudySessionStateRoundDone state)
}
}
/// [duration] the short way: seconds below a minute, else minutes and
/// seconds.
String _duration(AppLocalizations l10n, Duration duration) {
if (duration.inSeconds < 60) {
return l10n.studyDurationSec(duration.inSeconds);
}
final seconds = duration.inSeconds % 60;
return seconds == 0
? l10n.studyDurationMin(duration.inMinutes)
: l10n.studyDurationMinSec(duration.inMinutes, seconds);
}
/// One figure of the session: its name, an icon and the value.
class const _Stat({
required final int count,
required final IconData icon,
required final String label,
required final Color color,
required final String value,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'$count',
style: theme.textTheme.headlineSmall?.copyWith(color: color),
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Icon(icon, size: 20, color: theme.colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(label, style: theme.textTheme.labelMedium),
],
),
);

View file

@ -328,7 +328,7 @@ void main() {
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.text('Grammar · p. 5'));
await tester.tap(find.text('Grammar'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();

View file

@ -233,7 +233,7 @@ void main() {
expect(find.textContaining("GNOME's top bar"), findsNothing);
});
testWidgets('the timer shows while studying, and a tap pauses it', (
testWidgets('the timer shows while studying, only to watch', (
tester,
) async {
final now = clock.now().toUtc();
@ -258,9 +258,10 @@ void main() {
find.descendant(of: chip, matching: find.textContaining(':')),
findsOneWidget,
);
// Only to watch: a tap leaves the timer running.
await tester.tap(chip);
await tester.pump();
await tester.pump();
expect(platform.showing?.isRunning, isFalse);
expect(platform.showing?.isRunning, isTrue);
});
}

View file

@ -92,13 +92,23 @@ void main() {
(s) => s is StudySessionStateShowing && s.card.content.front == text(front),
) as StudySessionStateShowing;
/// The card on screen again, after it was rated [rating].
Future<void> back(StudySessionBloc bloc, Rating rating) => until(
bloc,
(s) =>
s is StudySessionStateShowing &&
s.pills.any(
(pill) => pill.state == PillState.active && pill.rating == rating,
),
);
/// Shows the answer and rates the card on screen.
void rate(StudySessionBloc bloc, Rating rating) => bloc
..add(const StudySessionEventAnswerShown())
..add(StudySessionEventRated(rating));
test('shows the due cards one by one; a rating records how long answering '
'took', () async {
test('shows the due cards one by one, each with when it would come back '
'after each rating; a rating records how long answering took', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
@ -110,14 +120,10 @@ void main() {
PillState.active,
PillState.pending,
]);
expect(neko.answerShown, isFalse);
expect(neko.nextDue.keys, Rating.values);
now = now.add(const Duration(seconds: 7));
bloc.add(const StudySessionEventAnswerShown());
final answered = await until(
bloc,
(s) => s is StudySessionStateShowing && s.answerShown,
) as StudySessionStateShowing;
expect(answered.nextDue.keys, Rating.values);
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '犬');
});
@ -127,6 +133,25 @@ void main() {
expect(review.mode, 'review');
});
test('tapping the card shows the answer, and hides it again', () async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
bloc.add(const StudySessionEventAnswerToggled());
await until(bloc, (s) => s is StudySessionStateShowing && s.answerShown);
bloc.add(const StudySessionEventAnswerToggled());
final hidden = await until(
bloc,
(s) => s is StudySessionStateShowing && !s.answerShown,
) as StudySessionStateShowing;
expect(hidden.nextDue.keys, Rating.values);
});
});
test('a round holds at most the cards per round', () async {
for (var i = 0; i < 8; i++) {
await cards.create('japanese', card('card $i'));
@ -188,6 +213,10 @@ void main() {
expect(done.tally[Rating.easy], 2);
expect(done.tally[Rating.again], 0);
expect(done.more, isFalse);
// Two cards done, of the two due; Bad, Perfect, Perfect.
expect(done.reviewed, 2);
expect(done.total, 2);
expect(done.accuracy, closeTo(2 / 3, 0.001));
});
});
@ -210,11 +239,61 @@ void main() {
) as StudySessionStateRoundDone;
expect(done.more, isTrue);
expect(done.reviewed, 5);
expect(done.total, 6);
bloc.add(const StudySessionEventContinued());
expect((await showing(bloc, 'card 5')).pills, hasLength(1));
});
});
test('the end of a round says how the whole session went: the cards done '
'of those due, how well, and how long it took', () async {
for (var i = 0; i < 6; i++) {
await cards.create('japanese', card('card $i'));
}
await study(const StudyOptions(cardsPerRound: 5));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
for (var i = 0; i < 4; i++) {
await showing(bloc, 'card $i');
now = now.add(const Duration(seconds: 10));
bloc.add(const StudySessionEventRated(Rating.easy));
}
await showing(bloc, 'card 4');
now = now.add(const Duration(seconds: 35));
bloc.add(const StudySessionEventRated(Rating.again));
// Bad keeps it in its learning steps: it comes back early.
await back(bloc, Rating.again);
bloc.add(const StudySessionEventRated(Rating.easy));
final first = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(first.reviewed, 5);
expect(first.total, 6);
expect(first.accuracy, closeTo(5 / 6, 0.001));
expect(first.duration, const Duration(seconds: 75));
bloc.add(const StudySessionEventContinued());
await showing(bloc, 'card 5');
now = now.add(const Duration(seconds: 15));
bloc.add(const StudySessionEventRated(Rating.again));
await back(bloc, Rating.again);
bloc.add(const StudySessionEventRated(Rating.easy));
final second = await until(
bloc,
(s) => s is StudySessionStateRoundDone,
) as StudySessionStateRoundDone;
expect(second.reviewed, 6);
expect(second.total, 6);
expect(second.accuracy, closeTo(6 / 8, 0.001));
expect(second.duration, const Duration(seconds: 90));
});
});
test('nothing to study says when the next card is due', () async {
await cards.create('japanese', card('猫'));
final neko = (await cards.cardsOf('japanese')).single;
@ -234,6 +313,7 @@ void main() {
(s) => s is StudySessionStateNothing,
) as StudySessionStateNothing;
expect(nothing.next!.isAfter(now.add(const Duration(days: 1))), isTrue);
expect(nothing.backFirst, isFalse);
});
});
@ -308,12 +388,7 @@ void main() {
// Not due, but crammed all the same.
final first = await showing(bloc, '猫');
expect(first.cram, isTrue);
bloc.add(const StudySessionEventAnswerShown());
final answered = await until(
bloc,
(s) => s is StudySessionStateShowing && s.answerShown,
) as StudySessionStateShowing;
expect(answered.nextDue, isEmpty);
expect(first.nextDue, isEmpty);
bloc.add(const StudySessionEventRated(Rating.hard));
await showing(bloc, '犬');
rate(bloc, Rating.good);
@ -325,6 +400,10 @@ void main() {
expect(done.tally[Rating.hard], 1);
expect(done.tally[Rating.good], 1);
expect(done.more, isFalse);
// OK counts half.
expect(done.reviewed, 2);
expect(done.total, 2);
expect(done.accuracy, 0.75);
});
expect(reviewGateway.rows.skip(1).map((row) => row.mode), [
@ -334,23 +413,39 @@ void main() {
expect((await reviews.schedulesOf([neko]))[neko]!.reviewCount, 1);
});
test('goes back and on, to skip or revisit cards', () async {
test(
'goes back and on, round the round, to skip or revisit cards',
() async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await cards.create('japanese', card('鳥'));
await study(const StudyOptions(mode: LearnMode.cram));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final first = await showing(bloc, '猫');
expect(first.canGoBack, isFalse);
expect(first.canGoBack, isTrue);
expect(first.canGoForward, isTrue);
bloc.add(const StudySessionEventMoved(1));
final second = await showing(bloc, '犬');
expect(second.canGoBack, isTrue);
bloc.add(const StudySessionEventMoved(-1));
await showing(bloc, '鳥');
bloc.add(const StudySessionEventMoved(1));
await showing(bloc, '猫');
bloc.add(const StudySessionEventMoved(1));
await showing(bloc, '犬');
});
},
);
test('with a single card, there is nothing to go back to', () async {
await cards.create('japanese', card('猫'));
await study(const StudyOptions(mode: LearnMode.cram));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
expect((await showing(bloc, '猫')).canGoBack, isFalse);
});
});

View file

@ -5,6 +5,7 @@ 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/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -79,8 +80,8 @@ void main() {
);
}
testWidgets('shows the front, then the back and the ratings with their '
'next intervals', (tester) async {
testWidgets('shows the front with the ratings, each with a face and when '
'the card comes back; the back once shown', (tester) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
@ -89,15 +90,57 @@ void main() {
await study(tester);
expect(find.textContaining('猫', findRichText: true), findsOneWidget);
expect(find.textContaining('cat', findRichText: true), findsNothing);
for (final label in ['Bad', 'OK', 'Good', 'Perfect']) {
expect(find.text(label), findsOneWidget);
}
for (final face in [
LucideIcons.frown,
LucideIcons.meh,
LucideIcons.smile,
LucideIcons.laugh,
]) {
expect(find.byIcon(face), findsOneWidget);
}
expect(find.text('in 1 min'), findsOneWidget);
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
expect(find.textContaining('cat', findRichText: true), findsOneWidget);
for (final label in ['Bad', 'OK', 'Good', 'Perfect']) {
expect(find.text(label), findsOneWidget);
}
expect(find.text('1 min'), findsOneWidget);
});
testWidgets('a tap on the card shows the answer, and another hides it', (
tester,
) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
);
await study(tester);
await tester.tap(find.byType(StudyCardSurface));
await tester.pumpAndSettle();
expect(find.textContaining('cat', findRichText: true), findsOneWidget);
await tester.tap(find.byType(StudyCardSurface));
await tester.pumpAndSettle();
expect(find.textContaining('cat', findRichText: true), findsNothing);
});
testWidgets('leaving in the middle of a round asks first', (tester) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
);
await study(tester);
await tester.tap(find.byTooltip('Close session'));
await tester.pumpAndSettle();
expect(find.text('Leave this study session?'), findsOneWidget);
await tester.tap(find.text('Keep studying'));
await tester.pumpAndSettle();
expect(find.textContaining('猫', findRichText: true), findsOneWidget);
});
testWidgets('says when there is nothing to study', (tester) async {
@ -115,14 +158,15 @@ void main() {
);
await study(tester);
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
// Rated straight away: whoever knows it needn't look.
await tester.tap(find.text('Perfect'));
await tester.pumpAndSettle();
expect(find.text('Round complete'), findsOneWidget);
expect(find.text('Session complete'), findsOneWidget);
expect(find.text('1 / 1 reviewed'), findsOneWidget);
expect(find.text('100%'), findsOneWidget);
expect(find.text('Continue learning'), findsNothing);
expect(find.text('Done'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Done'), findsOneWidget);
});
testWidgets('a multiple-choice card is checked, then rated', (tester) async {
@ -191,14 +235,15 @@ void main() {
// The crow is no mammal.
expect(find.text('Perfect'), findsOneWidget);
final crow = tester.widget<Material>(
final crow = tester.widget<Container>(
find
.ancestor(of: find.text('crow'), matching: find.byType(Material))
.ancestor(of: find.text('crow'), matching: find.byType(Container))
.first,
);
final colors = Theme.of(tester.element(find.text('crow'))).colorScheme;
expect(
(crow.shape! as StadiumBorder).side.color,
isNot(Colors.transparent),
((crow.decoration! as BoxDecoration).border! as Border).top.color,
colors.error,
);
});
@ -222,7 +267,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('たべる'), findsOneWidget);
expect(find.text('1. to eat'), findsOneWidget);
expect(find.text('to eat'), findsOneWidget);
});
testWidgets('the session settings narrow the cards studied to the tags '
@ -278,14 +323,17 @@ void main() {
Finder check() => find.widgetWithText(FilledButton, 'Check');
expect(tester.widget<FilledButton>(check()).onPressed, isNull);
expect(find.text('Tap a region to answer'), findsOneWidget);
tester.widget<RegionMap>(find.byType(RegionMap)).onRegionTapped!('JP-27');
await tester.pump();
expect(find.text('Ōsaka · 大阪府'), findsOneWidget);
expect(find.text('Selected: Ōsaka (大阪府)'), findsOneWidget);
await tester.tap(check());
await tester.pumpAndSettle();
expect(find.text('Tokyo · 東京都'), findsOneWidget);
expect(find.text('Ōsaka · 大阪府'), findsOneWidget);
expect(
find.text('You picked Ōsaka (大阪府) · Answer: Tokyo (東京都)'),
findsOneWidget,
);
expect(find.text('Perfect'), findsOneWidget);
});
}