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:
Tizian 「ティツィアーン」 2026-10-03 09:25:08 +02:00
commit e4bfe3bdeb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 6310 additions and 65 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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