feat(studyset): prepare for an exam, with plans, a date and the pace to it
A studyset plans for exams: each plan names what an exam covers, the topics it takes whole (with their flashcards, learning paths and PDFs) and the tags whose flashcards it takes from anywhere in the studyset. The author's plans are published with the studyset (payload schema 7, `examPlans`); copying a release and taking a newer one point their topics at the copy's own studysets. Plans are also offered from level tags: JLPT N5 to N1, HSK 1 to 7-9 and KRDict's levels, each taking the levels below it, as the exams do. The person chooses a plan and the exam's day (`exam`), which stays theirs: the server leaves it out of a release, as a PDF's deadline. The study plan then shows the days left, how many of the plan's flashcards were started, its paths' steps done and its PDFs' pages read, and the pace that gets everything new learnt a week before the exam (by the exam when two weeks or fewer are left): new flashcards a day, steps a week, pages a day. Study for the exam studies only what the plan covers, its new cards at that pace (counted as the day began) and every review due. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
31e4d7d64b
commit
dd6c567ad0
24 changed files with 2351 additions and 20 deletions
|
|
@ -1787,6 +1787,36 @@
|
|||
"planStepsDone": "{total, plural, =1{{done} von 1 Schritt} other{{done} von {total} Schritten}}",
|
||||
"planNothing": "Gerade steht nichts an.",
|
||||
"planLearningPaths": "Lernpfade",
|
||||
"examPrepare": "Auf eine Prüfung vorbereiten",
|
||||
"examPrepareHint": "Wähle, was sie abdeckt und wann sie ist, und bekomme ein Tempo, das dich hinbringt.",
|
||||
"examChange": "Prüfung ändern",
|
||||
"examDaysLeft": "{count, plural, =0{Heute, {date}} =1{Morgen, {date}} other{In {count} Tagen, {date}}}",
|
||||
"examPast": "Sie war am {date}",
|
||||
"examPlanGone": "Diesen Prüfungsplan gibt es nicht mehr",
|
||||
"examCardsStarted": "{total, plural, =1{{started} von 1 Karteikarte begonnen} other{{started} von {total} Karteikarten begonnen}}",
|
||||
"examPagesRead": "{total, plural, =1{{read} von 1 Seite gelesen} other{{read} von {total} Seiten gelesen}}",
|
||||
"examPaceTitle": "Dein Tempo",
|
||||
"examPaceNew": "{count, plural, =1{1 neue Karteikarte am Tag} other{{count} neue Karteikarten am Tag}}",
|
||||
"examPaceSteps": "{count, plural, =1{1 Schritt pro Woche} other{{count} Schritte pro Woche}}",
|
||||
"examPacePages": "{count, plural, =1{1 Seite am Tag} other{{count} Seiten am Tag}}",
|
||||
"examPaceDone": "Alles Neue ist gelernt: bis zur Prüfung wiederholen.",
|
||||
"examStudy": "Für die Prüfung lernen",
|
||||
"examSheetHelp": "Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.",
|
||||
"examDate": "Prüfungsdatum",
|
||||
"examSheetPlans": "Prüfungspläne dieses Lernsets",
|
||||
"examSheetFromTags": "Aus den Stufen-Tags",
|
||||
"examSheetNoPlans": "Noch keine Prüfungspläne. Erstelle einen aus den Themen und Tags dieses Lernsets.",
|
||||
"examRemove": "Nicht mehr vorbereiten",
|
||||
"examPlanTopicCount": "{count, plural, =1{1 Thema} other{{count} Themen}}",
|
||||
"examPlanNew": "Neuer Prüfungsplan",
|
||||
"examPlanEdit": "Prüfungsplan bearbeiten",
|
||||
"examPlanHelp": "Ein Prüfungsplan nennt, was eine Prüfung abdeckt: ganze Themen mit ihren Karteikarten, Lernpfaden und PDFs, und Karteikarten mit bestimmten Tags. Er wird mit dem Lernset veröffentlicht.",
|
||||
"examPlanName": "Name",
|
||||
"examPlanNameHint": "z. B. Abschlussprüfung",
|
||||
"examPlanTopics": "Themen, die sie ganz abdeckt",
|
||||
"examPlanTags": "Karteikarten mit diesen Tags",
|
||||
"examPlanEmpty": "Dieses Lernset hat noch keine Themen oder Karteikarten mit Tags zur Auswahl.",
|
||||
"examPlanDelete": "Plan löschen",
|
||||
"jellyfinTitle": "Jellyfin",
|
||||
"jellyfinSubtitle": "Schau Serien und Filme und hör Musik von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel und Liedtexte. Angemeldet bietet „Material hinzufügen“ eines Lernsets „Aus Jellyfin“ an.",
|
||||
"jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.",
|
||||
|
|
|
|||
|
|
@ -2111,6 +2111,44 @@
|
|||
"@planStepsDone": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"planNothing": "Nothing due right now.",
|
||||
"planLearningPaths": "Learning paths",
|
||||
"examPrepare": "Prepare for an exam",
|
||||
"examPrepareHint": "Choose what it covers and its date, and get a pace that gets you there.",
|
||||
"examChange": "Change the exam",
|
||||
"examDaysLeft": "{count, plural, =0{Today, {date}} =1{Tomorrow, {date}} other{In {count} days, {date}}}",
|
||||
"@examDaysLeft": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
|
||||
"examPast": "It was on {date}",
|
||||
"@examPast": {"placeholders": {"date": {"type": "String"}}},
|
||||
"examPlanGone": "This exam plan is gone",
|
||||
"examCardsStarted": "{total, plural, =1{{started} of 1 flashcard started} other{{started} of {total} flashcards started}}",
|
||||
"@examCardsStarted": {"placeholders": {"started": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"examPagesRead": "{total, plural, =1{{read} of 1 page read} other{{read} of {total} pages read}}",
|
||||
"@examPagesRead": {"placeholders": {"read": {"type": "int"}, "total": {"type": "int"}}},
|
||||
"examPaceTitle": "Your pace",
|
||||
"examPaceNew": "{count, plural, =1{1 new flashcard a day} other{{count} new flashcards a day}}",
|
||||
"@examPaceNew": {"placeholders": {"count": {"type": "int"}}},
|
||||
"examPaceSteps": "{count, plural, =1{1 step a week} other{{count} steps a week}}",
|
||||
"@examPaceSteps": {"placeholders": {"count": {"type": "int"}}},
|
||||
"examPacePages": "{count, plural, =1{1 page a day} other{{count} pages a day}}",
|
||||
"@examPacePages": {"placeholders": {"count": {"type": "int"}}},
|
||||
"examPaceDone": "Everything new is learnt: review until the exam.",
|
||||
"examStudy": "Study for the exam",
|
||||
"examSheetHelp": "Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.",
|
||||
"examDate": "Exam date",
|
||||
"examSheetPlans": "This studyset’s exam plans",
|
||||
"examSheetFromTags": "From the level tags",
|
||||
"examSheetNoPlans": "No exam plans yet. Make one from this studyset’s topics and tags.",
|
||||
"examRemove": "Stop preparing",
|
||||
"examPlanTopicCount": "{count, plural, =1{1 topic} other{{count} topics}}",
|
||||
"@examPlanTopicCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"examPlanNew": "New exam plan",
|
||||
"examPlanEdit": "Edit exam plan",
|
||||
"examPlanHelp": "An exam plan names what an exam covers: whole topics, with their flashcards, learning paths and PDFs, and flashcards with certain tags. It is published with the studyset.",
|
||||
"examPlanName": "Name",
|
||||
"examPlanNameHint": "e.g. Final exam",
|
||||
"examPlanTopics": "Topics it covers whole",
|
||||
"examPlanTags": "Flashcards with these tags",
|
||||
"examPlanEmpty": "This studyset has no topics or tagged flashcards to choose from yet.",
|
||||
"examPlanDelete": "Delete plan",
|
||||
"jellyfinTitle": "Jellyfin",
|
||||
"jellyfinSubtitle": "Watch shows and films and listen to music from your own Jellyfin server, and learn the words of their subtitles and lyrics. Signed in, a studyset's Add material offers From Jellyfin.",
|
||||
"jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.",
|
||||
|
|
|
|||
|
|
@ -10563,6 +10563,186 @@ abstract class AppLocalizations {
|
|||
/// **'Learning paths'**
|
||||
String get planLearningPaths;
|
||||
|
||||
/// No description provided for @examPrepare.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Prepare for an exam'**
|
||||
String get examPrepare;
|
||||
|
||||
/// No description provided for @examPrepareHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose what it covers and its date, and get a pace that gets you there.'**
|
||||
String get examPrepareHint;
|
||||
|
||||
/// No description provided for @examChange.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change the exam'**
|
||||
String get examChange;
|
||||
|
||||
/// No description provided for @examDaysLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{Today, {date}} =1{Tomorrow, {date}} other{In {count} days, {date}}}'**
|
||||
String examDaysLeft(int count, String date);
|
||||
|
||||
/// No description provided for @examPast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It was on {date}'**
|
||||
String examPast(String date);
|
||||
|
||||
/// No description provided for @examPlanGone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This exam plan is gone'**
|
||||
String get examPlanGone;
|
||||
|
||||
/// No description provided for @examCardsStarted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{total, plural, =1{{started} of 1 flashcard started} other{{started} of {total} flashcards started}}'**
|
||||
String examCardsStarted(int started, int total);
|
||||
|
||||
/// No description provided for @examPagesRead.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{total, plural, =1{{read} of 1 page read} other{{read} of {total} pages read}}'**
|
||||
String examPagesRead(int read, int total);
|
||||
|
||||
/// No description provided for @examPaceTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your pace'**
|
||||
String get examPaceTitle;
|
||||
|
||||
/// No description provided for @examPaceNew.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 new flashcard a day} other{{count} new flashcards a day}}'**
|
||||
String examPaceNew(int count);
|
||||
|
||||
/// No description provided for @examPaceSteps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 step a week} other{{count} steps a week}}'**
|
||||
String examPaceSteps(int count);
|
||||
|
||||
/// No description provided for @examPacePages.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 page a day} other{{count} pages a day}}'**
|
||||
String examPacePages(int count);
|
||||
|
||||
/// No description provided for @examPaceDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Everything new is learnt: review until the exam.'**
|
||||
String get examPaceDone;
|
||||
|
||||
/// No description provided for @examStudy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study for the exam'**
|
||||
String get examStudy;
|
||||
|
||||
/// No description provided for @examSheetHelp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.'**
|
||||
String get examSheetHelp;
|
||||
|
||||
/// No description provided for @examDate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exam date'**
|
||||
String get examDate;
|
||||
|
||||
/// No description provided for @examSheetPlans.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This studyset’s exam plans'**
|
||||
String get examSheetPlans;
|
||||
|
||||
/// No description provided for @examSheetFromTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From the level tags'**
|
||||
String get examSheetFromTags;
|
||||
|
||||
/// No description provided for @examSheetNoPlans.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No exam plans yet. Make one from this studyset’s topics and tags.'**
|
||||
String get examSheetNoPlans;
|
||||
|
||||
/// No description provided for @examRemove.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stop preparing'**
|
||||
String get examRemove;
|
||||
|
||||
/// No description provided for @examPlanTopicCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 topic} other{{count} topics}}'**
|
||||
String examPlanTopicCount(int count);
|
||||
|
||||
/// No description provided for @examPlanNew.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New exam plan'**
|
||||
String get examPlanNew;
|
||||
|
||||
/// No description provided for @examPlanEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit exam plan'**
|
||||
String get examPlanEdit;
|
||||
|
||||
/// No description provided for @examPlanHelp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'An exam plan names what an exam covers: whole topics, with their flashcards, learning paths and PDFs, and flashcards with certain tags. It is published with the studyset.'**
|
||||
String get examPlanHelp;
|
||||
|
||||
/// No description provided for @examPlanName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name'**
|
||||
String get examPlanName;
|
||||
|
||||
/// No description provided for @examPlanNameHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'e.g. Final exam'**
|
||||
String get examPlanNameHint;
|
||||
|
||||
/// No description provided for @examPlanTopics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Topics it covers whole'**
|
||||
String get examPlanTopics;
|
||||
|
||||
/// No description provided for @examPlanTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Flashcards with these tags'**
|
||||
String get examPlanTags;
|
||||
|
||||
/// No description provided for @examPlanEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This studyset has no topics or tagged flashcards to choose from yet.'**
|
||||
String get examPlanEmpty;
|
||||
|
||||
/// No description provided for @examPlanDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete plan'**
|
||||
String get examPlanDelete;
|
||||
|
||||
/// No description provided for @jellyfinTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -6677,6 +6677,161 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get planLearningPaths => 'Lernpfade';
|
||||
|
||||
@override
|
||||
String get examPrepare => 'Auf eine Prüfung vorbereiten';
|
||||
|
||||
@override
|
||||
String get examPrepareHint =>
|
||||
'Wähle, was sie abdeckt und wann sie ist, und bekomme ein Tempo, das dich hinbringt.';
|
||||
|
||||
@override
|
||||
String get examChange => 'Prüfung ändern';
|
||||
|
||||
@override
|
||||
String examDaysLeft(int count, String date) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'In $count Tagen, $date',
|
||||
one: 'Morgen, $date',
|
||||
zero: 'Heute, $date',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPast(String date) {
|
||||
return 'Sie war am $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPlanGone => 'Diesen Prüfungsplan gibt es nicht mehr';
|
||||
|
||||
@override
|
||||
String examCardsStarted(int started, int total) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
total,
|
||||
locale: localeName,
|
||||
other: '$started von $total Karteikarten begonnen',
|
||||
one: '$started von 1 Karteikarte begonnen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPagesRead(int read, int total) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
total,
|
||||
locale: localeName,
|
||||
other: '$read von $total Seiten gelesen',
|
||||
one: '$read von 1 Seite gelesen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPaceTitle => 'Dein Tempo';
|
||||
|
||||
@override
|
||||
String examPaceNew(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count neue Karteikarten am Tag',
|
||||
one: '1 neue Karteikarte am Tag',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPaceSteps(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Schritte pro Woche',
|
||||
one: '1 Schritt pro Woche',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPacePages(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Seiten am Tag',
|
||||
one: '1 Seite am Tag',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPaceDone =>
|
||||
'Alles Neue ist gelernt: bis zur Prüfung wiederholen.';
|
||||
|
||||
@override
|
||||
String get examStudy => 'Für die Prüfung lernen';
|
||||
|
||||
@override
|
||||
String get examSheetHelp =>
|
||||
'Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.';
|
||||
|
||||
@override
|
||||
String get examDate => 'Prüfungsdatum';
|
||||
|
||||
@override
|
||||
String get examSheetPlans => 'Prüfungspläne dieses Lernsets';
|
||||
|
||||
@override
|
||||
String get examSheetFromTags => 'Aus den Stufen-Tags';
|
||||
|
||||
@override
|
||||
String get examSheetNoPlans =>
|
||||
'Noch keine Prüfungspläne. Erstelle einen aus den Themen und Tags dieses Lernsets.';
|
||||
|
||||
@override
|
||||
String get examRemove => 'Nicht mehr vorbereiten';
|
||||
|
||||
@override
|
||||
String examPlanTopicCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Themen',
|
||||
one: '1 Thema',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPlanNew => 'Neuer Prüfungsplan';
|
||||
|
||||
@override
|
||||
String get examPlanEdit => 'Prüfungsplan bearbeiten';
|
||||
|
||||
@override
|
||||
String get examPlanHelp =>
|
||||
'Ein Prüfungsplan nennt, was eine Prüfung abdeckt: ganze Themen mit ihren Karteikarten, Lernpfaden und PDFs, und Karteikarten mit bestimmten Tags. Er wird mit dem Lernset veröffentlicht.';
|
||||
|
||||
@override
|
||||
String get examPlanName => 'Name';
|
||||
|
||||
@override
|
||||
String get examPlanNameHint => 'z. B. Abschlussprüfung';
|
||||
|
||||
@override
|
||||
String get examPlanTopics => 'Themen, die sie ganz abdeckt';
|
||||
|
||||
@override
|
||||
String get examPlanTags => 'Karteikarten mit diesen Tags';
|
||||
|
||||
@override
|
||||
String get examPlanEmpty =>
|
||||
'Dieses Lernset hat noch keine Themen oder Karteikarten mit Tags zur Auswahl.';
|
||||
|
||||
@override
|
||||
String get examPlanDelete => 'Plan löschen';
|
||||
|
||||
@override
|
||||
String get jellyfinTitle => 'Jellyfin';
|
||||
|
||||
|
|
|
|||
|
|
@ -6617,6 +6617,160 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get planLearningPaths => 'Learning paths';
|
||||
|
||||
@override
|
||||
String get examPrepare => 'Prepare for an exam';
|
||||
|
||||
@override
|
||||
String get examPrepareHint =>
|
||||
'Choose what it covers and its date, and get a pace that gets you there.';
|
||||
|
||||
@override
|
||||
String get examChange => 'Change the exam';
|
||||
|
||||
@override
|
||||
String examDaysLeft(int count, String date) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'In $count days, $date',
|
||||
one: 'Tomorrow, $date',
|
||||
zero: 'Today, $date',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPast(String date) {
|
||||
return 'It was on $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPlanGone => 'This exam plan is gone';
|
||||
|
||||
@override
|
||||
String examCardsStarted(int started, int total) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
total,
|
||||
locale: localeName,
|
||||
other: '$started of $total flashcards started',
|
||||
one: '$started of 1 flashcard started',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPagesRead(int read, int total) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
total,
|
||||
locale: localeName,
|
||||
other: '$read of $total pages read',
|
||||
one: '$read of 1 page read',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPaceTitle => 'Your pace';
|
||||
|
||||
@override
|
||||
String examPaceNew(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count new flashcards a day',
|
||||
one: '1 new flashcard a day',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPaceSteps(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count steps a week',
|
||||
one: '1 step a week',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String examPacePages(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count pages a day',
|
||||
one: '1 page a day',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPaceDone => 'Everything new is learnt: review until the exam.';
|
||||
|
||||
@override
|
||||
String get examStudy => 'Study for the exam';
|
||||
|
||||
@override
|
||||
String get examSheetHelp =>
|
||||
'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.';
|
||||
|
||||
@override
|
||||
String get examDate => 'Exam date';
|
||||
|
||||
@override
|
||||
String get examSheetPlans => 'This studyset’s exam plans';
|
||||
|
||||
@override
|
||||
String get examSheetFromTags => 'From the level tags';
|
||||
|
||||
@override
|
||||
String get examSheetNoPlans =>
|
||||
'No exam plans yet. Make one from this studyset’s topics and tags.';
|
||||
|
||||
@override
|
||||
String get examRemove => 'Stop preparing';
|
||||
|
||||
@override
|
||||
String examPlanTopicCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count topics',
|
||||
one: '1 topic',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get examPlanNew => 'New exam plan';
|
||||
|
||||
@override
|
||||
String get examPlanEdit => 'Edit exam plan';
|
||||
|
||||
@override
|
||||
String get examPlanHelp =>
|
||||
'An exam plan names what an exam covers: whole topics, with their flashcards, learning paths and PDFs, and flashcards with certain tags. It is published with the studyset.';
|
||||
|
||||
@override
|
||||
String get examPlanName => 'Name';
|
||||
|
||||
@override
|
||||
String get examPlanNameHint => 'e.g. Final exam';
|
||||
|
||||
@override
|
||||
String get examPlanTopics => 'Topics it covers whole';
|
||||
|
||||
@override
|
||||
String get examPlanTags => 'Flashcards with these tags';
|
||||
|
||||
@override
|
||||
String get examPlanEmpty =>
|
||||
'This studyset has no topics or tagged flashcards to choose from yet.';
|
||||
|
||||
@override
|
||||
String get examPlanDelete => 'Delete plan';
|
||||
|
||||
@override
|
||||
String get jellyfinTitle => 'Jellyfin';
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
|
@ -227,6 +228,37 @@ class CopyUpdater(
|
|||
);
|
||||
}
|
||||
|
||||
// An exam plan's topics are studysets of the release: the plans name
|
||||
// the copy's own in their place.
|
||||
final studysetOf = {
|
||||
for (final MapEntry(key: item, value: studyset) in copy.studysets.entries)
|
||||
item: studyset.id,
|
||||
};
|
||||
final ours = studysetOf.values.toSet();
|
||||
for (final studyset in await _studysets.all()) {
|
||||
final plans = studyset.content.examPlans;
|
||||
if (!ours.contains(studyset.id) || plans.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final renamed = [
|
||||
for (final plan in plans)
|
||||
ExamPlan(
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
topics: [
|
||||
for (final topic in plan.topics) studysetOf[topic] ?? topic,
|
||||
],
|
||||
tags: plan.tags,
|
||||
),
|
||||
];
|
||||
if (renamed.indexed.any((each) => each.$2 != plans[each.$1])) {
|
||||
await _studysets.updateContent(
|
||||
studyset.id,
|
||||
studyset.content.withExamPlans(renamed),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cards.
|
||||
for (final entry in diff.changed.where(_is(ReleaseKind.card))) {
|
||||
final card = copy.cards[entry.id]!;
|
||||
|
|
@ -578,6 +610,8 @@ class CopyUpdater(
|
|||
bibliography: content.bibliography,
|
||||
published: origin,
|
||||
publishedAt: location,
|
||||
examPlans: content.examPlans,
|
||||
exam: content.exam,
|
||||
);
|
||||
|
||||
static LearningPathContent _pathWithOrigin(
|
||||
|
|
|
|||
|
|
@ -179,6 +179,25 @@ class ReleaseCopier(
|
|||
},
|
||||
);
|
||||
|
||||
// An exam plan's topics are studysets of the release too: its plans
|
||||
// name the ones made of them now.
|
||||
for (final MapEntry(key: directory, value: item)
|
||||
in named.studysets.entries) {
|
||||
if (item.content.examPlans.isNotEmpty) {
|
||||
final content = item.content;
|
||||
await _studysets.updateContent(
|
||||
studysetIds[directory]!,
|
||||
origin == null
|
||||
? content
|
||||
: _withOrigin(
|
||||
content,
|
||||
origin(item.id),
|
||||
location: directory.isEmpty ? location : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final tagIds = <String, String>{};
|
||||
Future<Set<String>> tagsOf(List<String> names) async => {
|
||||
for (final name in names) tagIds[name] ??= await _tags.create(name),
|
||||
|
|
@ -367,8 +386,8 @@ class ReleaseCopier(
|
|||
|
||||
static const _kinds = {'cards', 'notes', 'paths', 'pdfs'};
|
||||
|
||||
/// [json] with every studyset a study button names by the one [names]
|
||||
/// gives in its place.
|
||||
/// [json] with every studyset a study button or an exam plan names by the
|
||||
/// one [names] gives in its place.
|
||||
static Object? _renamed(Object? json, Map<String, String> names) =>
|
||||
names.isEmpty
|
||||
? json
|
||||
|
|
@ -380,6 +399,11 @@ class ReleaseCopier(
|
|||
for (final MapEntry(:key, :value) in map.entries)
|
||||
key: switch ((key, value)) {
|
||||
(studyButtonStudysetKey, final String id) => names[id] ?? id,
|
||||
// The topics an exam plan covers.
|
||||
('topics', final List<Object?> ids) => [
|
||||
for (final id in ids)
|
||||
if (id is String) names[id] ?? id else id,
|
||||
],
|
||||
_ => _renamed(value, names),
|
||||
},
|
||||
},
|
||||
|
|
@ -421,6 +445,8 @@ class ReleaseCopier(
|
|||
bibliography: content.bibliography,
|
||||
published: origin,
|
||||
publishedAt: location,
|
||||
examPlans: content.examPlans,
|
||||
exam: content.exam,
|
||||
);
|
||||
|
||||
static LearningPathContent _pathWithOrigin(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/vocabulary_quiz.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/exam_coverage.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
|
@ -361,6 +363,11 @@ class StudySessionBloc(
|
|||
/// by the settings. Takes each one's study options along: its own, else
|
||||
/// the settings' new cards a day.
|
||||
Future<List<List<Card>>> _groups({bool filtered = true}) async {
|
||||
if (_focus?.exam ?? false) {
|
||||
if (await _examGroups() case final groups?) {
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
final tagged = filtered ? await _cardsWithChosenTags() : null;
|
||||
if (!filtered) {
|
||||
_tagFilterCount = 0;
|
||||
|
|
@ -402,6 +409,79 @@ class StudySessionBloc(
|
|||
return groups;
|
||||
}
|
||||
|
||||
/// For a session studying for the exam the person prepares for in the
|
||||
/// studyset: what the exam's plan covers, as one group whose new cards
|
||||
/// a day are the pace that gets them through by the exam (`examPace`);
|
||||
/// null without an exam, or with one its studyset no longer plans for.
|
||||
Future<List<List<Card>>?> _examGroups() async {
|
||||
final all = await _studysets.all();
|
||||
final choice = all
|
||||
.where((each) => each.id == _studysetId)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.exam;
|
||||
if (choice == null) {
|
||||
return null;
|
||||
}
|
||||
final coverage = ExamCoverage(
|
||||
studysets: _studysets,
|
||||
cards: _cards,
|
||||
tags: _tags,
|
||||
);
|
||||
final plan = await coverage.planOf(_studysetId, choice.plan);
|
||||
if (plan == null) {
|
||||
return null;
|
||||
}
|
||||
final covered = [
|
||||
for (final each in await coverage.cardsOf(_studysetId, plan))
|
||||
if (!_deletions.pending.contains(each.card.id)) each,
|
||||
];
|
||||
final schedules = await _reviews.schedulesOf([
|
||||
for (final (studysetId: _, :card) in covered) card.id,
|
||||
]);
|
||||
final now = clock.now();
|
||||
final startOfToday = startOfLocalDay(now);
|
||||
// The pace as the day began: the new cards started today count among
|
||||
// those it spreads, and against the day's limit.
|
||||
final pace = examPace(
|
||||
today: now,
|
||||
examDate: choice.date,
|
||||
newCards: covered.where((each) {
|
||||
final schedule = schedules[each.card.id];
|
||||
return (schedule?.isNew ?? true) ||
|
||||
schedule?.firstReviewedAt?.isBefore(startOfToday) == false;
|
||||
}).length,
|
||||
stepsLeft: 0,
|
||||
pagesLeft: 0,
|
||||
);
|
||||
_tagFilterCount = 0;
|
||||
_groupOptions = [
|
||||
// Reviews as many as are due ([StudysetStudyOptions.reviewsPerDay]'s
|
||||
// default); no new cards left asks for no limit, which starts none.
|
||||
StudysetStudyOptions(newPerDay: pace.newPerDay),
|
||||
];
|
||||
_order =
|
||||
all
|
||||
.where((each) => each.id == _studysetId)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.studyOptions
|
||||
?.order ??
|
||||
NewCardOrder.reviewsFirst;
|
||||
_studysetOf.clear();
|
||||
for (final (:studysetId, :card) in covered) {
|
||||
_studysetOf[card.id] = studysetId;
|
||||
}
|
||||
_words = [
|
||||
for (final (:studysetId, :card) in covered)
|
||||
if (card.content.type == CardType.vocabulary)
|
||||
(cardId: card.id, studysetId: studysetId, content: card.content),
|
||||
];
|
||||
return [
|
||||
[for (final (studysetId: _, :card) in covered) card],
|
||||
];
|
||||
}
|
||||
|
||||
/// [ids] in the order the settings ask for.
|
||||
List<String> _ordered(List<String> ids) => switch (_options.effectiveOrder) {
|
||||
StudyOrder.oldestFirst => ids,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ final class const StudyFocus({
|
|||
/// Whether every card is gone through once, ratings moving none; else as
|
||||
/// the settings have it.
|
||||
final bool cram = false,
|
||||
|
||||
/// Whether only what the exam the person prepares for covers is studied,
|
||||
/// its new cards at the pace that gets them through by the exam.
|
||||
final bool exam = false,
|
||||
}) with Equatable {
|
||||
/// The focus [toQuery] wrote; null for a query without one, a session as
|
||||
/// the settings have it.
|
||||
|
|
@ -38,6 +42,7 @@ final class const StudyFocus({
|
|||
_ => null,
|
||||
},
|
||||
cram: query[_cramKey] == '1',
|
||||
exam: query[_examKey] == '1',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +53,7 @@ final class const StudyFocus({
|
|||
_topicsKey: topics ? '1' : '0',
|
||||
if (backFirst case final backFirst?) _backFirstKey: backFirst ? '1' : '0',
|
||||
if (cram) _cramKey: '1',
|
||||
if (exam) _examKey: '1',
|
||||
};
|
||||
|
||||
/// This focus asking the other side first.
|
||||
|
|
@ -56,10 +62,11 @@ final class const StudyFocus({
|
|||
topics: topics,
|
||||
backFirst: !(backFirst ?? false),
|
||||
cram: cram,
|
||||
exam: exam,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tag, topics, backFirst, cram];
|
||||
List<Object?> get props => [tag, topics, backFirst, cram, exam];
|
||||
}
|
||||
|
||||
const _focusKey = 'focus';
|
||||
|
|
@ -68,3 +75,4 @@ const _tagNameKey = 'tagName';
|
|||
const _topicsKey = 'topics';
|
||||
const _backFirstKey = 'back';
|
||||
const _cramKey = 'cram';
|
||||
const _examKey = 'exam';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/exam_coverage.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
/// The exams a studyset plans for and the one the person prepares for:
|
||||
/// [plans] (its author's, then those offered from level tags; null until
|
||||
/// read), the [choice] made, the [plan] chosen as it is now, and [progress]
|
||||
/// with what it covers (null until worked out, or without a choice).
|
||||
final class const ExamState({
|
||||
final List<ExamPlan>? plans,
|
||||
final ExamChoice? choice,
|
||||
final ExamPlan? plan,
|
||||
final ExamProgress? progress,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [plans, choice, plan, progress];
|
||||
}
|
||||
|
||||
/// The exam plans of the studyset [_studysetId] and the person's choice
|
||||
/// among them (decided 2026-10-14): reads them as the studyset changes, and
|
||||
/// how far the person has come with the plan chosen as its cards, reviews,
|
||||
/// paths, their progress, PDFs and tags change; writes the choice and the
|
||||
/// author's plans into the studyset.
|
||||
class ExamCubit({
|
||||
required final StudysetRepository _studysets,
|
||||
required final CardRepository _cards,
|
||||
required final TagRepository _tags,
|
||||
required final ReviewRepository _reviews,
|
||||
required final LearningPathRepository _paths,
|
||||
required final PathProgressRepository _pathProgress,
|
||||
required final PdfSourceRepository _pdfs,
|
||||
required final String _studysetId,
|
||||
}) extends Cubit<ExamState> {
|
||||
this : super(const ExamState()) {
|
||||
_subscriptions = [
|
||||
_studysets.watchAll().listen((all) {
|
||||
_studyset = all.where((each) => each.id == _studysetId).firstOrNull;
|
||||
_refresh();
|
||||
}, onError: (Object _) {}),
|
||||
for (final changes in [
|
||||
_cards.changes,
|
||||
_reviews.changes,
|
||||
_paths.changes,
|
||||
_pathProgress.changes,
|
||||
_pdfs.changes,
|
||||
])
|
||||
changes.listen((_) => _refresh()),
|
||||
_tags.watch().listen((_) => _refresh(), onError: (Object _) {}),
|
||||
];
|
||||
}
|
||||
|
||||
late final _coverage = ExamCoverage(
|
||||
studysets: _studysets,
|
||||
cards: _cards,
|
||||
tags: _tags,
|
||||
);
|
||||
late final List<StreamSubscription<Object?>> _subscriptions;
|
||||
Studyset? _studyset;
|
||||
|
||||
/// Whether a reading runs, and whether another was asked for meanwhile:
|
||||
/// a burst of changes reads once more, not once each.
|
||||
var _reading = false;
|
||||
var _readAgain = false;
|
||||
|
||||
void _refresh() {
|
||||
if (_reading) {
|
||||
_readAgain = true;
|
||||
return;
|
||||
}
|
||||
unawaited(_read());
|
||||
}
|
||||
|
||||
Future<void> _read() async {
|
||||
_reading = true;
|
||||
try {
|
||||
do {
|
||||
_readAgain = false;
|
||||
final choice = _studyset?.content.exam;
|
||||
final plans = await _coverage.plansOf(_studysetId);
|
||||
final plan = switch (choice) {
|
||||
final choice? =>
|
||||
plans.where((plan) => plan.id == choice.plan).firstOrNull,
|
||||
null => null,
|
||||
};
|
||||
final progress = plan == null
|
||||
? null
|
||||
: await _coverage.progressOf(
|
||||
_studysetId,
|
||||
plan,
|
||||
reviews: _reviews,
|
||||
paths: _paths,
|
||||
pathProgress: _pathProgress,
|
||||
pdfs: _pdfs,
|
||||
);
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
ExamState(
|
||||
plans: plans,
|
||||
choice: choice,
|
||||
plan: plan,
|
||||
progress: progress,
|
||||
),
|
||||
);
|
||||
} while (_readAgain && !isClosed);
|
||||
} on Object catch (error) {
|
||||
log('Reading the exam plans failed', name: 'Exam', error: error);
|
||||
} finally {
|
||||
_reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a plan may cover: the studyset's topics (each with how deep it
|
||||
/// sits) and the names of the tags its cards carry.
|
||||
Future<({List<({Studyset studyset, int depth})> topics, List<String> tags})>
|
||||
coverable() async => (
|
||||
topics: await _coverage.topicsOf(_studysetId),
|
||||
tags: await _coverage.tagNamesOf(_studysetId),
|
||||
);
|
||||
|
||||
/// Prepares for the exam [planId] on [date]; none for null.
|
||||
Future<void> choose(String? planId, DateTime? date) async {
|
||||
final studyset = _studyset;
|
||||
if (studyset == null) {
|
||||
return;
|
||||
}
|
||||
await _studysets.updateContent(
|
||||
_studysetId,
|
||||
studyset.content.withExam(
|
||||
planId == null || date == null
|
||||
? null
|
||||
: ExamChoice(
|
||||
plan: planId,
|
||||
date: DateTime(date.year, date.month, date.day),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Keeps [plan] among the author's plans, in place of the one with its id.
|
||||
Future<void> savePlan(ExamPlan plan) async {
|
||||
final studyset = _studyset;
|
||||
if (studyset == null) {
|
||||
return;
|
||||
}
|
||||
final plans = studyset.content.examPlans;
|
||||
await _studysets.updateContent(
|
||||
_studysetId,
|
||||
studyset.content.withExamPlans(
|
||||
plans.any((each) => each.id == plan.id)
|
||||
? [
|
||||
for (final each in plans)
|
||||
if (each.id == plan.id) plan else each,
|
||||
]
|
||||
: [...plans, plan],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes the author's plan [planId], and the choice of it.
|
||||
Future<void> deletePlan(String planId) async {
|
||||
final studyset = _studyset;
|
||||
if (studyset == null) {
|
||||
return;
|
||||
}
|
||||
var content = studyset.content.withExamPlans([
|
||||
for (final plan in studyset.content.examPlans)
|
||||
if (plan.id != planId) plan,
|
||||
]);
|
||||
if (content.exam?.plan == planId) {
|
||||
content = content.withExam(null);
|
||||
}
|
||||
await _studysets.updateContent(_studysetId, content);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
for (final subscription in _subscriptions) {
|
||||
await subscription.cancel();
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
/// How far a person has come with what an exam plan covers: its cards (and
|
||||
/// how many of them were never studied, and how many were first studied
|
||||
/// today), the steps of its learning paths (and how many are done), the
|
||||
/// pages of its PDFs (and how many are read).
|
||||
typedef ExamProgress = ({
|
||||
int cards,
|
||||
int newCards,
|
||||
int startedToday,
|
||||
int steps,
|
||||
int stepsDone,
|
||||
int pages,
|
||||
int pagesRead,
|
||||
});
|
||||
|
||||
/// What exam plans cover in a studyset (decided 2026-10-14), read from the
|
||||
/// person's repositories: the studysets a plan takes whole (each with
|
||||
/// everything it studies inside), and the cards of the plan's tags anywhere
|
||||
/// in the studyset. The study session reads the cards alone ([cardsOf]),
|
||||
/// the study plan everything ([progressOf]).
|
||||
class ExamCoverage({
|
||||
required final StudysetRepository _studysets,
|
||||
required final CardRepository _cards,
|
||||
required final TagRepository _tags,
|
||||
}) {
|
||||
/// The plans the studyset [studysetId] offers: its author's, then those
|
||||
/// offered from the level tags its cards carry ([automaticExamPlans]).
|
||||
Future<List<ExamPlan>> plansOf(String studysetId) async {
|
||||
final all = await _studysets.all();
|
||||
final studyset = all.where((each) => each.id == studysetId).firstOrNull;
|
||||
if (studyset == null) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
...studyset.content.examPlans,
|
||||
...automaticExamPlans(await tagNamesOf(studysetId)),
|
||||
];
|
||||
}
|
||||
|
||||
/// The names of the tags the cards the studyset [studysetId] studies
|
||||
/// carry, in alphabetical order.
|
||||
Future<List<String>> tagNamesOf(String studysetId) async {
|
||||
final all = await _studysets.all();
|
||||
final tagging = await _tags.read();
|
||||
final names = <String>{};
|
||||
for (final id in studiedIn(studysetId, all)) {
|
||||
for (final card in await _cards.cardsOf(id)) {
|
||||
names.addAll([for (final tag in tagging.of(card.id)) tag.name]);
|
||||
}
|
||||
}
|
||||
return names.toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
}
|
||||
|
||||
/// The topics a plan of the studyset [studysetId] may take whole: the
|
||||
/// studysets inside it (not deleted), in tree order, each with how deep
|
||||
/// it sits below it (1 for its children).
|
||||
Future<List<({Studyset studyset, int depth})>> topicsOf(
|
||||
String studysetId,
|
||||
) async {
|
||||
final all = notDeleted(await _studysets.all());
|
||||
final topics = <({Studyset studyset, int depth})>[];
|
||||
final seen = {studysetId};
|
||||
void visit(String id, int depth) {
|
||||
for (final child in childrenOf(id, all)) {
|
||||
if (seen.add(child.id)) {
|
||||
topics.add((studyset: child, depth: depth));
|
||||
visit(child.id, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(studysetId, 1);
|
||||
return topics;
|
||||
}
|
||||
|
||||
/// The plan [planId] of the studyset [studysetId]; null for one it no
|
||||
/// longer offers.
|
||||
Future<ExamPlan?> planOf(String studysetId, String planId) async => [
|
||||
for (final plan in await plansOf(studysetId))
|
||||
if (plan.id == planId) plan,
|
||||
].firstOrNull;
|
||||
|
||||
/// The studysets [plan] takes whole within [studysetId], each with what
|
||||
/// it studies inside (archived ones rest).
|
||||
Future<Set<String>> studysetsOf(String studysetId, ExamPlan plan) async {
|
||||
final all = await _studysets.all();
|
||||
final inside = subtreeOf(studysetId, all).toSet();
|
||||
return {
|
||||
for (final topic in plan.topics)
|
||||
if (inside.contains(topic)) ...studiedIn(topic, all),
|
||||
};
|
||||
}
|
||||
|
||||
/// The cards [plan] covers in the studyset [studysetId], each with the
|
||||
/// studyset it is in, each studyset's in its order: those of the
|
||||
/// studysets it takes whole, and those of its tags anywhere the studyset
|
||||
/// studies.
|
||||
Future<List<({String studysetId, Card card})>> cardsOf(
|
||||
String studysetId,
|
||||
ExamPlan plan,
|
||||
) async {
|
||||
final all = await _studysets.all();
|
||||
final whole = await studysetsOf(studysetId, plan);
|
||||
final tagging = await _tags.read();
|
||||
final tagged = <String>{
|
||||
for (final name in plan.tags)
|
||||
if (tagging.named(name) case final tag?) ...tagging.cardsWith(tag.id),
|
||||
};
|
||||
return [
|
||||
for (final id in studiedIn(studysetId, all))
|
||||
for (final card in await _cards.cardsOf(id))
|
||||
if (whole.contains(id) || tagged.contains(card.id))
|
||||
(studysetId: id, card: card),
|
||||
];
|
||||
}
|
||||
|
||||
/// How far the person has come with what [plan] covers in [studysetId]:
|
||||
/// its cards by [reviews], its learning paths' steps by [pathProgress],
|
||||
/// its PDFs' pages by how far each was read.
|
||||
Future<ExamProgress> progressOf(
|
||||
String studysetId,
|
||||
ExamPlan plan, {
|
||||
required ReviewRepository reviews,
|
||||
required LearningPathRepository paths,
|
||||
required PathProgressRepository pathProgress,
|
||||
required PdfSourceRepository pdfs,
|
||||
}) async {
|
||||
final cards = [
|
||||
for (final (studysetId: _, :card) in await cardsOf(studysetId, plan))
|
||||
card,
|
||||
];
|
||||
final schedules = await reviews.schedulesOf([
|
||||
for (final card in cards) card.id,
|
||||
]);
|
||||
final newCards = cards
|
||||
.where((card) => schedules[card.id]?.isNew ?? true)
|
||||
.length;
|
||||
final startOfToday = startOfLocalDay(clock.now());
|
||||
final startedToday = cards
|
||||
.where(
|
||||
(card) =>
|
||||
schedules[card.id]?.firstReviewedAt?.isBefore(startOfToday) ==
|
||||
false,
|
||||
)
|
||||
.length;
|
||||
var steps = 0;
|
||||
var stepsDone = 0;
|
||||
var pages = 0;
|
||||
var pagesRead = 0;
|
||||
for (final id in await studysetsOf(studysetId, plan)) {
|
||||
for (final path in await paths.pathsOf(id)) {
|
||||
final pathSteps = await paths.stepsOf(path.id);
|
||||
final progress = await pathProgress.of({
|
||||
for (final step in pathSteps) step.id: step.kind,
|
||||
});
|
||||
steps += pathSteps.length;
|
||||
stepsDone += pathSteps
|
||||
.where((step) => progress[step.id]?.done ?? false)
|
||||
.length;
|
||||
}
|
||||
final sources = await pdfs.sourcesOf(id);
|
||||
final furthest = await pdfs.furthestPages(
|
||||
pdfIds: {for (final source in sources) source.id},
|
||||
);
|
||||
for (final source in sources) {
|
||||
final count = source.content.pageCount ?? 0;
|
||||
pages += count;
|
||||
pagesRead += (furthest[source.id] ?? 0).clamp(0, count);
|
||||
}
|
||||
}
|
||||
return (
|
||||
cards: cards.length,
|
||||
newCards: newCards,
|
||||
startedToday: startedToday,
|
||||
steps: steps,
|
||||
stepsDone: stepsDone,
|
||||
pages: pages,
|
||||
pagesRead: pagesRead,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
// A studyset's content as its synced row stores it (see core/content/payload).
|
||||
|
|
@ -19,10 +20,14 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
// copy's studyset came from (`published`, see published_ref.dart, with the
|
||||
// published studyset's `location` on the copy's top studyset), left out for a
|
||||
// studyset of its own. Schema 6 gave a source of the bibliography its kind
|
||||
// (`kind`: `website` or `video`, left out for a book or an article).
|
||||
// (`kind`: `website` or `video`, left out for a book or an article). Schema 7
|
||||
// added the exams it plans for (`examPlans`: id, name, the topics it covers
|
||||
// by their ids, the tags by their names; left out when none) and the one the
|
||||
// person prepares for (`exam`: the plan's id and the exam's day,
|
||||
// `YYYY-MM-DD`; theirs alone, never published).
|
||||
|
||||
/// The schema this app writes.
|
||||
const studysetSchemaVersion = 6;
|
||||
const studysetSchemaVersion = 7;
|
||||
|
||||
/// Encodes a studyset's content into its payload.
|
||||
ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
|
||||
|
|
@ -52,6 +57,9 @@ ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
|
|||
...publishedRefToJson(published),
|
||||
'location': ?content.publishedAt,
|
||||
},
|
||||
if (content.examPlans.isNotEmpty)
|
||||
'examPlans': [for (final plan in content.examPlans) plan.toJson()],
|
||||
if (content.exam case final exam?) 'exam': exam.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -101,6 +109,12 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
|
|||
{'location': final String location} => location,
|
||||
_ => null,
|
||||
},
|
||||
examPlans: [
|
||||
for (final plan
|
||||
in _optional<List<Object?>>(json, 'examPlans') ?? const [])
|
||||
?ExamPlan.fromJson(plan),
|
||||
],
|
||||
exam: ExamChoice.fromJson(json['exam']),
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a studyset payload.');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What an exam covers, as a studyset plans for it (decided 2026-10-14): a
|
||||
/// [name] ("JLPT N5"), the [topics] it takes whole (studysets inside the
|
||||
/// one planning, with their cards, learning paths and PDFs) and the [tags]
|
||||
/// of cards it takes from anywhere in it. Its author writes it, and it is
|
||||
/// published with the studyset; plans are also offered from the studyset's
|
||||
/// level tags ([automaticExamPlans]).
|
||||
final class const ExamPlan({
|
||||
/// Its id among the studyset's plans; `auto:…` for one offered from
|
||||
/// level tags.
|
||||
required final String id,
|
||||
required final String name,
|
||||
|
||||
/// The studysets it covers whole, by their ids.
|
||||
final List<String> topics = const [],
|
||||
|
||||
/// The tags whose cards it covers, by their names.
|
||||
final List<String> tags = const [],
|
||||
}) with Equatable {
|
||||
/// Whether the app offers it from level tags rather than its author.
|
||||
bool get automatic => id.startsWith(_automatic);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (topics.isNotEmpty) 'topics': topics,
|
||||
if (tags.isNotEmpty) 'tags': tags,
|
||||
};
|
||||
|
||||
/// The plan [json] holds; null for one that isn't.
|
||||
static ExamPlan? fromJson(Object? json) => switch (json) {
|
||||
{'id': final String id, 'name': final String name} => ExamPlan(
|
||||
id: id,
|
||||
name: name,
|
||||
topics: _strings(json['topics']),
|
||||
tags: _strings(json['tags']),
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, topics, tags];
|
||||
}
|
||||
|
||||
/// The exam a person prepares for in a studyset, theirs alone (never
|
||||
/// published, as a PDF's reading deadline): the [plan] by its id and the
|
||||
/// exam's [date], a day of the person's calendar.
|
||||
final class const ExamChoice({
|
||||
required final String plan,
|
||||
|
||||
/// The exam's day, at midnight, local.
|
||||
required final DateTime date,
|
||||
}) with Equatable {
|
||||
Map<String, Object?> toJson() => {
|
||||
'plan': plan,
|
||||
'date':
|
||||
'${date.year.toString().padLeft(4, '0')}-'
|
||||
'${date.month.toString().padLeft(2, '0')}-'
|
||||
'${date.day.toString().padLeft(2, '0')}',
|
||||
};
|
||||
|
||||
/// The choice [json] holds; null for one that isn't.
|
||||
static ExamChoice? fromJson(Object? json) => switch (json) {
|
||||
{'plan': final String plan, 'date': final String date} =>
|
||||
switch (DateTime.tryParse(date)) {
|
||||
final day? => ExamChoice(
|
||||
plan: plan,
|
||||
date: DateTime(day.year, day.month, day.day),
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [plan, date];
|
||||
}
|
||||
|
||||
const _automatic = 'auto:';
|
||||
|
||||
/// The levels exams test, each with the tags its cards carry, easiest
|
||||
/// first: JLPT's (N5 to N1), HSK's (1 to 7–9) and KRDict's (초급 to 고급).
|
||||
const _examLevels = [
|
||||
(
|
||||
prefix: 'jlpt',
|
||||
levels: [
|
||||
('N5', 'JLPT-N5'),
|
||||
('N4', 'JLPT-N4'),
|
||||
('N3', 'JLPT-N3'),
|
||||
('N2', 'JLPT-N2'),
|
||||
('N1', 'JLPT-N1'),
|
||||
],
|
||||
exam: 'JLPT',
|
||||
),
|
||||
(
|
||||
prefix: 'hsk',
|
||||
levels: [
|
||||
('1', 'HSK-1'),
|
||||
('2', 'HSK-2'),
|
||||
('3', 'HSK-3'),
|
||||
('4', 'HSK-4'),
|
||||
('5', 'HSK-5'),
|
||||
('6', 'HSK-6'),
|
||||
('7–9', 'HSK-7-9'),
|
||||
],
|
||||
exam: 'HSK',
|
||||
),
|
||||
(
|
||||
prefix: 'topik',
|
||||
levels: [('초급', '초급'), ('중급', '중급'), ('고급', '고급')],
|
||||
exam: 'Korean',
|
||||
),
|
||||
];
|
||||
|
||||
/// The plans offered from the level tags a studyset's cards carry
|
||||
/// ([tagNames], whatever their case): one for each level an exam tests,
|
||||
/// taking the levels below it too, as an exam does (JLPT N4 takes N5's
|
||||
/// words), up to the highest level present.
|
||||
List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
|
||||
final present = {for (final name in tagNames) name.toLowerCase()};
|
||||
return [
|
||||
for (final (:prefix, :levels, :exam) in _examLevels)
|
||||
for (final (index, (level, _)) in levels.indexed)
|
||||
if (levels
|
||||
.skip(index)
|
||||
.any((each) => present.contains(each.$2.toLowerCase())))
|
||||
ExamPlan(
|
||||
id: '$_automatic$prefix:${index + 1}',
|
||||
name: '$exam $level',
|
||||
tags: [
|
||||
for (final (_, tag) in levels.take(index + 1))
|
||||
if (present.contains(tag.toLowerCase())) tag,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// How far a person has come with what an exam covers, and the pace that
|
||||
/// gets them through it by the exam: the days left, the new cards to learn
|
||||
/// a day, the steps of learning paths to work through a week and the pages
|
||||
/// to read a day, so that what is new is learnt a week before the exam (or
|
||||
/// by it, closer than that), leaving the last week for reviews.
|
||||
typedef ExamPace = ({
|
||||
int daysLeft,
|
||||
int newPerDay,
|
||||
int stepsPerWeek,
|
||||
int pagesPerDay,
|
||||
});
|
||||
|
||||
/// The pace from [today] to the exam on [examDate] for [newCards] cards
|
||||
/// never studied, [stepsLeft] steps not done and [pagesLeft] pages not
|
||||
/// read (see [ExamPace]).
|
||||
ExamPace examPace({
|
||||
required DateTime today,
|
||||
required DateTime examDate,
|
||||
required int newCards,
|
||||
required int stepsLeft,
|
||||
required int pagesLeft,
|
||||
}) {
|
||||
final start = DateTime(today.year, today.month, today.day);
|
||||
final end = DateTime(examDate.year, examDate.month, examDate.day);
|
||||
// Whole days between, as the calendar counts them, summer time or not.
|
||||
final daysLeft = math.max(
|
||||
0,
|
||||
(end.difference(start).inHours / 24).round(),
|
||||
);
|
||||
final learning = math.max(1, daysLeft > 14 ? daysLeft - 7 : daysLeft);
|
||||
int perDay(int left) => left <= 0 ? 0 : (left / learning).ceil();
|
||||
return (
|
||||
daysLeft: daysLeft,
|
||||
newPerDay: perDay(newCards),
|
||||
stepsPerWeek: stepsLeft <= 0
|
||||
? 0
|
||||
: (stepsLeft / math.max(1, learning / 7)).ceil(),
|
||||
pagesPerDay: perDay(pagesLeft),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _strings(Object? json) => [
|
||||
if (json is List)
|
||||
for (final item in json)
|
||||
if (item is String) item,
|
||||
];
|
||||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
|
||||
/// What a person writes about a studyset: everything its synced payload
|
||||
/// holds. Where it sits and whether it is starred or archived are columns of
|
||||
|
|
@ -55,6 +56,13 @@ final class const StudysetContent({
|
|||
/// (`halkyon.eu/@raisondetredev/japanese`), on the copy's top studyset;
|
||||
/// null on the others and on one of the person's own.
|
||||
final String? publishedAt,
|
||||
|
||||
/// The exams it plans for, as its author wrote them (published with it).
|
||||
final List<ExamPlan> examPlans = const [],
|
||||
|
||||
/// The exam the person prepares for and its date, theirs alone (never
|
||||
/// published); null without one.
|
||||
final ExamChoice? exam,
|
||||
}) with Equatable {
|
||||
/// Content with only a [title] and an empty description, as a new
|
||||
/// studyset starts.
|
||||
|
|
@ -76,6 +84,8 @@ final class const StudysetContent({
|
|||
bibliography,
|
||||
published,
|
||||
publishedAt,
|
||||
examPlans,
|
||||
exam,
|
||||
];
|
||||
|
||||
/// This content with a new [title] or [description]. The other fields can
|
||||
|
|
@ -95,6 +105,8 @@ final class const StudysetContent({
|
|||
bibliography: bibliography,
|
||||
published: published,
|
||||
publishedAt: publishedAt,
|
||||
examPlans: examPlans,
|
||||
exam: exam,
|
||||
);
|
||||
|
||||
/// This content studied with [options]; null for the defaults.
|
||||
|
|
@ -113,6 +125,8 @@ final class const StudysetContent({
|
|||
bibliography: bibliography,
|
||||
published: published,
|
||||
publishedAt: publishedAt,
|
||||
examPlans: examPlans,
|
||||
exam: exam,
|
||||
);
|
||||
|
||||
/// This content citing [bibliography].
|
||||
|
|
@ -131,5 +145,45 @@ final class const StudysetContent({
|
|||
bibliography: bibliography,
|
||||
published: published,
|
||||
publishedAt: publishedAt,
|
||||
examPlans: examPlans,
|
||||
exam: exam,
|
||||
);
|
||||
|
||||
/// This content planning for the exams [plans].
|
||||
StudysetContent withExamPlans(List<ExamPlan> plans) => StudysetContent(
|
||||
title: title,
|
||||
description: description,
|
||||
secondaryTitle: secondaryTitle,
|
||||
colorName: colorName,
|
||||
license: license,
|
||||
licenseHolder: licenseHolder,
|
||||
licenseYear: licenseYear,
|
||||
frontLanguage: frontLanguage,
|
||||
backLanguage: backLanguage,
|
||||
studyOptions: studyOptions,
|
||||
bibliography: bibliography,
|
||||
published: published,
|
||||
publishedAt: publishedAt,
|
||||
examPlans: plans,
|
||||
exam: exam,
|
||||
);
|
||||
|
||||
/// This content with the person preparing for [exam]; null for none.
|
||||
StudysetContent withExam(ExamChoice? exam) => StudysetContent(
|
||||
title: title,
|
||||
description: description,
|
||||
secondaryTitle: secondaryTitle,
|
||||
colorName: colorName,
|
||||
license: license,
|
||||
licenseHolder: licenseHolder,
|
||||
licenseYear: licenseYear,
|
||||
frontLanguage: frontLanguage,
|
||||
backLanguage: backLanguage,
|
||||
studyOptions: studyOptions,
|
||||
bibliography: bibliography,
|
||||
published: published,
|
||||
publishedAt: publishedAt,
|
||||
examPlans: examPlans,
|
||||
exam: exam,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,550 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The exam the person prepares for in the studyset [studysetId], at the
|
||||
/// top of its study plan (decided 2026-10-14). Without one, a row to
|
||||
/// prepare for one; with one, its name and how many days are left, how far
|
||||
/// the person has come with what it covers (flashcards started, steps
|
||||
/// done, pages read), the pace that gets them through by the exam, and a
|
||||
/// button to study for it: what it covers alone, its new cards at that
|
||||
/// pace.
|
||||
class const ExamSection({required final String studysetId, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final state = context.watch<ExamCubit>().state;
|
||||
if (state.plans == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
void change() => _chooseExam(context);
|
||||
final choice = state.choice;
|
||||
if (choice == null) {
|
||||
return ListTile(
|
||||
tileColor: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
leading: Icon(LucideIcons.graduationCap, color: colors.primary),
|
||||
title: Text(l10n.examPrepare),
|
||||
subtitle: Text(l10n.examPrepareHint),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: change,
|
||||
);
|
||||
}
|
||||
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final plan = state.plan;
|
||||
final progress = state.progress;
|
||||
final today = clock.now();
|
||||
final date = MaterialLocalizations.of(context).formatMediumDate(
|
||||
choice.date,
|
||||
);
|
||||
final pace = progress == null
|
||||
? null
|
||||
: examPace(
|
||||
today: today,
|
||||
examDate: choice.date,
|
||||
newCards: progress.newCards + progress.startedToday,
|
||||
stepsLeft: progress.steps - progress.stepsDone,
|
||||
pagesLeft: progress.pages - progress.pagesRead,
|
||||
);
|
||||
final past = choice.date.isBefore(
|
||||
DateTime(today.year, today.month, today.day),
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 4, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.graduationCap, color: colors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
plan?.name ?? l10n.examPlanGone,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
past
|
||||
? l10n.examPast(date)
|
||||
: l10n.examDaysLeft(pace?.daysLeft ?? 0, date),
|
||||
style: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
tooltip: l10n.examChange,
|
||||
onPressed: change,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (plan != null && progress != null && pace != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_Bar(
|
||||
done: progress.cards - progress.newCards,
|
||||
total: progress.cards,
|
||||
label: l10n.examCardsStarted(
|
||||
progress.cards - progress.newCards,
|
||||
progress.cards,
|
||||
),
|
||||
),
|
||||
if (progress.steps > 0)
|
||||
_Bar(
|
||||
done: progress.stepsDone,
|
||||
total: progress.steps,
|
||||
label: l10n.planStepsDone(
|
||||
progress.stepsDone,
|
||||
progress.steps,
|
||||
),
|
||||
),
|
||||
if (progress.pages > 0)
|
||||
_Bar(
|
||||
done: progress.pagesRead,
|
||||
total: progress.pages,
|
||||
label: l10n.examPagesRead(
|
||||
progress.pagesRead,
|
||||
progress.pages,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.examPaceTitle, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
switch ([
|
||||
if (pace.newPerDay > 0)
|
||||
l10n.examPaceNew(pace.newPerDay),
|
||||
if (pace.stepsPerWeek > 0)
|
||||
l10n.examPaceSteps(pace.stepsPerWeek),
|
||||
if (pace.pagesPerDay > 0)
|
||||
l10n.examPacePages(pace.pagesPerDay),
|
||||
]) {
|
||||
final parts when parts.isEmpty => l10n.examPaceDone,
|
||||
final parts => parts.join(' · '),
|
||||
},
|
||||
style: muted,
|
||||
),
|
||||
if (progress.cards > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonalIcon(
|
||||
icon: const Icon(LucideIcons.layers),
|
||||
label: Text(l10n.examStudy),
|
||||
onPressed: () => context.push(
|
||||
RoutePaths.study(
|
||||
studysetId,
|
||||
query: const StudyFocus(exam: true).toQuery(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far something has come: a bar, then [label].
|
||||
class const _Bar({
|
||||
required final int done,
|
||||
required final int total,
|
||||
required final String label,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: total == 0 ? 0 : done / total,
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks which exam to prepare for and when it is, and lets the studyset's
|
||||
/// own plans be made, changed and deleted on the way.
|
||||
Future<void> _chooseExam(BuildContext context) {
|
||||
final cubit = context.read<ExamCubit>();
|
||||
return showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) =>
|
||||
BlocProvider.value(value: cubit, child: const _ExamSheet()),
|
||||
);
|
||||
}
|
||||
|
||||
class const _ExamSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_ExamSheet> createState() => _ExamSheetState();
|
||||
}
|
||||
|
||||
class _ExamSheetState() extends State<_ExamSheet> {
|
||||
late final _today = clock.now();
|
||||
late final ExamChoice? _current = context.read<ExamCubit>().state.choice;
|
||||
late String? _planId =
|
||||
_current?.plan ?? context.read<ExamCubit>().state.plans?.firstOrNull?.id;
|
||||
|
||||
/// Twelve weeks from today unless chosen: an exam is months off, as a
|
||||
/// rule, rather than days.
|
||||
late DateTime _date =
|
||||
_current?.date ??
|
||||
DateTime(_today.year, _today.month, _today.day + 7 * 12);
|
||||
|
||||
Future<void> _edit([ExamPlan? plan]) async {
|
||||
final cubit = context.read<ExamCubit>();
|
||||
final saved = await showAdaptiveSheet<String>(
|
||||
context,
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: cubit,
|
||||
child: _PlanEditor(plan: plan),
|
||||
),
|
||||
);
|
||||
if (saved != null && mounted) {
|
||||
setState(() => _planId = saved);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final cubit = context.watch<ExamCubit>();
|
||||
final plans = cubit.state.plans ?? const <ExamPlan>[];
|
||||
final planId = plans.any((plan) => plan.id == _planId) ? _planId : null;
|
||||
final heading = theme.textTheme.labelLarge?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final muted = theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
Widget option(ExamPlan plan) => RadioListTile<String>(
|
||||
value: plan.id,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(plan.name),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (plan.topics.isNotEmpty)
|
||||
l10n.examPlanTopicCount(plan.topics.length),
|
||||
...plan.tags,
|
||||
].join(' · '),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
secondary: plan.automatic
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
tooltip: l10n.examPlanEdit,
|
||||
onPressed: () => _edit(plan),
|
||||
),
|
||||
);
|
||||
final authored = [
|
||||
for (final plan in plans)
|
||||
if (!plan.automatic) plan,
|
||||
];
|
||||
final automatic = [
|
||||
for (final plan in plans)
|
||||
if (plan.automatic) plan,
|
||||
];
|
||||
return SheetScaffold(
|
||||
title: l10n.examPrepare,
|
||||
footer: Row(
|
||||
children: [
|
||||
if (_current != null)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(foregroundColor: colors.error),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await cubit.choose(null, null);
|
||||
},
|
||||
child: Text(l10n.examRemove),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: planId == null
|
||||
? null
|
||||
: () async {
|
||||
Navigator.of(context).pop();
|
||||
await cubit.choose(planId, _date);
|
||||
},
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.examSheetHelp, style: muted),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.calendarCheck),
|
||||
title: Text(l10n.examDate),
|
||||
subtitle: Text(
|
||||
MaterialLocalizations.of(context).formatMediumDate(_date),
|
||||
),
|
||||
onTap: () async {
|
||||
final first = DateTime(_today.year, _today.month, _today.day);
|
||||
if (await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date.isBefore(first) ? first : _date,
|
||||
firstDate: first,
|
||||
lastDate: DateTime(
|
||||
_today.year + 5,
|
||||
_today.month,
|
||||
_today.day,
|
||||
),
|
||||
)
|
||||
case final picked?) {
|
||||
setState(() => _date = picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
RadioGroup<String>(
|
||||
groupValue: planId,
|
||||
onChanged: (id) => setState(() => _planId = id),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (plans.isEmpty)
|
||||
Text(l10n.examSheetNoPlans, style: muted)
|
||||
else ...[
|
||||
if (authored.isNotEmpty) ...[
|
||||
Text(l10n.examSheetPlans, style: heading),
|
||||
for (final plan in authored) option(plan),
|
||||
],
|
||||
if (automatic.isNotEmpty) ...[
|
||||
if (authored.isNotEmpty) const SizedBox(height: 12),
|
||||
Text(l10n.examSheetFromTags, style: heading),
|
||||
for (final plan in automatic) option(plan),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.examPlanNew),
|
||||
onPressed: _edit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes a plan of the studyset's own, or changes or deletes [plan]:
|
||||
/// its name, the topics it takes whole and the tags whose flashcards it
|
||||
/// takes. Completes with the id of the plan saved; null otherwise.
|
||||
class const _PlanEditor({required final ExamPlan? plan})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_PlanEditor> createState() => _PlanEditorState();
|
||||
}
|
||||
|
||||
class _PlanEditorState() extends State<_PlanEditor> {
|
||||
late final _name = TextEditingController(text: widget.plan?.name);
|
||||
late final _topics = {...?widget.plan?.topics};
|
||||
late final _tags = {...?widget.plan?.tags};
|
||||
late final Future<
|
||||
({List<({Studyset studyset, int depth})> topics, List<String> tags})
|
||||
>
|
||||
_coverable = context.read<ExamCubit>().coverable();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final heading = theme.textTheme.labelLarge?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final muted = theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final complete =
|
||||
_name.text.trim().isNotEmpty &&
|
||||
(_topics.isNotEmpty || _tags.isNotEmpty);
|
||||
return SheetScaffold(
|
||||
title: widget.plan == null ? l10n.examPlanNew : l10n.examPlanEdit,
|
||||
footer: Row(
|
||||
children: [
|
||||
if (widget.plan case final plan?)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(foregroundColor: colors.error),
|
||||
onPressed: () async {
|
||||
final cubit = context.read<ExamCubit>();
|
||||
Navigator.of(context).pop();
|
||||
await cubit.deletePlan(plan.id);
|
||||
},
|
||||
child: Text(l10n.examPlanDelete),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: !complete
|
||||
? null
|
||||
: () async {
|
||||
final cubit = context.read<ExamCubit>();
|
||||
final plan = ExamPlan(
|
||||
id: widget.plan?.id ?? _newPlanId(),
|
||||
name: _name.text.trim(),
|
||||
topics: [..._topics],
|
||||
tags: [..._tags],
|
||||
);
|
||||
Navigator.of(context).pop(plan.id);
|
||||
await cubit.savePlan(plan);
|
||||
},
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.examPlanHelp, style: muted),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _name,
|
||||
autofocus: widget.plan == null,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.examPlanName,
|
||||
hintText: l10n.examPlanNameHint,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
future: _coverable,
|
||||
builder: (context, snapshot) {
|
||||
final coverable = snapshot.data;
|
||||
if (coverable == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (coverable.topics.isEmpty && coverable.tags.isEmpty) {
|
||||
return Text(l10n.examPlanEmpty, style: muted);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (coverable.topics.isNotEmpty) ...[
|
||||
Text(l10n.examPlanTopics, style: heading),
|
||||
for (final (:studyset, :depth) in coverable.topics)
|
||||
CheckboxListTile(
|
||||
value: _topics.contains(studyset.id),
|
||||
dense: true,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsetsDirectional.only(
|
||||
start: 16.0 * math.max(0, depth - 1),
|
||||
),
|
||||
title: Text(
|
||||
studyset.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onChanged: (on) => setState(
|
||||
() => on ?? false
|
||||
? _topics.add(studyset.id)
|
||||
: _topics.remove(studyset.id),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (coverable.tags.isNotEmpty) ...[
|
||||
Text(l10n.examPlanTags, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in coverable.tags)
|
||||
FilterChip(
|
||||
label: Text(tag),
|
||||
selected: _tags.contains(tag),
|
||||
onSelected: (on) => setState(
|
||||
() => on ? _tags.add(tag) : _tags.remove(tag),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new id for a plan among the studyset's own: unlikely to meet another,
|
||||
/// and never `auto:…`.
|
||||
String _newPlanId() =>
|
||||
'e${clock.now().microsecondsSinceEpoch.toRadixString(36)}'
|
||||
'${math.Random().nextInt(1 << 30).toRadixString(36)}';
|
||||
|
|
@ -35,6 +35,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activit
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/exam_section.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -980,12 +981,13 @@ class const _Sparkline({
|
|||
oldDelegate.color != color || !listEquals(oldDelegate.values, values);
|
||||
}
|
||||
|
||||
/// The studyset [studysetId]'s plan (decided 2026-10-13): how many of its
|
||||
/// The studyset [studysetId]'s plan (decided 2026-10-13): the exam the
|
||||
/// person prepares for in it ([ExamSection]), how many of its
|
||||
/// learning paths are finished, what to do next (study the cards due, the
|
||||
/// next step of each path under way, or of the first path not finished
|
||||
/// when none is), and how far each path has come, each opening the path.
|
||||
/// The paths themselves, to add, reorder and manage, are material; the
|
||||
/// plan only refers to them, as it will to an exam's date.
|
||||
/// plan only refers to them.
|
||||
class const PlanPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
|
@ -1045,6 +1047,10 @@ class const PlanPart({
|
|||
),
|
||||
];
|
||||
return _pullToSync(context, scroll, [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
sliver: SliverToBoxAdapter(child: ExamSection(studysetId: studysetId)),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
|
|
@ -125,6 +126,19 @@ class const StudysetScreen({
|
|||
),
|
||||
// The content tab to show once material was added.
|
||||
BlocProvider(create: (context) => ContentTabCubit()),
|
||||
// The exams it plans for, read once the study plan shows.
|
||||
BlocProvider(
|
||||
create: (context) => ExamCubit(
|
||||
studysets: person.studysets,
|
||||
cards: person.cards,
|
||||
tags: person.tags,
|
||||
reviews: person.reviews,
|
||||
paths: person.paths,
|
||||
pathProgress: person.pathProgress,
|
||||
pdfs: person.pdfSources,
|
||||
studysetId: studyset.id,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
|
|||
bibliography: initial?.bibliography ?? const [],
|
||||
published: initial?.published,
|
||||
publishedAt: initial?.publishedAt,
|
||||
examPlans: initial?.examPlans ?? const [],
|
||||
exam: initial?.exam,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
|
|
@ -45,11 +46,13 @@ final bookHash = sha256.convert(book).toString();
|
|||
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
|
||||
|
||||
/// Release [version] of a Japanese studyset, described as [description],
|
||||
/// with [cards] (by id: front, back and tags) in it, a Kanji studyset in it
|
||||
/// holding [kanjiCards], and a PDF titled [bookTitle].
|
||||
/// planning for [examPlans], with [cards] (by id: front, back and tags) in
|
||||
/// it, a Kanji studyset in it holding [kanjiCards], and a PDF titled
|
||||
/// [bookTitle].
|
||||
PublishedRelease japanese(
|
||||
int version, {
|
||||
String description = 'From N5 on.',
|
||||
List<ExamPlan> examPlans = const [],
|
||||
Map<String, (String, String, List<String>)> cards = const {},
|
||||
Map<String, (String, String, List<String>)> kanjiCards = const {},
|
||||
bool withKanji = true,
|
||||
|
|
@ -84,6 +87,7 @@ PublishedRelease japanese(
|
|||
title: 'Japanese',
|
||||
description: RichContent.plain(description),
|
||||
license: 'CC-BY-SA-4.0',
|
||||
examPlans: examPlans,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -325,6 +329,35 @@ void main() {
|
|||
expect((await cards.cardsOf(kanji.id)).single.content.front.plainText, '一');
|
||||
});
|
||||
|
||||
test("exam plans the release brings name the copy's studysets; the exam "
|
||||
'the learner prepares for stays theirs', () async {
|
||||
final v1 = japanese(1);
|
||||
final root = await copyOf(v1);
|
||||
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
|
||||
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
|
||||
await studysets.updateContent(root, copy.content.withExam(exam));
|
||||
|
||||
await updater.take(
|
||||
root,
|
||||
v1,
|
||||
japanese(
|
||||
2,
|
||||
examPlans: const [
|
||||
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: ['kanji']),
|
||||
],
|
||||
),
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final all = await studysets.all();
|
||||
final now = all.singleWhere((s) => s.id == root);
|
||||
final kanji = all.singleWhere((s) => s.parentId == root);
|
||||
expect(now.content.examPlans, [
|
||||
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: [kanji.id]),
|
||||
]);
|
||||
expect(now.content.exam, exam);
|
||||
});
|
||||
|
||||
test("a studyset's or PDF's fields the learner changed stay theirs; the "
|
||||
"copy says it holds the newer release; files it has aren't fetched "
|
||||
'again', () async {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
|
|
@ -224,6 +225,54 @@ void main() {
|
|||
expect(progress.last, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
"its exam plans come along, their topics the copy's own studysets",
|
||||
() async {
|
||||
final release = japanese();
|
||||
final withPlans = PublishedRelease(
|
||||
publicId: release.publicId,
|
||||
version: release.version,
|
||||
items: {
|
||||
...release.items,
|
||||
'studyset.json': item(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent.titled('Japanese').withExamPlans(const [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji test',
|
||||
topics: ['kanji'],
|
||||
tags: ['JLPT-N5'],
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
},
|
||||
files: release.files,
|
||||
);
|
||||
|
||||
final id = await copier.copy(
|
||||
withPlans,
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
onProgress: (_) {},
|
||||
);
|
||||
|
||||
final all = await studysets.all();
|
||||
final root = all.singleWhere((studyset) => studyset.id == id);
|
||||
final kanji = all.singleWhere((studyset) => studyset.parentId == id);
|
||||
expect(root.content.examPlans, [
|
||||
ExamPlan(
|
||||
id: 'kanji-test',
|
||||
name: 'Kanji test',
|
||||
topics: [kanji.id],
|
||||
tags: const ['JLPT-N5'],
|
||||
),
|
||||
]);
|
||||
expect(root.content.exam, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test("cards come in their order with their tags by name, the person's "
|
||||
'own tag of a name taken', () async {
|
||||
final noun = await tags.create('noun');
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue