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:
parent
65976ae63a
commit
369ce4ade1
19 changed files with 2145 additions and 643 deletions
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue