feat(app): learning paths in the studyset screen: worked through and written
- A Paths tab before Flashcards and Notes: each path with its title and secondary title, dimmed once completed; Edit, Reset progress (with an undo) and Delete (with an undo) behind its options. The studyset opens on its paths while one is unfinished, on its flashcards otherwise. - "Create a path" in Add material asks for the titles in a sheet and opens the new path to write; "Import a path (JSON)" reads a file in the import format, says what came in and how many blocks were left out (images and animations: the app never fetches from the web). - The player: one step at a time at reading width, a bar of the steps above, Back and "Check answers" / "Continue" / "Finish" below. It opens at the first unfinished step; an exercise passes only once answered right, as often as it takes; after a check, only the learner's own picks are marked, with the author's feedback; sources behind a chip; a finish screen leading to the next path. Words and items are dragged into place, or tapped into place for phones. - Writing: the path's screen (titles, description, tags, steps added by kind, reordered, deleted with an undo) and the step's (title, text with the format bar, its kind's exercise, feedback for right and wrong in the whole window, sources). The step saves on what it started from, as notes do, read-only while devices disagree until a version is kept. - The library and the topics count paths; steps done count on the dashboard, for the streak and the activity. - The rich-text screen for a studyset's description moves to core, for the steps' feedback too, and gains the format bar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
91f6376b93
commit
e4bfe3bdeb
36 changed files with 6310 additions and 65 deletions
|
|
@ -64,6 +64,118 @@
|
|||
"studysetMenuMove": "Move to…",
|
||||
"studysetTabFlashcards": "Flashcards",
|
||||
"studysetTabNotes": "Notes",
|
||||
"studysetTabPaths": "Paths",
|
||||
"studysetCreatePath": "Create a path",
|
||||
"pathImportMenu": "Import a path (JSON)",
|
||||
"pathImportFileType": "Learning path (JSON)",
|
||||
"pathImported": "{count, plural, =1{Imported \"{title}\" with 1 step.} other{Imported \"{title}\" with {count} steps.}}",
|
||||
"@pathImported": {"placeholders": {"title": {"type": "String"}, "count": {"type": "int"}}},
|
||||
"pathImportSkipped": "{count, plural, =1{1 block was left out: images and animations aren't imported.} other{{count} blocks were left out: images and animations aren't imported.}}",
|
||||
"@pathImportSkipped": {"placeholders": {"count": {"type": "int"}}},
|
||||
"pathImportFailed": "Couldn't import this file. {reason}",
|
||||
"@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}},
|
||||
"pathImportUnreadable": "It isn't text in UTF-8.",
|
||||
"pathsEmpty": "No paths yet",
|
||||
"pathsEmptyHint": "A path leads through a lesson, step by step.",
|
||||
"pathOptions": "Path options",
|
||||
"pathEdit": "Edit",
|
||||
"pathResetProgress": "Reset progress",
|
||||
"pathProgressReset": "Progress reset",
|
||||
"pathDelete": "Delete",
|
||||
"pathDeleteTitle": "Delete path?",
|
||||
"pathDeleteMessage": "This path and its steps will be removed from the studyset. You can undo this for a few seconds.",
|
||||
"pathDeleteConfirm": "Delete",
|
||||
"pathDeleted": "Path deleted",
|
||||
"pathCreateTitle": "New path",
|
||||
"pathCreate": "Create",
|
||||
"pathTitleLabel": "Title",
|
||||
"pathTitleRequired": "A path needs a title.",
|
||||
"pathSecondaryTitleLabel": "Secondary title",
|
||||
"pathSecondaryTitleHint": "E.g. the title in the language you learn",
|
||||
"pathEditTitle": "Edit path",
|
||||
"pathDescription": "Description",
|
||||
"pathAddDescription": "Add a description",
|
||||
"pathDescriptionEditTitle": "Path description",
|
||||
"pathAddTags": "Add tags",
|
||||
"pathTags": "Tags",
|
||||
"pathSteps": "Steps",
|
||||
"pathStepsEmpty": "No steps yet",
|
||||
"pathStepsEmptyHint": "Pick a step type to start writing the lesson.",
|
||||
"pathAddStep": "Add step",
|
||||
"pathReorder": "Reorder",
|
||||
"pathReorderDone": "Done",
|
||||
"pathStepDelete": "Delete step",
|
||||
"pathStepDeleted": "Step deleted",
|
||||
"stepUntitled": "Untitled step",
|
||||
"stepKindInfo": "Info",
|
||||
"stepKindSingleChoice": "Single choice",
|
||||
"stepKindMultipleChoice": "Multiple choice",
|
||||
"stepKindDragAndDrop": "Drag and drop",
|
||||
"stepKindFillInTheBlank": "Fill in the blank",
|
||||
"stepEditTitle": "Edit step",
|
||||
"stepTitleHint": "Title",
|
||||
"stepOptions": "Options",
|
||||
"stepOptionHint": "Option {number}",
|
||||
"@stepOptionHint": {"placeholders": {"number": {"type": "int"}}},
|
||||
"stepAddOption": "Add option",
|
||||
"stepRemoveOption": "Remove option",
|
||||
"stepCorrectOption": "The right one",
|
||||
"stepCorrectOptions": "Right ones",
|
||||
"stepCategories": "Categories",
|
||||
"stepCategoryHint": "Category {number}",
|
||||
"@stepCategoryHint": {"placeholders": {"number": {"type": "int"}}},
|
||||
"stepAddCategory": "Add category",
|
||||
"stepRemoveCategory": "Remove category",
|
||||
"stepItems": "Items",
|
||||
"stepItemHint": "Item {number}",
|
||||
"@stepItemHint": {"placeholders": {"number": {"type": "int"}}},
|
||||
"stepAddItem": "Add item",
|
||||
"stepRemoveItem": "Remove item",
|
||||
"stepItemCategory": "Belongs to",
|
||||
"stepPassage": "Passage",
|
||||
"stepPassageHint": "Type ___ (three underscores) wherever a word should be dropped in.",
|
||||
"stepBlankHint": "Word for gap {number}",
|
||||
"@stepBlankHint": {"placeholders": {"number": {"type": "int"}}},
|
||||
"stepIncomplete": "Not ready to answer yet: fill in every field and choose what is right.",
|
||||
"stepFeedbackRight": "Feedback after a right answer",
|
||||
"stepFeedbackWrong": "Feedback after a wrong answer",
|
||||
"stepFeedbackNone": "None",
|
||||
"stepSources": "Sources",
|
||||
"stepAddSource": "Add source",
|
||||
"stepRemoveSource": "Remove source",
|
||||
"stepSourceAuthors": "Authors",
|
||||
"stepSourceYear": "Year",
|
||||
"stepSourceTitle": "Title",
|
||||
"stepSourceUrl": "Link",
|
||||
"stepSourcesCount": "{count, plural, =1{1 source} other{{count} sources}}",
|
||||
"@stepSourcesCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"stepConflict": "This step was changed differently on another device. Keep one version to go on writing.",
|
||||
"stepFieldTitle": "Title",
|
||||
"stepFieldBody": "Text",
|
||||
"stepFieldExercise": "Exercise",
|
||||
"stepFieldRightFeedback": "Feedback after a right answer",
|
||||
"stepFieldWrongFeedback": "Feedback after a wrong answer",
|
||||
"stepFieldSources": "Sources",
|
||||
"runnerClose": "Close",
|
||||
"runnerEditStep": "Edit step",
|
||||
"runnerBack": "Back",
|
||||
"runnerCheck": "Check answers",
|
||||
"runnerContinue": "Continue",
|
||||
"runnerFinish": "Finish",
|
||||
"runnerRight": "Correct!",
|
||||
"runnerWrong": "Not quite",
|
||||
"runnerAlreadyRight": "You already answered this correctly.",
|
||||
"runnerSelectAll": "Select all that apply.",
|
||||
"runnerDragHint": "Drag each item into the category it belongs to, or tap it and then the category. Tap a placed item to take it back.",
|
||||
"runnerBlankHint": "Drag a word into each gap, or tap it and then the gap. Tap a placed word to take it back.",
|
||||
"runnerEmpty": "This path has no steps yet.",
|
||||
"runnerFinished": "You finished this path",
|
||||
"runnerFinishedSteps": "{count, plural, =1{1 step complete.} other{{count} steps complete.}}",
|
||||
"@runnerFinishedSteps": {"placeholders": {"count": {"type": "int"}}},
|
||||
"runnerNextPath": "Continue to next path",
|
||||
"runnerBackToStudyset": "Back to studyset",
|
||||
"studysetCountPaths": "{count, plural, =1{1 path} other{{count} paths}}",
|
||||
"@studysetCountPaths": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetCreateNote": "Create a note",
|
||||
"notesEmpty": "No notes yet",
|
||||
"notesEmptyHint": "Notes will appear here when you add them.",
|
||||
|
|
@ -126,7 +238,7 @@
|
|||
"studysetReadMore": "Read more",
|
||||
"studysetDescriptionTitle": "Description",
|
||||
"studysetDescriptionEditTitle": "Edit description",
|
||||
"studysetDescriptionSave": "Save",
|
||||
"richTextSave": "Save",
|
||||
"studysetSheetDescription": "Description",
|
||||
"studysetSheetDescriptionAdd": "Add a description",
|
||||
"studysetSheetInheritColor": "Same color as the studyset it is in",
|
||||
|
|
|
|||
|
|
@ -406,6 +406,618 @@ abstract class AppLocalizations {
|
|||
/// **'Notes'**
|
||||
String get studysetTabNotes;
|
||||
|
||||
/// No description provided for @studysetTabPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Paths'**
|
||||
String get studysetTabPaths;
|
||||
|
||||
/// No description provided for @studysetCreatePath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create a path'**
|
||||
String get studysetCreatePath;
|
||||
|
||||
/// No description provided for @pathImportMenu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import a path (JSON)'**
|
||||
String get pathImportMenu;
|
||||
|
||||
/// No description provided for @pathImportFileType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learning path (JSON)'**
|
||||
String get pathImportFileType;
|
||||
|
||||
/// No description provided for @pathImported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Imported \"{title}\" with 1 step.} other{Imported \"{title}\" with {count} steps.}}'**
|
||||
String pathImported(String title, int count);
|
||||
|
||||
/// No description provided for @pathImportSkipped.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 block was left out: images and animations aren\'t imported.} other{{count} blocks were left out: images and animations aren\'t imported.}}'**
|
||||
String pathImportSkipped(int count);
|
||||
|
||||
/// No description provided for @pathImportFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t import this file. {reason}'**
|
||||
String pathImportFailed(String reason);
|
||||
|
||||
/// No description provided for @pathImportUnreadable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It isn\'t text in UTF-8.'**
|
||||
String get pathImportUnreadable;
|
||||
|
||||
/// No description provided for @pathsEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No paths yet'**
|
||||
String get pathsEmpty;
|
||||
|
||||
/// No description provided for @pathsEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A path leads through a lesson, step by step.'**
|
||||
String get pathsEmptyHint;
|
||||
|
||||
/// No description provided for @pathOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Path options'**
|
||||
String get pathOptions;
|
||||
|
||||
/// No description provided for @pathEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit'**
|
||||
String get pathEdit;
|
||||
|
||||
/// No description provided for @pathResetProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset progress'**
|
||||
String get pathResetProgress;
|
||||
|
||||
/// No description provided for @pathProgressReset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress reset'**
|
||||
String get pathProgressReset;
|
||||
|
||||
/// No description provided for @pathDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get pathDelete;
|
||||
|
||||
/// No description provided for @pathDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete path?'**
|
||||
String get pathDeleteTitle;
|
||||
|
||||
/// No description provided for @pathDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This path and its steps will be removed from the studyset. You can undo this for a few seconds.'**
|
||||
String get pathDeleteMessage;
|
||||
|
||||
/// No description provided for @pathDeleteConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get pathDeleteConfirm;
|
||||
|
||||
/// No description provided for @pathDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Path deleted'**
|
||||
String get pathDeleted;
|
||||
|
||||
/// No description provided for @pathCreateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New path'**
|
||||
String get pathCreateTitle;
|
||||
|
||||
/// No description provided for @pathCreate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create'**
|
||||
String get pathCreate;
|
||||
|
||||
/// No description provided for @pathTitleLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get pathTitleLabel;
|
||||
|
||||
/// No description provided for @pathTitleRequired.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A path needs a title.'**
|
||||
String get pathTitleRequired;
|
||||
|
||||
/// No description provided for @pathSecondaryTitleLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Secondary title'**
|
||||
String get pathSecondaryTitleLabel;
|
||||
|
||||
/// No description provided for @pathSecondaryTitleHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'E.g. the title in the language you learn'**
|
||||
String get pathSecondaryTitleHint;
|
||||
|
||||
/// No description provided for @pathEditTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit path'**
|
||||
String get pathEditTitle;
|
||||
|
||||
/// No description provided for @pathDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Description'**
|
||||
String get pathDescription;
|
||||
|
||||
/// No description provided for @pathAddDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a description'**
|
||||
String get pathAddDescription;
|
||||
|
||||
/// No description provided for @pathDescriptionEditTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Path description'**
|
||||
String get pathDescriptionEditTitle;
|
||||
|
||||
/// No description provided for @pathAddTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add tags'**
|
||||
String get pathAddTags;
|
||||
|
||||
/// No description provided for @pathTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tags'**
|
||||
String get pathTags;
|
||||
|
||||
/// No description provided for @pathSteps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Steps'**
|
||||
String get pathSteps;
|
||||
|
||||
/// No description provided for @pathStepsEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No steps yet'**
|
||||
String get pathStepsEmpty;
|
||||
|
||||
/// No description provided for @pathStepsEmptyHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pick a step type to start writing the lesson.'**
|
||||
String get pathStepsEmptyHint;
|
||||
|
||||
/// No description provided for @pathAddStep.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add step'**
|
||||
String get pathAddStep;
|
||||
|
||||
/// No description provided for @pathReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reorder'**
|
||||
String get pathReorder;
|
||||
|
||||
/// No description provided for @pathReorderDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get pathReorderDone;
|
||||
|
||||
/// No description provided for @pathStepDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete step'**
|
||||
String get pathStepDelete;
|
||||
|
||||
/// No description provided for @pathStepDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Step deleted'**
|
||||
String get pathStepDeleted;
|
||||
|
||||
/// No description provided for @stepUntitled.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Untitled step'**
|
||||
String get stepUntitled;
|
||||
|
||||
/// No description provided for @stepKindInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Info'**
|
||||
String get stepKindInfo;
|
||||
|
||||
/// No description provided for @stepKindSingleChoice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Single choice'**
|
||||
String get stepKindSingleChoice;
|
||||
|
||||
/// No description provided for @stepKindMultipleChoice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Multiple choice'**
|
||||
String get stepKindMultipleChoice;
|
||||
|
||||
/// No description provided for @stepKindDragAndDrop.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag and drop'**
|
||||
String get stepKindDragAndDrop;
|
||||
|
||||
/// No description provided for @stepKindFillInTheBlank.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fill in the blank'**
|
||||
String get stepKindFillInTheBlank;
|
||||
|
||||
/// No description provided for @stepEditTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit step'**
|
||||
String get stepEditTitle;
|
||||
|
||||
/// No description provided for @stepTitleHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get stepTitleHint;
|
||||
|
||||
/// No description provided for @stepOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Options'**
|
||||
String get stepOptions;
|
||||
|
||||
/// No description provided for @stepOptionHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Option {number}'**
|
||||
String stepOptionHint(int number);
|
||||
|
||||
/// No description provided for @stepAddOption.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add option'**
|
||||
String get stepAddOption;
|
||||
|
||||
/// No description provided for @stepRemoveOption.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove option'**
|
||||
String get stepRemoveOption;
|
||||
|
||||
/// No description provided for @stepCorrectOption.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The right one'**
|
||||
String get stepCorrectOption;
|
||||
|
||||
/// No description provided for @stepCorrectOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Right ones'**
|
||||
String get stepCorrectOptions;
|
||||
|
||||
/// No description provided for @stepCategories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Categories'**
|
||||
String get stepCategories;
|
||||
|
||||
/// No description provided for @stepCategoryHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Category {number}'**
|
||||
String stepCategoryHint(int number);
|
||||
|
||||
/// No description provided for @stepAddCategory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add category'**
|
||||
String get stepAddCategory;
|
||||
|
||||
/// No description provided for @stepRemoveCategory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove category'**
|
||||
String get stepRemoveCategory;
|
||||
|
||||
/// No description provided for @stepItems.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Items'**
|
||||
String get stepItems;
|
||||
|
||||
/// No description provided for @stepItemHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Item {number}'**
|
||||
String stepItemHint(int number);
|
||||
|
||||
/// No description provided for @stepAddItem.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add item'**
|
||||
String get stepAddItem;
|
||||
|
||||
/// No description provided for @stepRemoveItem.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove item'**
|
||||
String get stepRemoveItem;
|
||||
|
||||
/// No description provided for @stepItemCategory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Belongs to'**
|
||||
String get stepItemCategory;
|
||||
|
||||
/// No description provided for @stepPassage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Passage'**
|
||||
String get stepPassage;
|
||||
|
||||
/// No description provided for @stepPassageHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Type ___ (three underscores) wherever a word should be dropped in.'**
|
||||
String get stepPassageHint;
|
||||
|
||||
/// No description provided for @stepBlankHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Word for gap {number}'**
|
||||
String stepBlankHint(int number);
|
||||
|
||||
/// No description provided for @stepIncomplete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not ready to answer yet: fill in every field and choose what is right.'**
|
||||
String get stepIncomplete;
|
||||
|
||||
/// No description provided for @stepFeedbackRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Feedback after a right answer'**
|
||||
String get stepFeedbackRight;
|
||||
|
||||
/// No description provided for @stepFeedbackWrong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Feedback after a wrong answer'**
|
||||
String get stepFeedbackWrong;
|
||||
|
||||
/// No description provided for @stepFeedbackNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get stepFeedbackNone;
|
||||
|
||||
/// No description provided for @stepSources.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sources'**
|
||||
String get stepSources;
|
||||
|
||||
/// No description provided for @stepAddSource.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add source'**
|
||||
String get stepAddSource;
|
||||
|
||||
/// No description provided for @stepRemoveSource.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove source'**
|
||||
String get stepRemoveSource;
|
||||
|
||||
/// No description provided for @stepSourceAuthors.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Authors'**
|
||||
String get stepSourceAuthors;
|
||||
|
||||
/// No description provided for @stepSourceYear.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Year'**
|
||||
String get stepSourceYear;
|
||||
|
||||
/// No description provided for @stepSourceTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get stepSourceTitle;
|
||||
|
||||
/// No description provided for @stepSourceUrl.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Link'**
|
||||
String get stepSourceUrl;
|
||||
|
||||
/// No description provided for @stepSourcesCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 source} other{{count} sources}}'**
|
||||
String stepSourcesCount(int count);
|
||||
|
||||
/// No description provided for @stepConflict.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This step was changed differently on another device. Keep one version to go on writing.'**
|
||||
String get stepConflict;
|
||||
|
||||
/// No description provided for @stepFieldTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get stepFieldTitle;
|
||||
|
||||
/// No description provided for @stepFieldBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Text'**
|
||||
String get stepFieldBody;
|
||||
|
||||
/// No description provided for @stepFieldExercise.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exercise'**
|
||||
String get stepFieldExercise;
|
||||
|
||||
/// No description provided for @stepFieldRightFeedback.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Feedback after a right answer'**
|
||||
String get stepFieldRightFeedback;
|
||||
|
||||
/// No description provided for @stepFieldWrongFeedback.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Feedback after a wrong answer'**
|
||||
String get stepFieldWrongFeedback;
|
||||
|
||||
/// No description provided for @stepFieldSources.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sources'**
|
||||
String get stepFieldSources;
|
||||
|
||||
/// No description provided for @runnerClose.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close'**
|
||||
String get runnerClose;
|
||||
|
||||
/// No description provided for @runnerEditStep.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit step'**
|
||||
String get runnerEditStep;
|
||||
|
||||
/// No description provided for @runnerBack.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Back'**
|
||||
String get runnerBack;
|
||||
|
||||
/// No description provided for @runnerCheck.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check answers'**
|
||||
String get runnerCheck;
|
||||
|
||||
/// No description provided for @runnerContinue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue'**
|
||||
String get runnerContinue;
|
||||
|
||||
/// No description provided for @runnerFinish.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Finish'**
|
||||
String get runnerFinish;
|
||||
|
||||
/// No description provided for @runnerRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Correct!'**
|
||||
String get runnerRight;
|
||||
|
||||
/// No description provided for @runnerWrong.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not quite'**
|
||||
String get runnerWrong;
|
||||
|
||||
/// No description provided for @runnerAlreadyRight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You already answered this correctly.'**
|
||||
String get runnerAlreadyRight;
|
||||
|
||||
/// No description provided for @runnerSelectAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select all that apply.'**
|
||||
String get runnerSelectAll;
|
||||
|
||||
/// No description provided for @runnerDragHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag each item into the category it belongs to, or tap it and then the category. Tap a placed item to take it back.'**
|
||||
String get runnerDragHint;
|
||||
|
||||
/// No description provided for @runnerBlankHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag a word into each gap, or tap it and then the gap. Tap a placed word to take it back.'**
|
||||
String get runnerBlankHint;
|
||||
|
||||
/// No description provided for @runnerEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This path has no steps yet.'**
|
||||
String get runnerEmpty;
|
||||
|
||||
/// No description provided for @runnerFinished.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You finished this path'**
|
||||
String get runnerFinished;
|
||||
|
||||
/// No description provided for @runnerFinishedSteps.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 step complete.} other{{count} steps complete.}}'**
|
||||
String runnerFinishedSteps(int count);
|
||||
|
||||
/// No description provided for @runnerNextPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue to next path'**
|
||||
String get runnerNextPath;
|
||||
|
||||
/// No description provided for @runnerBackToStudyset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Back to studyset'**
|
||||
String get runnerBackToStudyset;
|
||||
|
||||
/// No description provided for @studysetCountPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 path} other{{count} paths}}'**
|
||||
String studysetCountPaths(int count);
|
||||
|
||||
/// No description provided for @studysetCreateNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -730,11 +1342,11 @@ abstract class AppLocalizations {
|
|||
/// **'Edit description'**
|
||||
String get studysetDescriptionEditTitle;
|
||||
|
||||
/// No description provided for @studysetDescriptionSave.
|
||||
/// No description provided for @richTextSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get studysetDescriptionSave;
|
||||
String get richTextSave;
|
||||
|
||||
/// No description provided for @studysetSheetDescription.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -214,6 +214,371 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetTabNotes => 'Notes';
|
||||
|
||||
@override
|
||||
String get studysetTabPaths => 'Paths';
|
||||
|
||||
@override
|
||||
String get studysetCreatePath => 'Create a path';
|
||||
|
||||
@override
|
||||
String get pathImportMenu => 'Import a path (JSON)';
|
||||
|
||||
@override
|
||||
String get pathImportFileType => 'Learning path (JSON)';
|
||||
|
||||
@override
|
||||
String pathImported(String title, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Imported \"$title\" with $count steps.',
|
||||
one: 'Imported \"$title\" with 1 step.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathImportSkipped(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other:
|
||||
'$count blocks were left out: images and animations aren\'t imported.',
|
||||
one: '1 block was left out: images and animations aren\'t imported.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathImportFailed(String reason) {
|
||||
return 'Couldn\'t import this file. $reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pathImportUnreadable => 'It isn\'t text in UTF-8.';
|
||||
|
||||
@override
|
||||
String get pathsEmpty => 'No paths yet';
|
||||
|
||||
@override
|
||||
String get pathsEmptyHint => 'A path leads through a lesson, step by step.';
|
||||
|
||||
@override
|
||||
String get pathOptions => 'Path options';
|
||||
|
||||
@override
|
||||
String get pathEdit => 'Edit';
|
||||
|
||||
@override
|
||||
String get pathResetProgress => 'Reset progress';
|
||||
|
||||
@override
|
||||
String get pathProgressReset => 'Progress reset';
|
||||
|
||||
@override
|
||||
String get pathDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String get pathDeleteTitle => 'Delete path?';
|
||||
|
||||
@override
|
||||
String get pathDeleteMessage =>
|
||||
'This path and its steps will be removed from the studyset. You can undo this for a few seconds.';
|
||||
|
||||
@override
|
||||
String get pathDeleteConfirm => 'Delete';
|
||||
|
||||
@override
|
||||
String get pathDeleted => 'Path deleted';
|
||||
|
||||
@override
|
||||
String get pathCreateTitle => 'New path';
|
||||
|
||||
@override
|
||||
String get pathCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get pathTitleLabel => 'Title';
|
||||
|
||||
@override
|
||||
String get pathTitleRequired => 'A path needs a title.';
|
||||
|
||||
@override
|
||||
String get pathSecondaryTitleLabel => 'Secondary title';
|
||||
|
||||
@override
|
||||
String get pathSecondaryTitleHint =>
|
||||
'E.g. the title in the language you learn';
|
||||
|
||||
@override
|
||||
String get pathEditTitle => 'Edit path';
|
||||
|
||||
@override
|
||||
String get pathDescription => 'Description';
|
||||
|
||||
@override
|
||||
String get pathAddDescription => 'Add a description';
|
||||
|
||||
@override
|
||||
String get pathDescriptionEditTitle => 'Path description';
|
||||
|
||||
@override
|
||||
String get pathAddTags => 'Add tags';
|
||||
|
||||
@override
|
||||
String get pathTags => 'Tags';
|
||||
|
||||
@override
|
||||
String get pathSteps => 'Steps';
|
||||
|
||||
@override
|
||||
String get pathStepsEmpty => 'No steps yet';
|
||||
|
||||
@override
|
||||
String get pathStepsEmptyHint =>
|
||||
'Pick a step type to start writing the lesson.';
|
||||
|
||||
@override
|
||||
String get pathAddStep => 'Add step';
|
||||
|
||||
@override
|
||||
String get pathReorder => 'Reorder';
|
||||
|
||||
@override
|
||||
String get pathReorderDone => 'Done';
|
||||
|
||||
@override
|
||||
String get pathStepDelete => 'Delete step';
|
||||
|
||||
@override
|
||||
String get pathStepDeleted => 'Step deleted';
|
||||
|
||||
@override
|
||||
String get stepUntitled => 'Untitled step';
|
||||
|
||||
@override
|
||||
String get stepKindInfo => 'Info';
|
||||
|
||||
@override
|
||||
String get stepKindSingleChoice => 'Single choice';
|
||||
|
||||
@override
|
||||
String get stepKindMultipleChoice => 'Multiple choice';
|
||||
|
||||
@override
|
||||
String get stepKindDragAndDrop => 'Drag and drop';
|
||||
|
||||
@override
|
||||
String get stepKindFillInTheBlank => 'Fill in the blank';
|
||||
|
||||
@override
|
||||
String get stepEditTitle => 'Edit step';
|
||||
|
||||
@override
|
||||
String get stepTitleHint => 'Title';
|
||||
|
||||
@override
|
||||
String get stepOptions => 'Options';
|
||||
|
||||
@override
|
||||
String stepOptionHint(int number) {
|
||||
return 'Option $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepAddOption => 'Add option';
|
||||
|
||||
@override
|
||||
String get stepRemoveOption => 'Remove option';
|
||||
|
||||
@override
|
||||
String get stepCorrectOption => 'The right one';
|
||||
|
||||
@override
|
||||
String get stepCorrectOptions => 'Right ones';
|
||||
|
||||
@override
|
||||
String get stepCategories => 'Categories';
|
||||
|
||||
@override
|
||||
String stepCategoryHint(int number) {
|
||||
return 'Category $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepAddCategory => 'Add category';
|
||||
|
||||
@override
|
||||
String get stepRemoveCategory => 'Remove category';
|
||||
|
||||
@override
|
||||
String get stepItems => 'Items';
|
||||
|
||||
@override
|
||||
String stepItemHint(int number) {
|
||||
return 'Item $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepAddItem => 'Add item';
|
||||
|
||||
@override
|
||||
String get stepRemoveItem => 'Remove item';
|
||||
|
||||
@override
|
||||
String get stepItemCategory => 'Belongs to';
|
||||
|
||||
@override
|
||||
String get stepPassage => 'Passage';
|
||||
|
||||
@override
|
||||
String get stepPassageHint =>
|
||||
'Type ___ (three underscores) wherever a word should be dropped in.';
|
||||
|
||||
@override
|
||||
String stepBlankHint(int number) {
|
||||
return 'Word for gap $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepIncomplete =>
|
||||
'Not ready to answer yet: fill in every field and choose what is right.';
|
||||
|
||||
@override
|
||||
String get stepFeedbackRight => 'Feedback after a right answer';
|
||||
|
||||
@override
|
||||
String get stepFeedbackWrong => 'Feedback after a wrong answer';
|
||||
|
||||
@override
|
||||
String get stepFeedbackNone => 'None';
|
||||
|
||||
@override
|
||||
String get stepSources => 'Sources';
|
||||
|
||||
@override
|
||||
String get stepAddSource => 'Add source';
|
||||
|
||||
@override
|
||||
String get stepRemoveSource => 'Remove source';
|
||||
|
||||
@override
|
||||
String get stepSourceAuthors => 'Authors';
|
||||
|
||||
@override
|
||||
String get stepSourceYear => 'Year';
|
||||
|
||||
@override
|
||||
String get stepSourceTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get stepSourceUrl => 'Link';
|
||||
|
||||
@override
|
||||
String stepSourcesCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count sources',
|
||||
one: '1 source',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get stepConflict =>
|
||||
'This step was changed differently on another device. Keep one version to go on writing.';
|
||||
|
||||
@override
|
||||
String get stepFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get stepFieldBody => 'Text';
|
||||
|
||||
@override
|
||||
String get stepFieldExercise => 'Exercise';
|
||||
|
||||
@override
|
||||
String get stepFieldRightFeedback => 'Feedback after a right answer';
|
||||
|
||||
@override
|
||||
String get stepFieldWrongFeedback => 'Feedback after a wrong answer';
|
||||
|
||||
@override
|
||||
String get stepFieldSources => 'Sources';
|
||||
|
||||
@override
|
||||
String get runnerClose => 'Close';
|
||||
|
||||
@override
|
||||
String get runnerEditStep => 'Edit step';
|
||||
|
||||
@override
|
||||
String get runnerBack => 'Back';
|
||||
|
||||
@override
|
||||
String get runnerCheck => 'Check answers';
|
||||
|
||||
@override
|
||||
String get runnerContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get runnerFinish => 'Finish';
|
||||
|
||||
@override
|
||||
String get runnerRight => 'Correct!';
|
||||
|
||||
@override
|
||||
String get runnerWrong => 'Not quite';
|
||||
|
||||
@override
|
||||
String get runnerAlreadyRight => 'You already answered this correctly.';
|
||||
|
||||
@override
|
||||
String get runnerSelectAll => 'Select all that apply.';
|
||||
|
||||
@override
|
||||
String get runnerDragHint =>
|
||||
'Drag each item into the category it belongs to, or tap it and then the category. Tap a placed item to take it back.';
|
||||
|
||||
@override
|
||||
String get runnerBlankHint =>
|
||||
'Drag a word into each gap, or tap it and then the gap. Tap a placed word to take it back.';
|
||||
|
||||
@override
|
||||
String get runnerEmpty => 'This path has no steps yet.';
|
||||
|
||||
@override
|
||||
String get runnerFinished => 'You finished this path';
|
||||
|
||||
@override
|
||||
String runnerFinishedSteps(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count steps complete.',
|
||||
one: '1 step complete.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get runnerNextPath => 'Continue to next path';
|
||||
|
||||
@override
|
||||
String get runnerBackToStudyset => 'Back to studyset';
|
||||
|
||||
@override
|
||||
String studysetCountPaths(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count paths',
|
||||
one: '1 path',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetCreateNote => 'Create a note';
|
||||
|
||||
|
|
@ -414,7 +779,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get studysetDescriptionEditTitle => 'Edit description';
|
||||
|
||||
@override
|
||||
String get studysetDescriptionSave => 'Save';
|
||||
String get richTextSave => 'Save';
|
||||
|
||||
@override
|
||||
String get studysetSheetDescription => 'Description';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_editor_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_editor_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||
|
|
@ -140,6 +146,33 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
noteId: state.pathParameters['noteId'],
|
||||
),
|
||||
),
|
||||
// A learning path: worked through, written, and one of its steps
|
||||
// written.
|
||||
GoRoute(
|
||||
path: '/studysets/:studysetId/paths/:pathId',
|
||||
builder: (context, state) => _pathRunner(
|
||||
context,
|
||||
state.pathParameters['studysetId']!,
|
||||
state.pathParameters['pathId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/studysets/:studysetId/paths/:pathId/edit',
|
||||
builder: (context, state) => _pathEditor(
|
||||
context,
|
||||
state.pathParameters['studysetId']!,
|
||||
state.pathParameters['pathId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/studysets/:studysetId/paths/:pathId/steps/:stepId',
|
||||
builder: (context, state) => _stepEditor(
|
||||
context,
|
||||
state.pathParameters['studysetId']!,
|
||||
state.pathParameters['pathId']!,
|
||||
state.pathParameters['stepId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: RoutePaths.settings,
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
|
|
@ -147,6 +180,66 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
],
|
||||
);
|
||||
|
||||
/// The learning path [pathId] of the studyset [studysetId], to work
|
||||
/// through; a studyset gone says so.
|
||||
Widget _pathRunner(BuildContext context, String studysetId, String pathId) {
|
||||
final person = PersonScope.of(context);
|
||||
return FoundById<Studyset>(
|
||||
id: studysetId,
|
||||
items: person.studysets.watchAll,
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset, _) => BlocProvider(
|
||||
create: (context) =>
|
||||
PathRunnerBloc(person.paths, person.pathProgress, studysetId, pathId)
|
||||
..add(const PathRunnerEventStarted()),
|
||||
child: PathRunnerScreen(studysetId: studysetId, pathId: pathId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The learning path [pathId] of the studyset [studysetId], to write.
|
||||
/// Leaving closes its bloc, which saves what is unsaved.
|
||||
Widget _pathEditor(BuildContext context, String studysetId, String pathId) {
|
||||
final person = PersonScope.of(context);
|
||||
return FoundById<Studyset>(
|
||||
id: studysetId,
|
||||
items: person.studysets.watchAll,
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset, _) => BlocProvider(
|
||||
create: (context) => PathEditorBloc(
|
||||
person.paths,
|
||||
person.tags,
|
||||
person.stepDeletions,
|
||||
studysetId,
|
||||
pathId,
|
||||
)..add(const PathEditorEventStarted()),
|
||||
child: PathEditorScreen(studysetId: studysetId, pathId: pathId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The step [stepId] of the learning path [pathId], to write. Leaving
|
||||
/// closes its bloc, which saves what is unsaved.
|
||||
Widget _stepEditor(
|
||||
BuildContext context,
|
||||
String studysetId,
|
||||
String pathId,
|
||||
String stepId,
|
||||
) {
|
||||
final person = PersonScope.of(context);
|
||||
return FoundById<Studyset>(
|
||||
id: studysetId,
|
||||
items: person.studysets.watchAll,
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset, _) => BlocProvider(
|
||||
create: (context) =>
|
||||
StepEditorBloc(person.paths, pathId, stepId)
|
||||
..add(const StepEditorEventStarted()),
|
||||
child: const StepEditorScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
|
||||
id: id,
|
||||
items: PersonScope.of(context).studysets.watchAll,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Edits rich text in the whole window, over what opened it, with the format
|
||||
/// bar while writing: a studyset's description, a step's feedback. Completes
|
||||
/// with the new text once saved, or with null when left without saving.
|
||||
Future<RichContent?> editRichText(
|
||||
BuildContext context,
|
||||
RichContent initial, {
|
||||
|
||||
/// The app bar's title, saying what is being written.
|
||||
required String title,
|
||||
}) => Navigator.of(context).push<RichContent>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (context) => _RichTextEditor(initial, title: title),
|
||||
),
|
||||
);
|
||||
|
||||
class const _RichTextEditor(
|
||||
final RichContent initial, {
|
||||
required final String title,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_RichTextEditor> createState() => _RichTextEditorState();
|
||||
}
|
||||
|
||||
class _RichTextEditorState() extends State<_RichTextEditor> {
|
||||
late var _text = widget.initial;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.title),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_text),
|
||||
child: Text(l10n.richTextSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The editor gets a height of its own in a scrolling body: stretched
|
||||
// to fill an unbounded column, it never finishes laying out.
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) => Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: math.max(320, constraints.maxHeight - 32),
|
||||
child: RichContentEditor(
|
||||
initialContent: widget.initial,
|
||||
onChanged: (text) => _text = text,
|
||||
formatBar: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,4 +38,16 @@ abstract final class RoutePaths() {
|
|||
/// A note of a studyset, over the tabs.
|
||||
static String note(String studysetId, String noteId) =>
|
||||
'/studysets/$studysetId/notes/$noteId';
|
||||
|
||||
/// A learning path of a studyset, to work through, over the tabs.
|
||||
static String path(String studysetId, String pathId) =>
|
||||
'/studysets/$studysetId/paths/$pathId';
|
||||
|
||||
/// A learning path of a studyset, to write, over the tabs.
|
||||
static String editPath(String studysetId, String pathId) =>
|
||||
'/studysets/$studysetId/paths/$pathId/edit';
|
||||
|
||||
/// A step of a learning path, to write, over the tabs.
|
||||
static String pathStep(String studysetId, String pathId, String stepId) =>
|
||||
'/studysets/$studysetId/paths/$pathId/steps/$stepId';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
|
|
@ -25,6 +26,7 @@ class HomeBloc(
|
|||
final CardRepository _cards,
|
||||
final ReviewRepository _reviews,
|
||||
final SettingsRepository _settings,
|
||||
final PathProgressRepository _pathProgress,
|
||||
) extends Bloc<HomeEvent, HomeState> {
|
||||
this : super(const HomeStateLoading()) {
|
||||
on<HomeEventStarted>(_onStarted);
|
||||
|
|
@ -78,6 +80,7 @@ class HomeBloc(
|
|||
_subscriptions.addAll([
|
||||
_reviews.changes.listen(changed),
|
||||
_cards.changes.listen(changed),
|
||||
_pathProgress.changes.listen(changed),
|
||||
_settings.watch().listen((settings) {
|
||||
_settingsNow = settings;
|
||||
changed(null);
|
||||
|
|
@ -95,6 +98,7 @@ class HomeBloc(
|
|||
final now = clock.now();
|
||||
final reviews = await _reviews.all();
|
||||
final cards = await _cards.allCards();
|
||||
final steps = await _pathProgress.doneTimes();
|
||||
final ratings = [
|
||||
for (final review in reviews)
|
||||
if (review.mode != StudyMode.reset) review.reviewedAt,
|
||||
|
|
@ -106,6 +110,7 @@ class HomeBloc(
|
|||
for (final card in cards)
|
||||
if (!card.imported) ?card.createdAt,
|
||||
],
|
||||
steps: steps,
|
||||
imported: [
|
||||
for (final card in cards)
|
||||
if (card.imported) ?card.createdAt,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ DateTime addDays(DateTime day, int days) =>
|
|||
DateTime mondayOf(DateTime day) => addDays(day, -(day.weekday - 1));
|
||||
|
||||
/// How much the person studied on each local day: cards rated, crammed
|
||||
/// included, and cards written by hand (decided 2026-10-01).
|
||||
/// included, and cards written by hand (decided 2026-10-01), and steps of
|
||||
/// learning paths done (decided 2026-10-02).
|
||||
final class const StudyActivity(
|
||||
/// What happened on each day, by the day.
|
||||
final Map<DateTime, int> byDay, {
|
||||
|
|
@ -24,20 +25,22 @@ final class const StudyActivity(
|
|||
/// has.
|
||||
final DateTime? start,
|
||||
}) with Equatable {
|
||||
/// The activity of [ratings] and [written] cards, each an instant, by the
|
||||
/// local day it fell on. Cards [imported] count only for its [start].
|
||||
/// The activity of [ratings], [written] cards and [steps] done, each an
|
||||
/// instant, by the local day it fell on. Cards [imported] count only for
|
||||
/// its [start].
|
||||
factory of({
|
||||
required Iterable<DateTime> ratings,
|
||||
required Iterable<DateTime> written,
|
||||
Iterable<DateTime> steps = const [],
|
||||
Iterable<DateTime> imported = const [],
|
||||
}) {
|
||||
final byDay = <DateTime, int>{};
|
||||
for (final instant in [...ratings, ...written]) {
|
||||
for (final instant in [...ratings, ...written, ...steps]) {
|
||||
final day = localDay(instant);
|
||||
byDay[day] = (byDay[day] ?? 0) + 1;
|
||||
}
|
||||
DateTime? start;
|
||||
for (final instant in [...ratings, ...written, ...imported]) {
|
||||
for (final instant in [...ratings, ...written, ...steps, ...imported]) {
|
||||
final day = localDay(instant);
|
||||
if (start == null || day.isBefore(start)) {
|
||||
start = day;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class const HomeScreen({super.key}) extends StatelessWidget {
|
|||
person.cards,
|
||||
person.reviews,
|
||||
person.settings,
|
||||
person.pathProgress,
|
||||
)..add(const HomeEventStarted()),
|
||||
child: const _Home(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
/// Writes a learning path: its own content (title, secondary title,
|
||||
/// description), saved after [_idleSave] without typing and when the bloc
|
||||
/// closes, the last writer winning, as a studyset's; its tags; and its
|
||||
/// steps, added, moved and (through [StepDeletions]) deleted. A blank title
|
||||
/// is never saved.
|
||||
class PathEditorBloc(
|
||||
final LearningPathRepository _paths,
|
||||
final TagRepository _tags,
|
||||
final StepDeletions _stepDeletions,
|
||||
final String _studysetId,
|
||||
final String _pathId, {
|
||||
final Duration _idleSave = const Duration(seconds: 1),
|
||||
}) extends Bloc<PathEditorEvent, PathEditorState> {
|
||||
this : super(PathEditorState(content: LearningPathContent.titled(''))) {
|
||||
on<PathEditorEventStarted>(_onStarted);
|
||||
on<PathEditorEventTitleChanged>(
|
||||
(event, emit) => _change(emit, (c) => _with(c, title: event.title)),
|
||||
);
|
||||
on<PathEditorEventSecondaryTitleChanged>(
|
||||
(event, emit) =>
|
||||
_change(emit, (c) => _with(c, secondaryTitle: event.secondaryTitle)),
|
||||
);
|
||||
on<PathEditorEventDescriptionChanged>(
|
||||
(event, emit) =>
|
||||
_change(emit, (c) => _with(c, description: event.description)),
|
||||
);
|
||||
on<PathEditorEventSaveRequested>((event, emit) => _save(emit));
|
||||
on<PathEditorEventTagsChanged>((event, emit) async {
|
||||
emit(state.copyWith(tagIds: event.tagIds));
|
||||
await _tags.setTagsOfPath(_pathId, event.tagIds);
|
||||
});
|
||||
on<PathEditorEventStepAdded>((event, emit) async {
|
||||
final created = await _paths.createStep(
|
||||
_pathId,
|
||||
event.kind,
|
||||
StepContent.empty(event.kind),
|
||||
);
|
||||
emit(state.copyWith(added: created.stepId));
|
||||
});
|
||||
on<PathEditorEventStepMoved>(
|
||||
(event, emit) =>
|
||||
_paths.moveStep(_pathId, event.stepId, toIndex: event.toIndex),
|
||||
);
|
||||
}
|
||||
|
||||
List<LearningPathStep>? _steps;
|
||||
var _pending = <String>{};
|
||||
Timer? _idle;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_idle?.cancel();
|
||||
// Leaving the path saves it; the write outlives the screen.
|
||||
if (state.canSave) {
|
||||
try {
|
||||
await _paths.updatePath(_pathId, state.content);
|
||||
} on Object catch (error) {
|
||||
log('Saving a path on leaving failed', name: 'Paths', error: error);
|
||||
}
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
PathEditorEventStarted event,
|
||||
Emitter<PathEditorState> emit,
|
||||
) => Future.wait([
|
||||
emit.forEach(
|
||||
_paths.watchPaths(_studysetId),
|
||||
onData: (paths) {
|
||||
final path = paths.where((path) => path.id == _pathId).firstOrNull;
|
||||
if (path == null) {
|
||||
_idle?.cancel();
|
||||
return state.copyWith(loaded: true, gone: true);
|
||||
}
|
||||
// Content the person hasn't changed takes the stored one.
|
||||
return state.copyWith(
|
||||
loaded: true,
|
||||
saved: path.content,
|
||||
content: state.isDirty ? null : path.content,
|
||||
);
|
||||
},
|
||||
onError: (error, _) {
|
||||
log('Reading the path failed', name: 'Paths', error: error);
|
||||
return state;
|
||||
},
|
||||
),
|
||||
emit.forEach(
|
||||
_paths.watchSteps(_pathId),
|
||||
onData: (steps) {
|
||||
_steps = steps;
|
||||
return state.copyWith(steps: _shownSteps);
|
||||
},
|
||||
onError: (_, _) => state,
|
||||
),
|
||||
emit.forEach(
|
||||
_stepDeletions.watchPending(),
|
||||
onData: (pending) {
|
||||
_pending = pending;
|
||||
return state.copyWith(steps: _shownSteps);
|
||||
},
|
||||
),
|
||||
emit.forEach(
|
||||
_tags.watch(),
|
||||
onData: (tagging) {
|
||||
final stored = {for (final tag in tagging.ofPath(_pathId)) tag.id};
|
||||
final unchanged =
|
||||
state.tagIds.length == state.savedTagIds.length &&
|
||||
state.tagIds.containsAll(state.savedTagIds);
|
||||
return state.copyWith(
|
||||
tagging: tagging,
|
||||
savedTagIds: stored,
|
||||
tagIds: unchanged ? stored : null,
|
||||
);
|
||||
},
|
||||
onError: (_, _) => state,
|
||||
),
|
||||
]);
|
||||
|
||||
List<LearningPathStep> get _shownSteps => [
|
||||
for (final step in _steps ?? const <LearningPathStep>[])
|
||||
if (!_pending.contains(step.id)) step,
|
||||
];
|
||||
|
||||
void _change(
|
||||
Emitter<PathEditorState> emit,
|
||||
LearningPathContent Function(LearningPathContent content) change,
|
||||
) {
|
||||
if (state.gone) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(content: change(state.content)));
|
||||
_idle?.cancel();
|
||||
_idle = Timer(_idleSave, () {
|
||||
if (!isClosed) {
|
||||
add(const PathEditorEventSaveRequested());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save(Emitter<PathEditorState> emit) async {
|
||||
_idle?.cancel();
|
||||
if (!state.canSave) {
|
||||
return;
|
||||
}
|
||||
final content = state.content.normalized;
|
||||
try {
|
||||
await _paths.updatePath(_pathId, content);
|
||||
emit(state.copyWith(saved: content));
|
||||
} on Object catch (error) {
|
||||
log('Saving a path failed', name: 'Paths', error: error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LearningPathContent _with(
|
||||
LearningPathContent content, {
|
||||
String? title,
|
||||
String? secondaryTitle,
|
||||
RichContent? description,
|
||||
}) => LearningPathContent(
|
||||
title: title ?? content.title,
|
||||
secondaryTitle: secondaryTitle ?? content.secondaryTitle,
|
||||
description: description ?? content.description,
|
||||
);
|
||||
|
||||
/// The path as its screen holds it.
|
||||
final class const PathEditorState({
|
||||
/// Its content as the person has it now.
|
||||
required final LearningPathContent content,
|
||||
|
||||
/// Its content as stored; null until read.
|
||||
final LearningPathContent? saved,
|
||||
|
||||
/// Whether the path was read.
|
||||
final bool loaded = false,
|
||||
|
||||
/// Whether it was deleted, here or elsewhere.
|
||||
final bool gone = false,
|
||||
|
||||
/// Its steps, in order, but those whose deletion waits.
|
||||
final List<LearningPathStep> steps = const [],
|
||||
|
||||
/// The person's tags.
|
||||
final Tagging tagging = Tagging.empty,
|
||||
|
||||
/// The ids of the tags the person gave it.
|
||||
final Set<String> tagIds = const {},
|
||||
|
||||
/// The ids of the tags it has as stored.
|
||||
final Set<String> savedTagIds = const {},
|
||||
|
||||
/// The step just added here, to open; null before any.
|
||||
final String? added,
|
||||
}) with Equatable {
|
||||
/// Whether its content differs from what is stored.
|
||||
bool get isDirty => saved != null && content.normalized != saved;
|
||||
|
||||
/// Whether saving now would write: changed, still there, and titled.
|
||||
bool get canSave => isDirty && !gone && content.normalized.title.isNotEmpty;
|
||||
|
||||
/// This state with some fields replaced.
|
||||
PathEditorState copyWith({
|
||||
LearningPathContent? content,
|
||||
LearningPathContent? saved,
|
||||
bool? loaded,
|
||||
bool? gone,
|
||||
List<LearningPathStep>? steps,
|
||||
Tagging? tagging,
|
||||
Set<String>? tagIds,
|
||||
Set<String>? savedTagIds,
|
||||
String? added,
|
||||
}) => PathEditorState(
|
||||
content: content ?? this.content,
|
||||
saved: saved ?? this.saved,
|
||||
loaded: loaded ?? this.loaded,
|
||||
gone: gone ?? this.gone,
|
||||
steps: steps ?? this.steps,
|
||||
tagging: tagging ?? this.tagging,
|
||||
tagIds: tagIds ?? this.tagIds,
|
||||
savedTagIds: savedTagIds ?? this.savedTagIds,
|
||||
added: added ?? this.added,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
content,
|
||||
saved,
|
||||
loaded,
|
||||
gone,
|
||||
steps,
|
||||
tagging,
|
||||
tagIds,
|
||||
savedTagIds,
|
||||
added,
|
||||
];
|
||||
}
|
||||
|
||||
/// What happens on the path's screen.
|
||||
sealed class const PathEditorEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The screen opens: follow the path, its steps and the tags.
|
||||
final class const PathEditorEventStarted() extends PathEditorEvent;
|
||||
|
||||
/// The person changed the title.
|
||||
final class const PathEditorEventTitleChanged(final String title)
|
||||
extends PathEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [title];
|
||||
}
|
||||
|
||||
/// The person changed the secondary title.
|
||||
final class const PathEditorEventSecondaryTitleChanged(
|
||||
final String secondaryTitle,
|
||||
) extends PathEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [secondaryTitle];
|
||||
}
|
||||
|
||||
/// The person changed the description.
|
||||
final class const PathEditorEventDescriptionChanged(
|
||||
final RichContent description,
|
||||
) extends PathEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [description];
|
||||
}
|
||||
|
||||
/// Save now, not after the pause.
|
||||
final class const PathEditorEventSaveRequested() extends PathEditorEvent;
|
||||
|
||||
/// The person chose the tags [tagIds].
|
||||
final class const PathEditorEventTagsChanged(final Set<String> tagIds)
|
||||
extends PathEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [tagIds];
|
||||
}
|
||||
|
||||
/// Add a step of [kind] at the end.
|
||||
final class const PathEditorEventStepAdded(final StepKind kind)
|
||||
extends PathEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [kind];
|
||||
}
|
||||
|
||||
/// Move the step [stepId] to [toIndex].
|
||||
final class const PathEditorEventStepMoved(
|
||||
final String stepId,
|
||||
final int toIndex,
|
||||
) extends PathEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [stepId, toIndex];
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
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/learning_path/data/learning_path_deletions.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_import.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
|
||||
/// A studyset's learning paths in order, through every change, leaving out
|
||||
/// those whose deletion can still be undone, and which of them are
|
||||
/// completed: every step done, and at least one step. Creates paths,
|
||||
/// imports them from the import format, and starts a path's progress afresh
|
||||
/// with an undo.
|
||||
class PathListBloc(
|
||||
final LearningPathRepository _paths,
|
||||
final PathProgressRepository _progress,
|
||||
final LearningPathDeletions _deletions,
|
||||
final String _studysetId,
|
||||
) extends Bloc<PathListEvent, PathListState> {
|
||||
this : super(const PathListState()) {
|
||||
on<PathListEventStarted>(_onStarted);
|
||||
on<PathListEventCompletionRequested>(_onCompletionRequested);
|
||||
on<PathListEventCreateRequested>((event, emit) async {
|
||||
final id = await _paths.createPath(_studysetId, event.content);
|
||||
emit(state.copyWith(created: id));
|
||||
});
|
||||
on<PathListEventImportRequested>((event, emit) async {
|
||||
try {
|
||||
final parsed = parsePathImport(event.source);
|
||||
final imported = await PathImporter(
|
||||
_paths,
|
||||
).import(_studysetId, event.source);
|
||||
emit(
|
||||
state.copyWith(
|
||||
importResult: (
|
||||
title: parsed.content.title,
|
||||
steps: imported.steps,
|
||||
skippedBlocks: imported.skippedBlocks,
|
||||
failure: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
} on PathImportException catch (error) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
importResult: (
|
||||
title: '',
|
||||
steps: 0,
|
||||
skippedBlocks: 0,
|
||||
failure: error.message,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
on<PathListEventMoveRequested>(
|
||||
(event, emit) =>
|
||||
_paths.movePath(_studysetId, event.id, toIndex: event.toIndex),
|
||||
);
|
||||
on<PathListEventResetRequested>((event, emit) async {
|
||||
final stepIds = {
|
||||
for (final step in await _paths.stepsOf(event.id)) step.id,
|
||||
};
|
||||
final at = await _progress.reset(stepIds);
|
||||
_lastReset = (stepIds: stepIds, at: at);
|
||||
});
|
||||
on<PathListEventResetUndone>((event, emit) async {
|
||||
if (_lastReset case (:final stepIds, :final at)) {
|
||||
_lastReset = null;
|
||||
await _progress.undoReset(stepIds, at);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The paths as last read; null until they were.
|
||||
List<LearningPath>? _all;
|
||||
var _pending = <String>{};
|
||||
({Set<String> stepIds, DateTime at})? _lastReset;
|
||||
|
||||
/// How often the completion was asked for, so that a slow, older working
|
||||
/// out never overwrites a newer one.
|
||||
var _completionRequests = 0;
|
||||
|
||||
Future<void> _onStarted(
|
||||
PathListEventStarted event,
|
||||
Emitter<PathListState> emit,
|
||||
) => Future.wait([
|
||||
emit.forEach(
|
||||
_paths.watchPaths(_studysetId),
|
||||
onData: (paths) {
|
||||
_all = paths;
|
||||
add(const PathListEventCompletionRequested());
|
||||
return state.copyWith(loaded: true, paths: _shown);
|
||||
},
|
||||
onError: (error, _) {
|
||||
log('Reading paths failed', name: 'Paths', error: error);
|
||||
return state.copyWith(loaded: true, failed: true);
|
||||
},
|
||||
),
|
||||
// Answers and changed steps change what is completed.
|
||||
emit.onEach(
|
||||
_progress.changes,
|
||||
onData: (_) => add(const PathListEventCompletionRequested()),
|
||||
),
|
||||
emit.onEach(
|
||||
_deletions.watchPending(),
|
||||
onData: (pending) {
|
||||
_pending = pending;
|
||||
if (_all != null) {
|
||||
emit(state.copyWith(paths: _shown));
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
List<LearningPath> get _shown => [
|
||||
for (final path in _all ?? const <LearningPath>[])
|
||||
if (!_pending.contains(path.id)) path,
|
||||
];
|
||||
|
||||
Future<void> _onCompletionRequested(
|
||||
PathListEventCompletionRequested event,
|
||||
Emitter<PathListState> emit,
|
||||
) async {
|
||||
final request = ++_completionRequests;
|
||||
final completed = <String>{};
|
||||
try {
|
||||
for (final path in _all ?? const <LearningPath>[]) {
|
||||
final steps = await _paths.stepsOf(path.id);
|
||||
final progress = await _progress.of({
|
||||
for (final step in steps) step.id: step.kind,
|
||||
});
|
||||
if (steps.isNotEmpty &&
|
||||
steps.every((step) => progress[step.id]?.done ?? false)) {
|
||||
completed.add(path.id);
|
||||
}
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Working out completion failed', name: 'Paths', error: error);
|
||||
return;
|
||||
}
|
||||
if (request == _completionRequests) {
|
||||
emit(state.copyWith(completed: completed, completionKnown: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an import went: the path's title, how many steps came in and how
|
||||
/// many blocks were left out; or, with a `failure`, why nothing came in.
|
||||
typedef ImportResult = ({
|
||||
String title,
|
||||
int steps,
|
||||
int skippedBlocks,
|
||||
String? failure,
|
||||
});
|
||||
|
||||
/// A studyset's paths, as the list shows them.
|
||||
final class const PathListState({
|
||||
/// Whether the paths were read.
|
||||
final bool loaded = false,
|
||||
|
||||
/// The paths, in order.
|
||||
final List<LearningPath> paths = const [],
|
||||
|
||||
/// The ids of the paths completed.
|
||||
final Set<String> completed = const {},
|
||||
|
||||
/// Whether [completed] was worked out at least once.
|
||||
final bool completionKnown = false,
|
||||
|
||||
/// The path just created here, to open; null before any.
|
||||
final String? created,
|
||||
|
||||
/// How the last import here went: what came in, or why nothing did;
|
||||
/// null before any.
|
||||
final ImportResult? importResult,
|
||||
|
||||
/// Whether the last read failed; the next change reads again.
|
||||
final bool failed = false,
|
||||
}) with Equatable {
|
||||
/// Whether some path is not completed yet.
|
||||
bool get hasOpenPaths => paths.any((path) => !completed.contains(path.id));
|
||||
|
||||
/// This state with some fields replaced.
|
||||
PathListState copyWith({
|
||||
bool? loaded,
|
||||
List<LearningPath>? paths,
|
||||
Set<String>? completed,
|
||||
bool? completionKnown,
|
||||
String? created,
|
||||
ImportResult? importResult,
|
||||
bool? failed,
|
||||
}) => PathListState(
|
||||
loaded: loaded ?? this.loaded,
|
||||
paths: paths ?? this.paths,
|
||||
completed: completed ?? this.completed,
|
||||
completionKnown: completionKnown ?? this.completionKnown,
|
||||
created: created ?? this.created,
|
||||
importResult: importResult ?? this.importResult,
|
||||
failed: failed ?? this.failed,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
loaded,
|
||||
paths,
|
||||
completed,
|
||||
completionKnown,
|
||||
created,
|
||||
importResult,
|
||||
failed,
|
||||
];
|
||||
}
|
||||
|
||||
/// What happens in the list.
|
||||
sealed class const PathListEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The list opens: follow the paths, the answers and the deletions.
|
||||
final class const PathListEventStarted() extends PathListEvent;
|
||||
|
||||
/// Work out anew which paths are completed, as after an answer.
|
||||
final class const PathListEventCompletionRequested() extends PathListEvent;
|
||||
|
||||
/// Create a path with [content] at the end.
|
||||
final class const PathListEventCreateRequested(
|
||||
/// What the new path says about itself.
|
||||
final LearningPathContent content,
|
||||
) extends PathListEvent {
|
||||
@override
|
||||
List<Object?> get props => [content];
|
||||
}
|
||||
|
||||
/// Import the path in [source], a file in the import format.
|
||||
final class const PathListEventImportRequested(
|
||||
/// The file's text.
|
||||
final String source,
|
||||
) extends PathListEvent {
|
||||
@override
|
||||
List<Object?> get props => [source];
|
||||
}
|
||||
|
||||
/// Move the path [id] to [toIndex].
|
||||
final class const PathListEventMoveRequested(
|
||||
/// The path's id.
|
||||
final String id,
|
||||
|
||||
/// Where it goes.
|
||||
final int toIndex,
|
||||
) extends PathListEvent {
|
||||
@override
|
||||
List<Object?> get props => [id, toIndex];
|
||||
}
|
||||
|
||||
/// Start the path [id]'s progress afresh.
|
||||
final class const PathListEventResetRequested(
|
||||
/// The path's id.
|
||||
final String id,
|
||||
) extends PathListEvent {
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Take the last fresh start back.
|
||||
final class const PathListEventResetUndone() extends PathListEvent;
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
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/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/learning_path/model/answer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
|
||||
/// Leads the person through a learning path, one step at a time (decided
|
||||
/// 2026-10-02): it opens at the first unfinished step; an info step is
|
||||
/// passed by going on, an exercise only once answered right, as often as it
|
||||
/// takes; every answer goes into the log. After the last step the path is
|
||||
/// finished, and the studyset's next path is named.
|
||||
class PathRunnerBloc(
|
||||
final LearningPathRepository _paths,
|
||||
final PathProgressRepository _progress,
|
||||
final String _studysetId,
|
||||
final String _pathId,
|
||||
) extends Bloc<PathRunnerEvent, PathRunnerState> {
|
||||
this : super(const PathRunnerState()) {
|
||||
on<PathRunnerEventStarted>(_onStarted);
|
||||
on<PathRunnerEventProgressRequested>(_onProgressRequested);
|
||||
on<PathRunnerEventAnswered>(
|
||||
(event, emit) => emit(state.withAnswer(event.answer)),
|
||||
);
|
||||
on<PathRunnerEventCheckRequested>(_onCheckRequested);
|
||||
on<PathRunnerEventContinued>(_onContinued);
|
||||
on<PathRunnerEventBack>((event, emit) {
|
||||
if (state.finished) {
|
||||
emit(state.copyWith(finished: false));
|
||||
} else if (state.index > 0) {
|
||||
emit(state.at(state.index - 1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
PathRunnerEventStarted event,
|
||||
Emitter<PathRunnerState> emit,
|
||||
) async {
|
||||
final paths = await _paths.pathsOf(_studysetId);
|
||||
final at = paths.indexWhere((path) => path.id == _pathId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
nextPathId: at >= 0 && at + 1 < paths.length ? paths[at + 1].id : null,
|
||||
),
|
||||
);
|
||||
await Future.wait([
|
||||
emit.forEach(
|
||||
_paths.watchSteps(_pathId),
|
||||
onData: (steps) {
|
||||
add(const PathRunnerEventProgressRequested());
|
||||
return state.copyWith(steps: steps);
|
||||
},
|
||||
onError: (error, _) {
|
||||
log('Reading steps failed', name: 'Paths', error: error);
|
||||
return state;
|
||||
},
|
||||
),
|
||||
emit.onEach(
|
||||
_progress.changes,
|
||||
onData: (_) => add(const PathRunnerEventProgressRequested()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _onProgressRequested(
|
||||
PathRunnerEventProgressRequested event,
|
||||
Emitter<PathRunnerState> emit,
|
||||
) async {
|
||||
final progress = await _progress.of({
|
||||
for (final step in state.steps) step.id: step.kind,
|
||||
});
|
||||
if (state.loaded) {
|
||||
emit(state.copyWith(progress: progress));
|
||||
return;
|
||||
}
|
||||
// Opening: at the first step not done yet, or the first.
|
||||
final first = state.steps.indexWhere(
|
||||
(step) => !(progress[step.id]?.done ?? false),
|
||||
);
|
||||
emit(
|
||||
state
|
||||
.copyWith(loaded: true, progress: progress)
|
||||
.at(first < 0 ? 0 : first),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCheckRequested(
|
||||
PathRunnerEventCheckRequested event,
|
||||
Emitter<PathRunnerState> emit,
|
||||
) async {
|
||||
final step = state.step;
|
||||
final exercise = step?.content.exercise;
|
||||
final answer = state.answer;
|
||||
if (step == null || exercise == null || answer == null) {
|
||||
return;
|
||||
}
|
||||
final result = grade(exercise, answer);
|
||||
emit(state.copyWith(grade: result));
|
||||
await _progress.record(
|
||||
step.id,
|
||||
result.right ? AnswerOutcome.right : AnswerOutcome.wrong,
|
||||
answer,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onContinued(
|
||||
PathRunnerEventContinued event,
|
||||
Emitter<PathRunnerState> emit,
|
||||
) async {
|
||||
final step = state.step;
|
||||
if (step == null || !state.canContinue) {
|
||||
return;
|
||||
}
|
||||
if (!step.kind.isExercise && !state.alreadyDone) {
|
||||
await _progress.record(step.id, AnswerOutcome.continued);
|
||||
}
|
||||
emit(
|
||||
state.isLast ? state.copyWith(finished: true) : state.at(state.index + 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the person stands in the path.
|
||||
final class const PathRunnerState({
|
||||
/// Whether the steps and the progress were read.
|
||||
final bool loaded = false,
|
||||
|
||||
/// The path's steps, in order.
|
||||
final List<LearningPathStep> steps = const [],
|
||||
|
||||
/// Each step's progress, by its id.
|
||||
final Map<String, StepProgress> progress = const {},
|
||||
|
||||
/// The index of the step shown.
|
||||
final int index = 0,
|
||||
|
||||
/// What the person answered to the step shown, so far; null before.
|
||||
final ExerciseAnswer? answer,
|
||||
|
||||
/// How the last check of that answer came out; null until checked, and
|
||||
/// again once the answer changed.
|
||||
final Grade? grade,
|
||||
|
||||
/// Whether the path was finished just now.
|
||||
final bool finished = false,
|
||||
|
||||
/// The studyset's next path, after this one; null for the last.
|
||||
final String? nextPathId,
|
||||
}) with Equatable {
|
||||
/// The step shown; null for a path without steps.
|
||||
LearningPathStep? get step => index < steps.length ? steps[index] : null;
|
||||
|
||||
/// Whether the step shown is the last.
|
||||
bool get isLast => index >= steps.length - 1;
|
||||
|
||||
/// Whether the step shown was done before.
|
||||
bool get alreadyDone => progress[step?.id]?.done ?? false;
|
||||
|
||||
/// Whether the answer so far can be checked: something chosen, every item
|
||||
/// or gap filled.
|
||||
bool get canCheck => switch ((step?.content.exercise, answer)) {
|
||||
(_, SingleChoiceAnswer()) => grade == null,
|
||||
(_, MultipleChoiceAnswer(:final selected)) =>
|
||||
selected.isNotEmpty && grade == null,
|
||||
(DragAndDrop(:final items), DragAndDropAnswer(:final placements)) =>
|
||||
placements.length == items.length && grade == null,
|
||||
(final FillInTheBlank exercise, FillInTheBlankAnswer(:final words)) =>
|
||||
words.length == exercise.gaps &&
|
||||
words.every((word) => word != null) &&
|
||||
grade == null,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Whether the person may go on: an info step, or an exercise answered
|
||||
/// right, now or before.
|
||||
bool get canContinue => switch (step) {
|
||||
null => false,
|
||||
final step when !step.kind.isExercise => true,
|
||||
_ => alreadyDone || (grade?.right ?? false),
|
||||
};
|
||||
|
||||
/// This state at the step [index], its answer the right one given before,
|
||||
/// if any.
|
||||
PathRunnerState at(int index) {
|
||||
final step = index < steps.length ? steps[index] : null;
|
||||
return PathRunnerState(
|
||||
loaded: loaded,
|
||||
steps: steps,
|
||||
progress: progress,
|
||||
index: index,
|
||||
answer: progress[step?.id]?.answer,
|
||||
nextPathId: nextPathId,
|
||||
);
|
||||
}
|
||||
|
||||
/// This state with [answer] as the answer so far, unchecked.
|
||||
PathRunnerState withAnswer(ExerciseAnswer answer) => PathRunnerState(
|
||||
loaded: loaded,
|
||||
steps: steps,
|
||||
progress: progress,
|
||||
index: index,
|
||||
answer: answer,
|
||||
finished: finished,
|
||||
nextPathId: nextPathId,
|
||||
);
|
||||
|
||||
/// This state with some fields replaced.
|
||||
PathRunnerState copyWith({
|
||||
bool? loaded,
|
||||
List<LearningPathStep>? steps,
|
||||
Map<String, StepProgress>? progress,
|
||||
Grade? grade,
|
||||
bool? finished,
|
||||
String? nextPathId,
|
||||
}) => PathRunnerState(
|
||||
loaded: loaded ?? this.loaded,
|
||||
steps: steps ?? this.steps,
|
||||
progress: progress ?? this.progress,
|
||||
index: index,
|
||||
answer: answer,
|
||||
grade: grade ?? this.grade,
|
||||
finished: finished ?? this.finished,
|
||||
nextPathId: nextPathId ?? this.nextPathId,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
loaded,
|
||||
steps,
|
||||
progress,
|
||||
index,
|
||||
answer,
|
||||
grade,
|
||||
finished,
|
||||
nextPathId,
|
||||
];
|
||||
}
|
||||
|
||||
/// What happens in the player.
|
||||
sealed class const PathRunnerEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The player opens: follow the steps and the answers.
|
||||
final class const PathRunnerEventStarted() extends PathRunnerEvent;
|
||||
|
||||
/// Read the steps' progress anew, as after an answer.
|
||||
final class const PathRunnerEventProgressRequested() extends PathRunnerEvent;
|
||||
|
||||
/// The person's answer to the step shown is now [answer].
|
||||
final class const PathRunnerEventAnswered(final ExerciseAnswer answer)
|
||||
extends PathRunnerEvent {
|
||||
@override
|
||||
List<Object?> get props => [answer];
|
||||
}
|
||||
|
||||
/// Check the answer given.
|
||||
final class const PathRunnerEventCheckRequested() extends PathRunnerEvent;
|
||||
|
||||
/// Go on to the next step, or finish after the last.
|
||||
final class const PathRunnerEventContinued() extends PathRunnerEvent;
|
||||
|
||||
/// Go back a step.
|
||||
final class const PathRunnerEventBack() extends PathRunnerEvent;
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
|
||||
/// Writes one step of a learning path, saving as the person goes, as a
|
||||
/// note's editor does (`NoteEditorBloc`): after [_idleSave] without typing,
|
||||
/// when asked (the app going to the background), and when it closes, without
|
||||
/// the person waiting for it.
|
||||
///
|
||||
/// Each save builds on the revisions the editor started from, so what
|
||||
/// another device wrote meanwhile merges with it (ADR 0003); a step devices
|
||||
/// changed differently is read-only until the person keeps one version of
|
||||
/// every field in conflict.
|
||||
class StepEditorBloc(
|
||||
final LearningPathRepository _paths,
|
||||
final String _pathId,
|
||||
final String _stepId, {
|
||||
final Duration _idleSave = const Duration(seconds: 5),
|
||||
}) extends Bloc<StepEditorEvent, StepEditorState> {
|
||||
this
|
||||
: super(
|
||||
StepEditorState(
|
||||
kind: StepKind.info,
|
||||
content: StepContent.empty(StepKind.info),
|
||||
saved: StepContent.empty(StepKind.info),
|
||||
),
|
||||
) {
|
||||
on<StepEditorEventStarted>(_onStarted);
|
||||
on<StepEditorEventChanged>(_onChanged);
|
||||
on<StepEditorEventVersionChosen>(_onVersionChosen);
|
||||
on<StepEditorEventSaveRequested>((event, emit) => _save(emit));
|
||||
}
|
||||
|
||||
/// What the next save builds on; kept here as the save when the bloc
|
||||
/// closes can't wait for a state to be emitted.
|
||||
var _base = <String>{};
|
||||
var _seen = false;
|
||||
var _gone = false;
|
||||
var _savedHere = false;
|
||||
final _writes = SerialQueue();
|
||||
Timer? _idle;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_idle?.cancel();
|
||||
// Leaving the step saves it; the write outlives the screen.
|
||||
if (state.canSave) {
|
||||
try {
|
||||
await _write(state.content.normalized);
|
||||
} on Object catch (error) {
|
||||
log('Saving a step on leaving failed', name: 'Paths', error: error);
|
||||
}
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
StepEditorEventStarted event,
|
||||
Emitter<StepEditorState> emit,
|
||||
) => emit.forEach(
|
||||
_paths.watchSteps(_pathId),
|
||||
onData: _withStored,
|
||||
onError: (error, _) {
|
||||
log('Reading steps failed', name: 'Paths', error: error);
|
||||
return state;
|
||||
},
|
||||
);
|
||||
|
||||
/// The state with the path's stored [steps]: a step with nothing unsaved
|
||||
/// takes the stored one whole (its content only when it differs from the
|
||||
/// person's); one with unsaved changes keeps them and the revisions they
|
||||
/// started from, so its save merges with what came.
|
||||
StepEditorState _withStored(List<LearningPathStep> steps) {
|
||||
final step = steps.where((step) => step.id == _stepId).firstOrNull;
|
||||
if (step == null) {
|
||||
if (_seen || !state.loaded) {
|
||||
_gone = true;
|
||||
_idle?.cancel();
|
||||
return state.copyWith(loaded: true, gone: true);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (!_seen) {
|
||||
_seen = true;
|
||||
_base = step.heads;
|
||||
return state.copyWith(
|
||||
loaded: true,
|
||||
kind: step.kind,
|
||||
content: step.content,
|
||||
saved: step.content,
|
||||
conflicts: step.conflicts,
|
||||
);
|
||||
}
|
||||
if (state.isDirty || state.status == StepSaveStatus.saving) {
|
||||
return state.copyWith(saved: step.content);
|
||||
}
|
||||
_base = step.heads;
|
||||
final replaced = step.content != state.content.normalized;
|
||||
return state.copyWith(
|
||||
saved: step.content,
|
||||
content: replaced ? step.content : null,
|
||||
conflicts: step.conflicts,
|
||||
resolved: const {},
|
||||
revision: replaced ? state.revision + 1 : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(StepEditorEventChanged event, Emitter<StepEditorState> emit) {
|
||||
if (state.unresolved.isNotEmpty || _gone || !state.loaded) {
|
||||
return;
|
||||
}
|
||||
final content = state.content.withField(event.field, event.value);
|
||||
if (content == state.content) {
|
||||
return;
|
||||
}
|
||||
final changed = state.copyWith(content: content);
|
||||
emit(
|
||||
changed.copyWith(
|
||||
status: switch (state.status) {
|
||||
StepSaveStatus.saving => null,
|
||||
_ when changed.isDirty => StepSaveStatus.editing,
|
||||
_ when _savedHere => StepSaveStatus.saved,
|
||||
_ => StepSaveStatus.clean,
|
||||
},
|
||||
),
|
||||
);
|
||||
_idle?.cancel();
|
||||
_idle = Timer(_idleSave, () {
|
||||
if (!isClosed) {
|
||||
add(const StepEditorEventSaveRequested());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onVersionChosen(
|
||||
StepEditorEventVersionChosen event,
|
||||
Emitter<StepEditorState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
content: state.content.withField(event.field, event.version),
|
||||
resolved: {...state.resolved, event.field},
|
||||
revision: state.revision + 1,
|
||||
),
|
||||
);
|
||||
if (state.unresolved.isEmpty) {
|
||||
await _save(emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save(Emitter<StepEditorState> emit) async {
|
||||
_idle?.cancel();
|
||||
if (!state.canSave || state.status == StepSaveStatus.saving) {
|
||||
return;
|
||||
}
|
||||
final content = state.content.normalized;
|
||||
final settling = state.conflicts.isNotEmpty;
|
||||
emit(state.copyWith(status: StepSaveStatus.saving));
|
||||
try {
|
||||
final written = await _write(content);
|
||||
_savedHere = _savedHere || written != null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
saved: written ?? state.saved,
|
||||
conflicts: settling ? const {} : null,
|
||||
resolved: settling ? const {} : null,
|
||||
status: state.content.normalized == content
|
||||
? StepSaveStatus.saved
|
||||
: StepSaveStatus.editing,
|
||||
),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('Saving a step failed', name: 'Paths', error: error);
|
||||
emit(state.copyWith(status: StepSaveStatus.failed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes [content] on top of [_base], one write after another; returns
|
||||
/// it once written, or null for a deleted step.
|
||||
Future<StepContent?> _write(StepContent content) => _writes.run(() async {
|
||||
if (_gone) {
|
||||
return null;
|
||||
}
|
||||
_base = {await _paths.saveStep(_stepId, content, basedOn: _base)};
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
/// How the step's saving stands.
|
||||
enum StepSaveStatus() {
|
||||
/// As stored: nothing changed yet.
|
||||
clean,
|
||||
|
||||
/// Changed, not saved yet.
|
||||
editing,
|
||||
|
||||
/// Being saved.
|
||||
saving,
|
||||
|
||||
/// Saved as it is now.
|
||||
saved,
|
||||
|
||||
/// The last save failed; the next pause tries again.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The step as its editor holds it.
|
||||
final class const StepEditorState({
|
||||
/// What kind of step it is.
|
||||
required final StepKind kind,
|
||||
|
||||
/// The content as the person has it now.
|
||||
required final StepContent content,
|
||||
|
||||
/// The content as stored.
|
||||
required final StepContent saved,
|
||||
|
||||
/// Whether the step was read.
|
||||
final bool loaded = false,
|
||||
|
||||
/// How saving stands.
|
||||
final StepSaveStatus status = StepSaveStatus.clean,
|
||||
|
||||
/// The fields devices changed differently, with every version.
|
||||
final Map<StepField, List<Object?>> conflicts = const {},
|
||||
|
||||
/// The fields in conflict the person kept a version of.
|
||||
final Set<StepField> resolved = const {},
|
||||
|
||||
/// Counts the times the content was replaced from outside the editors,
|
||||
/// which then show it anew.
|
||||
final int revision = 0,
|
||||
|
||||
/// Whether the step was deleted, here or on another device.
|
||||
final bool gone = false,
|
||||
}) with Equatable {
|
||||
/// The fields in conflict still waiting for a version.
|
||||
Set<StepField> get unresolved => conflicts.keys.toSet().difference(resolved);
|
||||
|
||||
/// Whether it differs from what is stored, or settles a conflict.
|
||||
bool get isDirty =>
|
||||
content.normalized != saved ||
|
||||
(conflicts.isNotEmpty && unresolved.isEmpty);
|
||||
|
||||
/// Whether saving it now would write.
|
||||
bool get canSave => isDirty && unresolved.isEmpty && !gone && loaded;
|
||||
|
||||
/// This state with some fields replaced.
|
||||
StepEditorState copyWith({
|
||||
StepKind? kind,
|
||||
StepContent? content,
|
||||
StepContent? saved,
|
||||
bool? loaded,
|
||||
StepSaveStatus? status,
|
||||
Map<StepField, List<Object?>>? conflicts,
|
||||
Set<StepField>? resolved,
|
||||
int? revision,
|
||||
bool? gone,
|
||||
}) => StepEditorState(
|
||||
kind: kind ?? this.kind,
|
||||
content: content ?? this.content,
|
||||
saved: saved ?? this.saved,
|
||||
loaded: loaded ?? this.loaded,
|
||||
status: status ?? this.status,
|
||||
conflicts: conflicts ?? this.conflicts,
|
||||
resolved: resolved ?? this.resolved,
|
||||
revision: revision ?? this.revision,
|
||||
gone: gone ?? this.gone,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
kind,
|
||||
content,
|
||||
saved,
|
||||
loaded,
|
||||
status,
|
||||
conflicts,
|
||||
resolved,
|
||||
revision,
|
||||
gone,
|
||||
];
|
||||
}
|
||||
|
||||
/// What happens in the step's editor.
|
||||
sealed class const StepEditorEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The editor opens: follow the path's steps.
|
||||
final class const StepEditorEventStarted() extends StepEditorEvent;
|
||||
|
||||
/// The person changed [field] to [value]: a title, text, an exercise,
|
||||
/// feedback or the sources.
|
||||
final class const StepEditorEventChanged(
|
||||
/// The field changed.
|
||||
final StepField field,
|
||||
|
||||
/// Its value now, of the field's kind.
|
||||
final Object? value,
|
||||
) extends StepEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [field, value];
|
||||
}
|
||||
|
||||
/// For a field devices changed differently, the person keeps [version].
|
||||
final class const StepEditorEventVersionChosen(
|
||||
/// The field in conflict.
|
||||
final StepField field,
|
||||
|
||||
/// The version to keep.
|
||||
final Object? version,
|
||||
) extends StepEditorEvent {
|
||||
@override
|
||||
List<Object?> get props => [field, version];
|
||||
}
|
||||
|
||||
/// Save now, not after the pause: the app goes to the background, say.
|
||||
final class const StepEditorEventSaveRequested() extends StepEditorEvent;
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
|
||||
// The import format of a learning path (decided 2026-10-02): hand-written
|
||||
// JSON, its content in simple blocks that are turned into rich text here, so
|
||||
// a change of the editor's own format never breaks a file written before.
|
||||
//
|
||||
// {
|
||||
// "path": {
|
||||
// "title": "Particles", "secondary_title": "助詞", "description": "…",
|
||||
// "steps": [
|
||||
// {"title": "…", "type": "info", "content": [blocks]},
|
||||
// {"title": "…", "type": "single_choice", "content": [blocks],
|
||||
// "options": ["…"], "correct_option_index": 0,
|
||||
// "correct_feedback": [blocks], "incorrect_feedback": [blocks]},
|
||||
// {"type": "multiple_choice", "options": […], "correct_option_indices": […]},
|
||||
// {"type": "drag_and_drop", "items": […], "targets": […],
|
||||
// "correct_pairing": {"0": [0, 2], "1": [1]}},
|
||||
// {"type": "fill_in_the_blank", "passage": "… ___ …", "blanks": ["…"]}
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Blocks: {"type": "paragraph" | "heading" (with "level"), "text": "…"} or
|
||||
// with "runs": [{"text", "bold", "italic", "link", "math"}]; "bulleted_list"
|
||||
// and "numbered_list" with "items" (text or {"runs"}); "code" ("code") and
|
||||
// "math" ("formula") as monospaced text, the editor having no such blocks
|
||||
// yet. "image" and "lottie" are left out, keeping an "alt" caption: their
|
||||
// files would have to be fetched from the web, which the app never does.
|
||||
|
||||
/// A file in the import format that couldn't be read: [message] says why.
|
||||
final class const PathImportException(
|
||||
/// What is wrong, and where.
|
||||
final String message,
|
||||
) implements Exception {
|
||||
@override
|
||||
String toString() => 'PathImportException: $message';
|
||||
}
|
||||
|
||||
/// A path read from the import format, not stored yet.
|
||||
typedef ParsedPath = ({
|
||||
LearningPathContent content,
|
||||
List<({StepKind kind, StepContent content})> steps,
|
||||
int skippedBlocks,
|
||||
});
|
||||
|
||||
/// What an import stored: the path, how many steps, how many blocks were
|
||||
/// left out.
|
||||
typedef ImportedPath = ({String pathId, int steps, int skippedBlocks});
|
||||
|
||||
/// Stores learning paths from files in the import format.
|
||||
class PathImporter(final LearningPathRepository _paths) {
|
||||
/// Reads [source] and stores its path at the end of [studysetId], with its
|
||||
/// steps; a [PathImportException] for a file it can't read, before
|
||||
/// anything is stored.
|
||||
Future<ImportedPath> import(String studysetId, String source) async {
|
||||
final parsed = parsePathImport(source);
|
||||
final pathId = await _paths.createPath(studysetId, parsed.content);
|
||||
for (final (:kind, :content) in parsed.steps) {
|
||||
await _paths.createStep(pathId, kind, content);
|
||||
}
|
||||
return (
|
||||
pathId: pathId,
|
||||
steps: parsed.steps.length,
|
||||
skippedBlocks: parsed.skippedBlocks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The path in [source], in the import format; a [PathImportException]
|
||||
/// saying what is wrong otherwise.
|
||||
ParsedPath parsePathImport(String source) {
|
||||
final Object? json;
|
||||
try {
|
||||
json = jsonDecode(source);
|
||||
} on FormatException catch (error) {
|
||||
throw PathImportException('This is no JSON: ${error.message}.');
|
||||
}
|
||||
final path = switch (json) {
|
||||
{'path': final Map<String, Object?> path} => path,
|
||||
_ => throw const PathImportException('A "path" object is missing.'),
|
||||
};
|
||||
final title = switch (path['title']) {
|
||||
final String title when title.trim().isNotEmpty => title,
|
||||
_ => throw const PathImportException('The path has no "title".'),
|
||||
};
|
||||
final blocks = _Blocks();
|
||||
final steps = [
|
||||
for (final (index, step) in _list(
|
||||
path['steps'],
|
||||
'the path\'s "steps"',
|
||||
).indexed)
|
||||
_step(step, index + 1, blocks),
|
||||
];
|
||||
return (
|
||||
content: LearningPathContent(
|
||||
title: title,
|
||||
secondaryTitle: _optionalString(path['secondary_title'], 'the path'),
|
||||
description: switch (path['description']) {
|
||||
null => RichContent.empty(),
|
||||
final String text => RichContent.plain(text),
|
||||
_ => throw const PathImportException(
|
||||
'The path\'s "description" is no text.',
|
||||
),
|
||||
},
|
||||
).normalized,
|
||||
steps: steps,
|
||||
skippedBlocks: blocks.skipped,
|
||||
);
|
||||
}
|
||||
|
||||
({StepKind kind, StepContent content}) _step(
|
||||
Object? json,
|
||||
int number,
|
||||
_Blocks blocks,
|
||||
) {
|
||||
final where = 'step $number';
|
||||
final step = switch (json) {
|
||||
final Map<String, Object?> step => step,
|
||||
_ => throw PathImportException('The $where is no object.'),
|
||||
};
|
||||
final kind = switch (step['type']) {
|
||||
'info' => StepKind.info,
|
||||
'single_choice' => StepKind.singleChoice,
|
||||
'multiple_choice' => StepKind.multipleChoice,
|
||||
'drag_and_drop' => StepKind.dragAndDrop,
|
||||
'fill_in_the_blank' => StepKind.fillInTheBlank,
|
||||
final type => throw PathImportException(
|
||||
'The $where has an unknown "type": $type.',
|
||||
),
|
||||
};
|
||||
final exercise = switch (kind) {
|
||||
StepKind.info => null,
|
||||
StepKind.singleChoice => () {
|
||||
final options = _strings(step['options'], where, 'options');
|
||||
final correct = _int(step['correct_option_index'], where);
|
||||
if (correct >= options.length) {
|
||||
throw PathImportException(
|
||||
"The $where's right option $correct is not among its options.",
|
||||
);
|
||||
}
|
||||
return SingleChoice(options: options, correct: correct);
|
||||
}(),
|
||||
StepKind.multipleChoice => () {
|
||||
final options = _strings(step['options'], where, 'options');
|
||||
final correct = {
|
||||
for (final index in _list(step['correct_option_indices'], where))
|
||||
_int(index, where),
|
||||
};
|
||||
if (correct.any((index) => index >= options.length)) {
|
||||
throw PathImportException(
|
||||
'The $where names a right option that is not among its options.',
|
||||
);
|
||||
}
|
||||
return MultipleChoice(options: options, correct: correct);
|
||||
}(),
|
||||
StepKind.dragAndDrop => () {
|
||||
final items = _strings(step['items'], where, 'items');
|
||||
final targets = _strings(step['targets'], where, 'targets');
|
||||
final targetOf = <int, int>{};
|
||||
final pairing = switch (step['correct_pairing']) {
|
||||
final Map<String, Object?> pairing => pairing,
|
||||
_ => throw PathImportException(
|
||||
'The $where has no "correct_pairing".',
|
||||
),
|
||||
};
|
||||
for (final MapEntry(key: target, value: placed) in pairing.entries) {
|
||||
final at = int.tryParse(target);
|
||||
if (at == null || at >= targets.length) {
|
||||
throw PathImportException(
|
||||
"The $where pairs items with a target that isn't there: $target.",
|
||||
);
|
||||
}
|
||||
for (final item in _list(placed, where)) {
|
||||
targetOf[_int(item, where)] = at;
|
||||
}
|
||||
}
|
||||
if (targetOf.length != items.length ||
|
||||
targetOf.keys.any((item) => item >= items.length)) {
|
||||
throw PathImportException(
|
||||
'The $where must pair every item with one target.',
|
||||
);
|
||||
}
|
||||
return DragAndDrop(
|
||||
targets: targets,
|
||||
items: [
|
||||
for (final (index, item) in items.indexed)
|
||||
DragItem(text: item, target: targetOf[index]!),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
StepKind.fillInTheBlank => () {
|
||||
final passage = switch (step['passage']) {
|
||||
final String passage => passage,
|
||||
_ => throw PathImportException('The $where has no "passage".'),
|
||||
};
|
||||
final exercise = FillInTheBlank(
|
||||
passage: passage,
|
||||
blanks: _strings(step['blanks'], where, 'blanks'),
|
||||
);
|
||||
if (!exercise.isComplete) {
|
||||
throw PathImportException(
|
||||
'The $where needs one word in "blanks" for each ___ in its passage.',
|
||||
);
|
||||
}
|
||||
return exercise;
|
||||
}(),
|
||||
};
|
||||
return (
|
||||
kind: kind,
|
||||
content: StepContent(
|
||||
title: _optionalString(step['title'], where) ?? '',
|
||||
body: blocks.read(step['content'], where),
|
||||
exercise: exercise,
|
||||
rightFeedback: blocks.read(step['correct_feedback'], where),
|
||||
wrongFeedback: blocks.read(step['incorrect_feedback'], where),
|
||||
).normalized,
|
||||
);
|
||||
}
|
||||
|
||||
/// Turns the format's blocks into rich text, counting what it leaves out.
|
||||
class _Blocks() {
|
||||
/// How many blocks were left out.
|
||||
int skipped = 0;
|
||||
|
||||
RichContent read(Object? json, String where) {
|
||||
if (json == null) {
|
||||
return RichContent.empty();
|
||||
}
|
||||
final nodes = [
|
||||
for (final block in _list(json, where)) ...?_nodes(block, where),
|
||||
];
|
||||
return nodes.isEmpty
|
||||
? RichContent.empty()
|
||||
: RichContent({
|
||||
'document': {'type': 'page', 'children': nodes},
|
||||
});
|
||||
}
|
||||
|
||||
/// The editor's nodes for [block]; null for a block left out.
|
||||
List<Map<String, Object?>>? _nodes(Object? block, String where) {
|
||||
final type = switch (block) {
|
||||
{'type': final String type} => type,
|
||||
_ => throw PathImportException('The $where has a block without "type".'),
|
||||
};
|
||||
final json = block as Map<String, Object?>;
|
||||
switch (type) {
|
||||
case 'paragraph':
|
||||
return [_text('paragraph', json, where)];
|
||||
case 'heading':
|
||||
final node = _text('heading', json, where);
|
||||
final level = switch (json['level']) {
|
||||
final int level when level >= 1 && level <= 6 => level,
|
||||
null => 2,
|
||||
_ => throw PathImportException('The $where has a malformed level.'),
|
||||
};
|
||||
return [
|
||||
{
|
||||
'type': 'heading',
|
||||
'data': {
|
||||
'level': level,
|
||||
...(node['data']! as Map<String, Object?>),
|
||||
},
|
||||
},
|
||||
];
|
||||
case 'bulleted_list' || 'numbered_list':
|
||||
return [
|
||||
for (final item in _list(json['items'], where))
|
||||
{
|
||||
'type': type,
|
||||
'data': {
|
||||
'delta': switch (item) {
|
||||
final String text => _plain(text),
|
||||
{'runs': final Object? runs} => _runs(runs, where),
|
||||
_ => throw PathImportException(
|
||||
'The $where has a malformed list item.',
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
case 'code' || 'math':
|
||||
final source = json[type == 'code' ? 'code' : 'formula'];
|
||||
if (source is! String) {
|
||||
throw PathImportException('The $where has a malformed $type block.');
|
||||
}
|
||||
return [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': source,
|
||||
'attributes': {'code': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
case 'image' || 'lottie':
|
||||
skipped++;
|
||||
return switch (json['alt']) {
|
||||
final String alt when alt.trim().isNotEmpty => [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': alt,
|
||||
'attributes': {'italic': true},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
_ => null,
|
||||
};
|
||||
default:
|
||||
throw PathImportException('The $where has an unknown block: $type.');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _text(
|
||||
String type,
|
||||
Map<String, Object?> json,
|
||||
String where,
|
||||
) => {
|
||||
'type': type,
|
||||
'data': {
|
||||
'delta': switch (json) {
|
||||
{'text': final String text} => _plain(text),
|
||||
{'runs': final Object? runs} => _runs(runs, where),
|
||||
_ => throw PathImportException(
|
||||
'The $where has a $type without "text" or "runs".',
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
List<Map<String, Object?>> _plain(String text) => [
|
||||
if (text.isNotEmpty) {'insert': text},
|
||||
];
|
||||
|
||||
List<Map<String, Object?>> _runs(Object? json, String where) => [
|
||||
for (final run in _list(json, where))
|
||||
switch (run) {
|
||||
{'math': final String formula} => {
|
||||
'insert': formula,
|
||||
'attributes': {'code': true},
|
||||
},
|
||||
{'text': final String text} => {
|
||||
'insert': text,
|
||||
if ({
|
||||
if (run['bold'] == true) 'bold': true,
|
||||
if (run['italic'] == true) 'italic': true,
|
||||
if (run['link'] case final String link) 'href': link,
|
||||
}
|
||||
case final attributes when attributes.isNotEmpty)
|
||||
'attributes': attributes,
|
||||
},
|
||||
_ => throw PathImportException('The $where has a malformed run.'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
List<Object?> _list(Object? json, String where) => switch (json) {
|
||||
final List<Object?> list => list,
|
||||
null => const [],
|
||||
_ => throw PathImportException('In $where, a list was expected.'),
|
||||
};
|
||||
|
||||
List<String> _strings(Object? json, String where, String field) {
|
||||
final strings = [
|
||||
for (final value in _list(json, where))
|
||||
switch (value) {
|
||||
final String text => text,
|
||||
_ => throw PathImportException('The $where\'s "$field" must be text.'),
|
||||
},
|
||||
];
|
||||
if (strings.length < 2 && field != 'blanks' && field != 'items') {
|
||||
throw PathImportException('The $where needs at least two "$field".');
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
int _int(Object? json, String where) => switch (json) {
|
||||
final int number when number >= 0 => number,
|
||||
_ => throw PathImportException('The $where has a malformed number.'),
|
||||
};
|
||||
|
||||
String? _optionalString(Object? json, String where) => switch (json) {
|
||||
null => null,
|
||||
final String text => text,
|
||||
_ => throw PathImportException('In $where, a title must be text.'),
|
||||
};
|
||||
|
|
@ -17,6 +17,14 @@ final class const LearningPathContent({
|
|||
factory titled(String title) =>
|
||||
LearningPathContent(title: title, description: RichContent.empty());
|
||||
|
||||
/// This content with [secondaryTitle] as its secondary title.
|
||||
LearningPathContent withSecondaryTitle(String? secondaryTitle) =>
|
||||
LearningPathContent(
|
||||
title: title,
|
||||
secondaryTitle: secondaryTitle,
|
||||
description: description,
|
||||
);
|
||||
|
||||
/// This content as it is stored: titles without surrounding spaces, a
|
||||
/// blank secondary title none.
|
||||
LearningPathContent get normalized {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,459 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How a pick shows once the answer was checked: only the person's own
|
||||
/// picks are marked (decided 2026-10-02); what they didn't pick is never
|
||||
/// revealed.
|
||||
enum PickMark() {
|
||||
/// Not checked, or not picked.
|
||||
none,
|
||||
|
||||
/// Picked and right.
|
||||
right,
|
||||
|
||||
/// Picked and wrong.
|
||||
wrong,
|
||||
}
|
||||
|
||||
/// The mark of the pick [index] after [grade]; none before a check.
|
||||
PickMark markOf(Grade? grade, int index, {required bool picked}) {
|
||||
if (grade == null || !picked) {
|
||||
return PickMark.none;
|
||||
}
|
||||
return grade.wrongPicks.contains(index) ? PickMark.wrong : PickMark.right;
|
||||
}
|
||||
|
||||
/// [exercise] to answer: [answer] so far, reported anew through
|
||||
/// [onAnswered]; checked by [grade], when it was.
|
||||
class const ExerciseView({
|
||||
required final String stepId,
|
||||
required final Exercise exercise,
|
||||
required final ExerciseAnswer? answer,
|
||||
required final Grade? grade,
|
||||
required final ValueChanged<ExerciseAnswer> onAnswered,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (exercise) {
|
||||
SingleChoice(:final options) => _Choices(
|
||||
options: options,
|
||||
multiple: false,
|
||||
selected: switch (answer) {
|
||||
SingleChoiceAnswer(:final selected) => {selected},
|
||||
_ => const {},
|
||||
},
|
||||
grade: grade,
|
||||
onTap: (index) => onAnswered(SingleChoiceAnswer(index)),
|
||||
),
|
||||
MultipleChoice(:final options) => _Choices(
|
||||
options: options,
|
||||
multiple: true,
|
||||
selected: switch (answer) {
|
||||
MultipleChoiceAnswer(:final selected) => selected,
|
||||
_ => const {},
|
||||
},
|
||||
grade: grade,
|
||||
onTap: (index) {
|
||||
final selected = switch (answer) {
|
||||
MultipleChoiceAnswer(:final selected) => {...selected},
|
||||
_ => <int>{},
|
||||
};
|
||||
if (!selected.remove(index)) {
|
||||
selected.add(index);
|
||||
}
|
||||
onAnswered(MultipleChoiceAnswer(selected));
|
||||
},
|
||||
),
|
||||
final DragAndDrop exercise => DragAndDropView(
|
||||
exercise: exercise,
|
||||
placements: switch (answer) {
|
||||
DragAndDropAnswer(:final placements) => placements,
|
||||
_ => const {},
|
||||
},
|
||||
grade: grade,
|
||||
onChanged: (placements) => onAnswered(DragAndDropAnswer(placements)),
|
||||
),
|
||||
final FillInTheBlank exercise => FillInTheBlankView(
|
||||
seed: stepId.hashCode,
|
||||
exercise: exercise,
|
||||
words: switch (answer) {
|
||||
FillInTheBlankAnswer(:final words) => words,
|
||||
_ => List<String?>.filled(exercise.gaps, null),
|
||||
},
|
||||
grade: grade,
|
||||
onChanged: (words) => onAnswered(FillInTheBlankAnswer(words)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// The options of a choice, as cards: a radio or a checkbox each.
|
||||
class const _Choices({
|
||||
required final List<String> options,
|
||||
required final bool multiple,
|
||||
required final Set<int> selected,
|
||||
required final Grade? grade,
|
||||
required final ValueChanged<int> onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (multiple)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
l10n.runnerSelectAll,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final (index, option) in options.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _MarkedCard(
|
||||
mark: markOf(grade, index, picked: selected.contains(index)),
|
||||
selected: selected.contains(index),
|
||||
onTap: () => onTap(index),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(switch ((multiple, selected.contains(index))) {
|
||||
(false, true) => LucideIcons.circleDot,
|
||||
(false, false) => LucideIcons.circle,
|
||||
(true, true) => LucideIcons.squareCheck,
|
||||
(true, false) => LucideIcons.square,
|
||||
}, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(option)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A card that shows whether it is chosen, and once checked, whether that
|
||||
/// was right.
|
||||
class const _MarkedCard({
|
||||
required final PickMark mark,
|
||||
required final bool selected,
|
||||
required final VoidCallback? onTap,
|
||||
required final Widget child,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final extras = theme.extras;
|
||||
final border = switch (mark) {
|
||||
PickMark.right => extras.success,
|
||||
PickMark.wrong => colors.error,
|
||||
PickMark.none => selected ? colors.primary : colors.outlineVariant,
|
||||
};
|
||||
return Material(
|
||||
color: switch (mark) {
|
||||
PickMark.right => extras.success.withValues(alpha: 0.12),
|
||||
PickMark.wrong => colors.error.withValues(alpha: 0.12),
|
||||
PickMark.none =>
|
||||
selected ? colors.primary.withValues(alpha: 0.08) : colors.surface,
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: border, width: selected ? 2 : 1),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(padding: const EdgeInsets.all(14), child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Items to drag into their categories, or to tap and then place by tapping
|
||||
/// a category; a placed item goes back to the pool when tapped.
|
||||
class const DragAndDropView({
|
||||
/// What to sort.
|
||||
required final DragAndDrop exercise,
|
||||
|
||||
/// The category each placed item is in, by the item's index.
|
||||
required final Map<int, int> placements,
|
||||
|
||||
/// How the last check came out; null until checked.
|
||||
required final Grade? grade,
|
||||
|
||||
/// Called with the placements after every move.
|
||||
required final ValueChanged<Map<int, int>> onChanged,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<DragAndDropView> createState() => _DragAndDropViewState();
|
||||
}
|
||||
|
||||
class _DragAndDropViewState() extends State<DragAndDropView> {
|
||||
/// The item tapped in the pool, waiting for a category.
|
||||
int? _picked;
|
||||
|
||||
void _place(int item, int target) {
|
||||
setState(() => _picked = null);
|
||||
widget.onChanged({...widget.placements, item: target});
|
||||
}
|
||||
|
||||
void _unplace(int item) =>
|
||||
widget.onChanged({...widget.placements}..remove(item));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final items = widget.exercise.items;
|
||||
final pool = [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
if (!widget.placements.containsKey(i)) i,
|
||||
];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.runnerDragHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final item in pool)
|
||||
Draggable<int>(
|
||||
data: item,
|
||||
feedback: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: _Chip(text: items[item].text, selected: true),
|
||||
),
|
||||
childWhenDragging: Opacity(
|
||||
opacity: 0.3,
|
||||
child: _Chip(text: items[item].text),
|
||||
),
|
||||
child: _Chip(
|
||||
text: items[item].text,
|
||||
selected: _picked == item,
|
||||
onTap: () => setState(
|
||||
() => _picked = _picked == item ? null : item,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (target, name) in widget.exercise.targets.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: DragTarget<int>(
|
||||
onAcceptWithDetails: (details) => _place(details.data, target),
|
||||
builder: (context, candidates, _) => _MarkedCard(
|
||||
mark: PickMark.none,
|
||||
selected: candidates.isNotEmpty,
|
||||
onTap: _picked == null ? null : () => _place(_picked!, target),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final MapEntry(key: item, value: at)
|
||||
in widget.placements.entries)
|
||||
if (at == target)
|
||||
_Chip(
|
||||
text: items[item].text,
|
||||
mark: markOf(widget.grade, item, picked: true),
|
||||
onTap: () => _unplace(item),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A passage with gaps to fill from a pool of words: dragged into a gap, or
|
||||
/// tapped into the first empty one; a filled gap gives its word back when
|
||||
/// tapped. The pool is shuffled the same way every time, from [seed].
|
||||
class const FillInTheBlankView({
|
||||
required final int seed,
|
||||
required final FillInTheBlank exercise,
|
||||
required final List<String?> words,
|
||||
required final Grade? grade,
|
||||
required final ValueChanged<List<String?>> onChanged,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final shuffled = [...exercise.blanks]..shuffle(Random(seed));
|
||||
// What is left in the pool: every word, less one of each placed.
|
||||
final pool = [...shuffled];
|
||||
for (final word in words) {
|
||||
if (word != null) {
|
||||
pool.remove(word);
|
||||
}
|
||||
}
|
||||
void fill(int gap, String word) {
|
||||
final next = [...words];
|
||||
if (next[gap] case final old?) {
|
||||
pool.add(old);
|
||||
}
|
||||
next[gap] = word;
|
||||
onChanged(next);
|
||||
}
|
||||
|
||||
final parts = exercise.parts;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.runnerBlankHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var gap = 0; gap < exercise.gaps; gap++) ...[
|
||||
Text(parts[gap], style: theme.textTheme.bodyLarge),
|
||||
DragTarget<String>(
|
||||
onAcceptWithDetails: (details) => fill(gap, details.data),
|
||||
builder: (context, candidates, _) => _Gap(
|
||||
word: words[gap],
|
||||
highlighted: candidates.isNotEmpty,
|
||||
mark: markOf(grade, gap, picked: words[gap] != null),
|
||||
onTap: words[gap] == null
|
||||
? null
|
||||
: () => onChanged([...words]..[gap] = null),
|
||||
),
|
||||
),
|
||||
],
|
||||
Text(parts.last, style: theme.textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final word in pool)
|
||||
Draggable<String>(
|
||||
data: word,
|
||||
feedback: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: _Chip(text: word, selected: true),
|
||||
),
|
||||
childWhenDragging: Opacity(
|
||||
opacity: 0.3,
|
||||
child: _Chip(text: word),
|
||||
),
|
||||
child: _Chip(
|
||||
text: word,
|
||||
onTap: () {
|
||||
final empty = words.indexOf(null);
|
||||
if (empty >= 0) {
|
||||
fill(empty, word);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A gap in a passage: empty, or holding a word.
|
||||
class const _Gap({
|
||||
required final String? word,
|
||||
required final bool highlighted,
|
||||
required final PickMark mark,
|
||||
required final VoidCallback? onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
if (word case final word?) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: _Chip(text: word, mark: mark, onTap: onTap),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 72,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: highlighted ? colors.primary : colors.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A word or an item to move: tappable, and marked once checked.
|
||||
class const _Chip({
|
||||
required final String text,
|
||||
final bool selected = false,
|
||||
final PickMark mark = PickMark.none,
|
||||
final VoidCallback? onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final extras = theme.extras;
|
||||
final color = switch (mark) {
|
||||
PickMark.right => extras.success,
|
||||
PickMark.wrong => colors.error,
|
||||
PickMark.none => selected ? colors.primary : colors.outline,
|
||||
};
|
||||
return ActionChip(
|
||||
label: Text(text),
|
||||
onPressed: onTap,
|
||||
side: BorderSide(
|
||||
color: color,
|
||||
width: selected || mark != PickMark.none ? 2 : 1,
|
||||
),
|
||||
backgroundColor: switch (mark) {
|
||||
PickMark.right => extras.success.withValues(alpha: 0.12),
|
||||
PickMark.wrong => colors.error.withValues(alpha: 0.12),
|
||||
PickMark.none => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
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/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Writes a learning path of the studyset [studysetId] in the whole window,
|
||||
/// over the tabs: its title, secondary title, description and tags, and its
|
||||
/// steps, added by kind, reordered and deleted with an undo
|
||||
/// (`PathEditorBloc`). A step added opens at once.
|
||||
class const PathEditorScreen({
|
||||
required final String studysetId,
|
||||
required final String pathId,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<PathEditorScreen> createState() => _PathEditorScreenState();
|
||||
}
|
||||
|
||||
class _PathEditorScreenState() extends State<PathEditorScreen> {
|
||||
final _title = TextEditingController();
|
||||
final _secondary = TextEditingController();
|
||||
var _reordering = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_secondary.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocConsumer<PathEditorBloc, PathEditorState>(
|
||||
listenWhen: (before, now) =>
|
||||
before.added != now.added || before.saved != now.saved,
|
||||
listener: (context, state) {
|
||||
if (state.added case final added? when added != _opened) {
|
||||
_opened = added;
|
||||
context.push(
|
||||
RoutePaths.pathStep(widget.studysetId, widget.pathId, added),
|
||||
);
|
||||
}
|
||||
// Stored content the person hasn't changed shows in the fields.
|
||||
if (!state.isDirty) {
|
||||
_sync(_title, state.content.title);
|
||||
_sync(_secondary, state.content.secondaryTitle ?? '');
|
||||
}
|
||||
},
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.pathEditTitle)),
|
||||
body: switch (state) {
|
||||
PathEditorState(loaded: false) => const Center(
|
||||
child: AppLoadingIndicator(),
|
||||
),
|
||||
PathEditorState(gone: true) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
_ => _form(context, state),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _opened;
|
||||
|
||||
void _sync(TextEditingController controller, String text) {
|
||||
if (controller.text != text) {
|
||||
controller.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _form(BuildContext context, PathEditorState state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final bloc = context.read<PathEditorBloc>();
|
||||
final description = state.content.description;
|
||||
final chosenTags = [
|
||||
for (final tag in state.tagging.all)
|
||||
if (state.tagIds.contains(tag.id)) tag,
|
||||
];
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _title,
|
||||
style: theme.textTheme.titleLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.pathTitleLabel,
|
||||
errorText: state.content.title.trim().isEmpty
|
||||
? l10n.pathTitleRequired
|
||||
: null,
|
||||
),
|
||||
onChanged: (text) => bloc.add(PathEditorEventTitleChanged(text)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _secondary,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.pathSecondaryTitleLabel,
|
||||
hintText: l10n.pathSecondaryTitleHint,
|
||||
),
|
||||
onChanged: (text) =>
|
||||
bloc.add(PathEditorEventSecondaryTitleChanged(text)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.text),
|
||||
title: Text(
|
||||
description.isBlank
|
||||
? l10n.pathAddDescription
|
||||
: l10n.pathDescription,
|
||||
),
|
||||
subtitle: description.isBlank
|
||||
? null
|
||||
: Text(
|
||||
description.plainText.trim(),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () async {
|
||||
final edited = await editRichText(
|
||||
context,
|
||||
description,
|
||||
title: l10n.pathDescriptionEditTitle,
|
||||
);
|
||||
if (edited != null) {
|
||||
bloc.add(PathEditorEventDescriptionChanged(edited));
|
||||
}
|
||||
},
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final tag in chosenTags) TagChip(tag: tag),
|
||||
TextButton.icon(
|
||||
icon: const Icon(LucideIcons.tag, size: 16),
|
||||
label: Text(
|
||||
chosenTags.isEmpty ? l10n.pathAddTags : l10n.pathTags,
|
||||
),
|
||||
onPressed: () => showTagPicker(
|
||||
context,
|
||||
selected: state.tagIds,
|
||||
onChanged: (tagIds) =>
|
||||
bloc.add(PathEditorEventTagsChanged(tagIds)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.pathSteps,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (state.steps.length > 1)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _reordering = !_reordering),
|
||||
child: Text(
|
||||
_reordering ? l10n.pathReorderDone : l10n.pathReorder,
|
||||
),
|
||||
),
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
for (final kind in StepKind.values)
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(stepKindIcon(kind)),
|
||||
onPressed: () =>
|
||||
bloc.add(PathEditorEventStepAdded(kind)),
|
||||
child: Text(stepKindLabel(l10n, kind)),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => FilledButton.tonalIcon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.pathAddStep),
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (state.steps.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.pathStepsEmpty,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pathStepsEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_reordering)
|
||||
ReorderableListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: (from, to) => bloc.add(
|
||||
PathEditorEventStepMoved(state.steps[from].id, to),
|
||||
),
|
||||
children: [
|
||||
for (final (index, step) in state.steps.indexed)
|
||||
ListTile(
|
||||
key: ValueKey(step.id),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(LucideIcons.gripVertical),
|
||||
),
|
||||
title: _StepTitle(step: step),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
for (final step in state.steps)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(stepKindIcon(step.kind)),
|
||||
title: _StepTitle(step: step),
|
||||
subtitle: Text(stepKindLabel(l10n, step.kind)),
|
||||
onTap: () => context.push(
|
||||
RoutePaths.pathStep(
|
||||
widget.studysetId,
|
||||
widget.pathId,
|
||||
step.id,
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
tooltip: l10n.pathStepDelete,
|
||||
onPressed: () => _deleteStep(context, step),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hands the step's deletion to the person's step deletions and offers to
|
||||
/// undo it for a moment.
|
||||
void _deleteStep(BuildContext context, LearningPathStep step) {
|
||||
final deletions = PersonScope.of(context).stepDeletions..delete(step.id);
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: AppLocalizations.of(context).pathStepDeleted,
|
||||
onUndo: () => deletions.undo(step.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A step's title, or "Untitled step".
|
||||
class const _StepTitle({required final LearningPathStep step})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
step.content.title.isEmpty
|
||||
? AppLocalizations.of(context).stepUntitled
|
||||
: step.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
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/dependencies/person_scope.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/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The studyset [studysetId]'s learning paths, from the [PathListBloc]
|
||||
/// above: each opens to be worked through; completed ones are dimmed; with
|
||||
/// none, it says so.
|
||||
class const PathList({required final String studysetId, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<PathListBloc, PathListState>(
|
||||
builder: (context, state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
if (!state.loaded) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.paths.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.pathsEmpty, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pathsEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final (index, path) in state.paths.indexed) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
_PathTile(
|
||||
studysetId: studysetId,
|
||||
path: path,
|
||||
completed: state.completed.contains(path.id),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// One path: its title and secondary title, dimmed once completed; its
|
||||
/// options behind the … button, or a long press.
|
||||
class const _PathTile({
|
||||
required final String studysetId,
|
||||
required final LearningPath path,
|
||||
required final bool completed,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final error = Theme.of(context).colorScheme.error;
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.pencil),
|
||||
onPressed: () =>
|
||||
context.push(RoutePaths.editPath(studysetId, path.id)),
|
||||
child: Text(l10n.pathEdit),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.rotateCcw),
|
||||
onPressed: () => _reset(context),
|
||||
child: Text(l10n.pathResetProgress),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(LucideIcons.trash2, color: error),
|
||||
onPressed: () => _delete(context),
|
||||
child: Text(l10n.pathDelete, style: TextStyle(color: error)),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => Opacity(
|
||||
opacity: completed ? 0.5 : 1,
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
completed ? LucideIcons.circleCheck : LucideIcons.route,
|
||||
),
|
||||
title: Text(
|
||||
path.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: switch (path.content.secondaryTitle) {
|
||||
null => null,
|
||||
final secondary => Text(
|
||||
secondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
},
|
||||
onTap: () => context.push(RoutePaths.path(studysetId, path.id)),
|
||||
onLongPress: menu.open,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.pathOptions,
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reset(BuildContext context) {
|
||||
final bloc = context.read<PathListBloc>()
|
||||
..add(PathListEventResetRequested(path.id));
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: AppLocalizations.of(context).pathProgressReset,
|
||||
onUndo: () => bloc.add(const PathListEventResetUndone()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks, then hands the deletion to the person's path deletions and offers
|
||||
/// to undo it for a moment; the deletion outlives this tile.
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final deletions = PersonScope.of(context).pathDeletions;
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.pathDeleteTitle,
|
||||
message: l10n.pathDeleteMessage,
|
||||
confirmLabel: l10n.pathDeleteConfirm,
|
||||
) ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
deletions.delete(path.id);
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.pathDeleted,
|
||||
onUndo: () => deletions.undo(path.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for a new path's title and secondary title; completes with them,
|
||||
/// or with null when closed without creating.
|
||||
Future<LearningPathContent?> askForNewPath(BuildContext context) =>
|
||||
showAdaptiveSheet<LearningPathContent>(
|
||||
context,
|
||||
builder: (context) => const _NewPathSheet(),
|
||||
);
|
||||
|
||||
class const _NewPathSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_NewPathSheet> createState() => _NewPathSheetState();
|
||||
}
|
||||
|
||||
class _NewPathSheetState() extends State<_NewPathSheet> {
|
||||
final _title = TextEditingController();
|
||||
final _secondary = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_secondary.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _create() {
|
||||
if (_title.text.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(
|
||||
LearningPathContent.titled(_title.text)
|
||||
.withSecondaryTitle(_secondary.text),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return SheetScaffold(
|
||||
title: l10n.pathCreateTitle,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _title,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.pathTitleLabel),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _secondary,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.pathSecondaryTitleLabel,
|
||||
hintText: l10n.pathSecondaryTitleHint,
|
||||
),
|
||||
onSubmitted: (_) => _create(),
|
||||
),
|
||||
],
|
||||
),
|
||||
action: FilledButton(
|
||||
onPressed: _title.text.trim().isEmpty ? null : _create,
|
||||
child: Text(l10n.pathCreate),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
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/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A learning path of the studyset [studysetId], one step at a time, in the
|
||||
/// whole window over the tabs: a bar of the steps on top, the step in the
|
||||
/// middle, and Back and the way on below (`PathRunnerBloc`).
|
||||
class const PathRunnerScreen({
|
||||
required final String studysetId,
|
||||
required final String pathId,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocBuilder<PathRunnerBloc, PathRunnerState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
tooltip: l10n.runnerClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
title: state.steps.isEmpty || state.finished
|
||||
? null
|
||||
: _StepsBar(state: state),
|
||||
actions: [
|
||||
if (state.step case final step? when !state.finished)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
tooltip: l10n.runnerEditStep,
|
||||
onPressed: () => context.push(
|
||||
RoutePaths.pathStep(studysetId, pathId, step.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: switch (state) {
|
||||
PathRunnerState(loaded: false) => const Center(
|
||||
child: AppLoadingIndicator(),
|
||||
),
|
||||
PathRunnerState(finished: true) => _Finished(
|
||||
state: state,
|
||||
studysetId: studysetId,
|
||||
),
|
||||
PathRunnerState(step: null) => Center(child: Text(l10n.runnerEmpty)),
|
||||
PathRunnerState(:final step?) => _Step(step: step, state: state),
|
||||
},
|
||||
bottomNavigationBar: state.step == null || state.finished
|
||||
? null
|
||||
: _Footer(state: state),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One pill per step: done, shown, or still ahead.
|
||||
class const _StepsBar({required final PathRunnerState state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
for (final (index, step) in state.steps.indexed)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: switch (index) {
|
||||
_ when index == state.index => colors.primary,
|
||||
_ when state.progress[step.id]?.done ?? false =>
|
||||
colors.primary.withValues(alpha: 0.45),
|
||||
_ => colors.surfaceContainerHighest,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The step shown: its title, text and exercise, then how a check came out.
|
||||
class const _Step({
|
||||
required final LearningPathStep step,
|
||||
required final PathRunnerState state,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final content = step.content;
|
||||
final exercise = content.exercise;
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (content.title.isNotEmpty)
|
||||
Text(content.title, style: theme.textTheme.titleLarge),
|
||||
if (!content.body.isBlank) ...[
|
||||
const SizedBox(height: 8),
|
||||
RichContentView(content: content.body),
|
||||
],
|
||||
if (content.sources.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: ActionChip(
|
||||
avatar: const Icon(LucideIcons.bookOpen, size: 16),
|
||||
label: Text(
|
||||
l10n.stepSourcesCount(content.sources.length),
|
||||
),
|
||||
onPressed: () =>
|
||||
_showSources(context, content.sources),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.alreadyDone && state.grade == null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.runnerAlreadyRight,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.extras.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (exercise != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ExerciseView(
|
||||
key: ValueKey(step.id),
|
||||
stepId: step.id,
|
||||
exercise: exercise,
|
||||
answer: state.answer,
|
||||
grade: state.grade,
|
||||
onAnswered: (answer) => context
|
||||
.read<PathRunnerBloc>()
|
||||
.add(PathRunnerEventAnswered(answer)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.grade case final grade?) ...[
|
||||
const SizedBox(height: 12),
|
||||
_Feedback(
|
||||
right: grade.right,
|
||||
feedback: grade.right
|
||||
? content.rightFeedback
|
||||
: content.wrongFeedback,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSources(BuildContext context, List<StepSource> sources) =>
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: AppLocalizations.of(context).stepSources,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final source in sources)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SelectableText(citationOf(source)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [source] as a citation: "Tae Kim (2014). A Guide to Japanese Grammar.
|
||||
/// https://…", leaving out what it hasn't got.
|
||||
String citationOf(StepSource source) => [
|
||||
[
|
||||
if (source.authors.isNotEmpty) source.authors,
|
||||
if (source.year.isNotEmpty) '(${source.year})',
|
||||
].join(' '),
|
||||
source.title,
|
||||
source.url,
|
||||
].where((part) => part.isNotEmpty).join('. ');
|
||||
|
||||
/// How a check came out, and what the author has to say to it.
|
||||
class const _Feedback({
|
||||
required final bool right,
|
||||
required final RichContent feedback,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final color = right ? theme.extras.success : theme.colorScheme.error;
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: color),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
right ? LucideIcons.circleCheck : LucideIcons.circleX,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
right ? l10n.runnerRight : l10n.runnerWrong,
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!feedback.isBlank) ...[
|
||||
const SizedBox(height: 8),
|
||||
RichContentView(content: feedback),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Back, and the way on: check the answer, go on, or finish.
|
||||
class const _Footer({required final PathRunnerState state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
void send(PathRunnerEvent event) =>
|
||||
context.read<PathRunnerBloc>().add(event);
|
||||
final checking = !state.canContinue;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.index > 0)
|
||||
TextButton.icon(
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
label: Text(l10n.runnerBack),
|
||||
onPressed: () => send(const PathRunnerEventBack()),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: switch (checking) {
|
||||
true when state.canCheck => () => send(
|
||||
const PathRunnerEventCheckRequested(),
|
||||
),
|
||||
true => null,
|
||||
false => () => send(const PathRunnerEventContinued()),
|
||||
},
|
||||
child: Text(switch (checking) {
|
||||
true => l10n.runnerCheck,
|
||||
false when state.isLast => l10n.runnerFinish,
|
||||
false => l10n.runnerContinue,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The path finished: how many steps, and where to go next.
|
||||
class const _Finished({
|
||||
required final PathRunnerState state,
|
||||
required final String studysetId,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.partyPopper,
|
||||
size: 56,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.runnerFinished, style: theme.textTheme.headlineSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.runnerFinishedSteps(state.steps.length)),
|
||||
const SizedBox(height: 24),
|
||||
if (state.nextPathId case final next?)
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
context.pushReplacement(RoutePaths.path(studysetId, next)),
|
||||
child: Text(l10n.runnerNextPath),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
child: Text(l10n.runnerBackToStudyset),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,744 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Writes a step of a learning path in the whole window, over the tabs: its
|
||||
/// title and text, its kind's exercise, the feedback after a right and a
|
||||
/// wrong answer, and its sources (`StepEditorBloc`). A step devices changed
|
||||
/// differently shows the versions to keep first.
|
||||
class const StepEditorScreen({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<StepEditorScreen> createState() => _StepEditorScreenState();
|
||||
}
|
||||
|
||||
class _StepEditorScreenState() extends State<StepEditorScreen> {
|
||||
late final AppLifecycleListener _lifecycle;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Hidden, the app may be ended without a word: save first.
|
||||
_lifecycle = AppLifecycleListener(
|
||||
onHide: () => context.read<StepEditorBloc>().add(
|
||||
const StepEditorEventSaveRequested(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lifecycle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocBuilder<StepEditorBloc, StepEditorState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
state.loaded
|
||||
? '${l10n.stepEditTitle} · ${stepKindLabel(l10n, state.kind)}'
|
||||
: l10n.stepEditTitle,
|
||||
),
|
||||
actions: [_SaveStatus(status: state.status)],
|
||||
),
|
||||
body: switch (state) {
|
||||
StepEditorState(loaded: false) => const Center(
|
||||
child: AppLoadingIndicator(),
|
||||
),
|
||||
StepEditorState(gone: true) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
_ => _Form(state: state),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Form({required final StepEditorState state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final content = state.content;
|
||||
final readOnly = state.unresolved.isNotEmpty;
|
||||
void change(StepField field, Object? value) => context
|
||||
.read<StepEditorBloc>()
|
||||
.add(StepEditorEventChanged(field, value));
|
||||
final exercise = content.exercise;
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (readOnly) ...[
|
||||
_Conflicts(state: state),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextFormField(
|
||||
key: ValueKey('title-${state.revision}'),
|
||||
initialValue: content.title,
|
||||
readOnly: readOnly,
|
||||
style: theme.textTheme.titleLarge,
|
||||
decoration: InputDecoration(hintText: l10n.stepTitleHint),
|
||||
onChanged: (text) => change(StepField.title, text),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: readOnly
|
||||
? SingleChildScrollView(
|
||||
child: RichContentView(content: content.body),
|
||||
)
|
||||
: RichContentEditor(
|
||||
key: ValueKey('body-${state.revision}'),
|
||||
initialContent: content.body,
|
||||
onChanged: (body) => change(StepField.body, body),
|
||||
formatBar: true,
|
||||
),
|
||||
),
|
||||
if (exercise != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
IgnorePointer(
|
||||
ignoring: readOnly,
|
||||
child: _ExerciseEditor(
|
||||
key: ValueKey('exercise-${state.revision}'),
|
||||
exercise: exercise,
|
||||
onChanged: (exercise) => change(StepField.exercise, exercise),
|
||||
),
|
||||
),
|
||||
if (!exercise.isComplete)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
l10n.stepIncomplete,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FeedbackTile(
|
||||
label: l10n.stepFeedbackRight,
|
||||
feedback: content.rightFeedback,
|
||||
onChanged: readOnly
|
||||
? null
|
||||
: (feedback) => change(StepField.rightFeedback, feedback),
|
||||
),
|
||||
_FeedbackTile(
|
||||
label: l10n.stepFeedbackWrong,
|
||||
feedback: content.wrongFeedback,
|
||||
onChanged: readOnly
|
||||
? null
|
||||
: (feedback) => change(StepField.wrongFeedback, feedback),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_Sources(
|
||||
sources: content.sources,
|
||||
onChanged: readOnly
|
||||
? null
|
||||
: (sources) => change(StepField.sources, sources),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An exercise of any kind, to write.
|
||||
class const _ExerciseEditor({
|
||||
required final Exercise exercise,
|
||||
required final ValueChanged<Exercise> onChanged,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (exercise) {
|
||||
SingleChoice(:final options, :final correct) => _OptionsEditor(
|
||||
options: options,
|
||||
correct: {?correct},
|
||||
multiple: false,
|
||||
onChanged: (options, correct) => onChanged(
|
||||
SingleChoice(options: options, correct: correct.firstOrNull),
|
||||
),
|
||||
),
|
||||
MultipleChoice(:final options, :final correct) => _OptionsEditor(
|
||||
options: options,
|
||||
correct: correct,
|
||||
multiple: true,
|
||||
onChanged: (options, correct) =>
|
||||
onChanged(MultipleChoice(options: options, correct: correct)),
|
||||
),
|
||||
final DragAndDrop exercise => _DragAndDropEditor(
|
||||
exercise: exercise,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
final FillInTheBlank exercise => _FillInTheBlankEditor(
|
||||
exercise: exercise,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Options to choose from, each with whether it is right: one of them for
|
||||
/// a single choice, any for a multiple one. At least two options.
|
||||
class const _OptionsEditor({
|
||||
required final List<String> options,
|
||||
required final Set<int> correct,
|
||||
required final bool multiple,
|
||||
required final void Function(List<String> options, Set<int> correct)
|
||||
onChanged,
|
||||
}) extends StatelessWidget {
|
||||
static const _most = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.stepOptions, style: theme.textTheme.titleSmall),
|
||||
for (final (index, option) in options.indexed)
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: multiple
|
||||
? l10n.stepCorrectOptions
|
||||
: l10n.stepCorrectOption,
|
||||
isSelected: correct.contains(index),
|
||||
icon: Icon(switch ((multiple, correct.contains(index))) {
|
||||
(false, true) => LucideIcons.circleDot,
|
||||
(false, false) => LucideIcons.circle,
|
||||
(true, true) => LucideIcons.squareCheck,
|
||||
(true, false) => LucideIcons.square,
|
||||
}),
|
||||
onPressed: () => onChanged(
|
||||
options,
|
||||
switch ((multiple, correct.contains(index))) {
|
||||
(false, _) => {index},
|
||||
(true, true) => {...correct}..remove(index),
|
||||
(true, false) => {...correct, index},
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
key: ValueKey('option-$index-of-${options.length}'),
|
||||
initialValue: option,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.stepOptionHint(index + 1),
|
||||
),
|
||||
onChanged: (text) =>
|
||||
onChanged([...options]..[index] = text, correct),
|
||||
),
|
||||
),
|
||||
if (options.length > 2)
|
||||
IconButton(
|
||||
tooltip: l10n.stepRemoveOption,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => onChanged(
|
||||
[...options]..removeAt(index),
|
||||
{
|
||||
for (final at in correct)
|
||||
if (at > index) at - 1 else if (at < index) at,
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (options.length < _most)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.stepAddOption),
|
||||
onPressed: () => onChanged([...options, ''], correct),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Categories, and the items that belong in each. At least two
|
||||
/// categories.
|
||||
class const _DragAndDropEditor({
|
||||
required final DragAndDrop exercise,
|
||||
required final ValueChanged<Exercise> onChanged,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final targets = exercise.targets;
|
||||
final items = exercise.items;
|
||||
void emit({List<String>? targets, List<DragItem>? items}) => onChanged(
|
||||
DragAndDrop(
|
||||
targets: targets ?? exercise.targets,
|
||||
items: items ?? exercise.items,
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.stepCategories, style: theme.textTheme.titleSmall),
|
||||
for (final (index, target) in targets.indexed)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
key: ValueKey('target-$index-of-${targets.length}'),
|
||||
initialValue: target,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.stepCategoryHint(index + 1),
|
||||
),
|
||||
onChanged: (text) =>
|
||||
emit(targets: [...targets]..[index] = text),
|
||||
),
|
||||
),
|
||||
if (targets.length > 2)
|
||||
IconButton(
|
||||
tooltip: l10n.stepRemoveCategory,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => emit(
|
||||
targets: [...targets]..removeAt(index),
|
||||
// Its items go to the first category left.
|
||||
items: [
|
||||
for (final item in items)
|
||||
DragItem(
|
||||
text: item.text,
|
||||
target: switch (item.target) {
|
||||
final at when at == index => 0,
|
||||
final at when at > index => at - 1,
|
||||
final at => at,
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.stepAddCategory),
|
||||
onPressed: () => emit(targets: [...targets, '']),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.stepItems, style: theme.textTheme.titleSmall),
|
||||
for (final (index, item) in items.indexed)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
key: ValueKey('item-$index-of-${items.length}'),
|
||||
initialValue: item.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.stepItemHint(index + 1),
|
||||
),
|
||||
onChanged: (text) => emit(
|
||||
items: [...items]
|
||||
..[index] = DragItem(text: text, target: item.target),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<int>(
|
||||
value: item.target < targets.length ? item.target : 0,
|
||||
hint: Text(l10n.stepItemCategory),
|
||||
items: [
|
||||
for (final (at, target) in targets.indexed)
|
||||
DropdownMenuItem(
|
||||
value: at,
|
||||
child: Text(
|
||||
target.isEmpty ? l10n.stepCategoryHint(at + 1) : target,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (at) => emit(
|
||||
items: [...items]
|
||||
..[index] = DragItem(text: item.text, target: at ?? 0),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.stepRemoveItem,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => emit(items: [...items]..removeAt(index)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.stepAddItem),
|
||||
onPressed: () => emit(
|
||||
items: [
|
||||
...items,
|
||||
const DragItem(text: '', target: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A passage with gaps, and the word for each gap.
|
||||
class const _FillInTheBlankEditor({
|
||||
required final FillInTheBlank exercise,
|
||||
required final ValueChanged<Exercise> onChanged,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final blanks = exercise.blanks;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.stepPassage, style: theme.textTheme.titleSmall),
|
||||
TextFormField(
|
||||
initialValue: exercise.passage,
|
||||
minLines: 2,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(hintText: l10n.stepPassageHint),
|
||||
onChanged: (passage) {
|
||||
// One word per gap: as many as the passage has, those written
|
||||
// kept.
|
||||
final gaps = gapMarker.allMatches(passage).length;
|
||||
onChanged(
|
||||
FillInTheBlank(
|
||||
passage: passage,
|
||||
blanks: [
|
||||
for (var gap = 0; gap < gaps; gap++)
|
||||
if (gap < blanks.length) blanks[gap] else '',
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
for (var gap = 0; gap < blanks.length; gap++)
|
||||
TextFormField(
|
||||
key: ValueKey('blank-$gap-of-${blanks.length}'),
|
||||
initialValue: blanks[gap],
|
||||
decoration: InputDecoration(hintText: l10n.stepBlankHint(gap + 1)),
|
||||
onChanged: (word) => onChanged(
|
||||
FillInTheBlank(
|
||||
passage: exercise.passage,
|
||||
blanks: [...blanks]..[gap] = word,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A feedback text: what it says so far, written in the whole window.
|
||||
class const _FeedbackTile({
|
||||
required final String label,
|
||||
required final RichContent feedback,
|
||||
required final ValueChanged<RichContent>? onChanged,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final onChanged = this.onChanged;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.messageSquare),
|
||||
title: Text(label),
|
||||
subtitle: Text(
|
||||
feedback.isBlank ? l10n.stepFeedbackNone : feedback.plainText.trim(),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: onChanged == null
|
||||
? null
|
||||
: () async {
|
||||
if (await editRichText(context, feedback, title: label)
|
||||
case final edited?) {
|
||||
onChanged(edited);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The works the step draws on: each as a citation, removable, and a way
|
||||
/// to add one.
|
||||
class const _Sources({
|
||||
required final List<StepSource> sources,
|
||||
required final ValueChanged<List<StepSource>>? onChanged,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final onChanged = this.onChanged;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.stepSources, style: theme.textTheme.titleSmall),
|
||||
for (final (index, source) in sources.indexed)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(citationOf(source)),
|
||||
trailing: onChanged == null
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: l10n.stepRemoveSource,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => onChanged([...sources]..removeAt(index)),
|
||||
),
|
||||
),
|
||||
if (onChanged != null)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.stepAddSource),
|
||||
onPressed: () async {
|
||||
final source = await showAdaptiveSheet<StepSource>(
|
||||
context,
|
||||
builder: (context) => const _SourceSheet(),
|
||||
);
|
||||
if (source != null) {
|
||||
onChanged([...sources, source]);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _SourceSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_SourceSheet> createState() => _SourceSheetState();
|
||||
}
|
||||
|
||||
class _SourceSheetState() extends State<_SourceSheet> {
|
||||
final _authors = TextEditingController();
|
||||
final _year = TextEditingController();
|
||||
final _title = TextEditingController();
|
||||
final _url = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in [_authors, _year, _title, _url]) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
Widget field(TextEditingController controller, String label) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: label),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
);
|
||||
return SheetScaffold(
|
||||
title: l10n.stepAddSource,
|
||||
body: Column(
|
||||
children: [
|
||||
field(_authors, l10n.stepSourceAuthors),
|
||||
field(_year, l10n.stepSourceYear),
|
||||
field(_title, l10n.stepSourceTitle),
|
||||
field(_url, l10n.stepSourceUrl),
|
||||
],
|
||||
),
|
||||
action: FilledButton(
|
||||
onPressed: _title.text.trim().isEmpty
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
StepSource(
|
||||
authors: _authors.text.trim(),
|
||||
year: _year.text.trim(),
|
||||
title: _title.text.trim(),
|
||||
url: _url.text.trim(),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.stepAddSource),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields devices changed differently, each with every version and a
|
||||
/// way to keep one; writing goes on once every field has one.
|
||||
class const _Conflicts({required final StepEditorState state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.stepConflict,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
for (final field in state.unresolved)
|
||||
for (final version in state.conflicts[field]!)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_fieldName(l10n, field),
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
_Version(version: version),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
context.read<StepEditorBloc>().add(
|
||||
StepEditorEventVersionChosen(
|
||||
field,
|
||||
version,
|
||||
),
|
||||
),
|
||||
child: Text(l10n.noteKeepThis),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fieldName(AppLocalizations l10n, StepField field) => switch (field) {
|
||||
StepField.title => l10n.stepFieldTitle,
|
||||
StepField.body => l10n.stepFieldBody,
|
||||
StepField.exercise => l10n.stepFieldExercise,
|
||||
StepField.rightFeedback => l10n.stepFieldRightFeedback,
|
||||
StepField.wrongFeedback => l10n.stepFieldWrongFeedback,
|
||||
StepField.sources => l10n.stepFieldSources,
|
||||
};
|
||||
}
|
||||
|
||||
/// One version of a field in conflict, as plain as it can be shown.
|
||||
class const _Version({required final Object? version}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (version) {
|
||||
final RichContent content => RichContentView(content: content),
|
||||
SingleChoice(:final options, :final correct) => Text(
|
||||
[
|
||||
for (final (i, option) in options.indexed)
|
||||
'${i == correct ? '✓' : '·'} $option',
|
||||
].join('\n'),
|
||||
),
|
||||
MultipleChoice(:final options, :final correct) => Text(
|
||||
[
|
||||
for (final (i, option) in options.indexed)
|
||||
'${correct.contains(i) ? '✓' : '·'} $option',
|
||||
].join('\n'),
|
||||
),
|
||||
DragAndDrop(:final targets, :final items) => Text(
|
||||
[
|
||||
for (final (t, target) in targets.indexed)
|
||||
'$target: ${[
|
||||
for (final item in items)
|
||||
if (item.target == t) item.text,
|
||||
].join(', ')}',
|
||||
].join('\n'),
|
||||
),
|
||||
FillInTheBlank(:final passage, :final blanks) => Text(
|
||||
'$passage\n${blanks.join(', ')}',
|
||||
),
|
||||
final List<StepSource> sources => Text(
|
||||
sources.map(citationOf).join('\n'),
|
||||
),
|
||||
_ => Text('${version ?? ''}'),
|
||||
};
|
||||
}
|
||||
|
||||
/// How saving stands, in the app bar: nothing before the first change.
|
||||
class const _SaveStatus({required final StepSaveStatus status})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final label = switch (status) {
|
||||
StepSaveStatus.clean => null,
|
||||
StepSaveStatus.editing => l10n.noteEditing,
|
||||
StepSaveStatus.saving => l10n.noteSaving,
|
||||
StepSaveStatus.saved => l10n.noteSaved,
|
||||
StepSaveStatus.failed => l10n.noteSaveFailed,
|
||||
};
|
||||
if (label == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: status == StepSaveStatus.failed
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue