feat(app): the card composer, for many cards in one sitting
Create flashcards, and tapping a card, open the composer: the stack of cards on the left (New flashcard on top, the new ones, then the stored ones; collapsible), the card being written on the right, saved as the person leaves it (another card, the pane, the screen, the app). The title says how saving stands; leaving with cards that couldn't be saved asks first. A card has its tags and their pin, an action menu (duplicate, clear, delete), the type switch (flashcard, vocabulary, matching), the front, and below a line its answer side: one answer or several to mark right or wrong, the reading and meanings, or categories and items. Fields changed differently on two devices show every version to keep one. The single-card editor goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a5515c13f7
commit
0afc22d454
24 changed files with 2701 additions and 735 deletions
|
|
@ -171,6 +171,83 @@
|
||||||
"cardListMore": "More",
|
"cardListMore": "More",
|
||||||
"cardListManageTags": "Manage tags",
|
"cardListManageTags": "Manage tags",
|
||||||
|
|
||||||
|
"composerTitle": "Create flashcards",
|
||||||
|
"composerTitleFor": "Create flashcards for \"{title}\"",
|
||||||
|
"@composerTitleFor": {"placeholders": {"title": {"type": "String"}}},
|
||||||
|
"composerSaving": "Saving…",
|
||||||
|
"composerSaveFailed": "Save failed — retry",
|
||||||
|
"composerAllSaved": "All changes saved",
|
||||||
|
"composerNewFlashcard": "New flashcard",
|
||||||
|
"composerCollapseStack": "Collapse stack",
|
||||||
|
"composerExpandStack": "Expand stack",
|
||||||
|
"composerStatusSaving": "Saving",
|
||||||
|
"composerStatusSaved": "Saved",
|
||||||
|
"composerStatusFailed": "Save failed",
|
||||||
|
"composerSaveFailedInline": "Save failed.",
|
||||||
|
"composerRetry": "Retry",
|
||||||
|
"composerEmptyPane": "Add a flashcard to get started.",
|
||||||
|
"composerKindFlashcard": "Flashcard",
|
||||||
|
"composerKindVocabulary": "Vocabulary",
|
||||||
|
"composerKindMatching": "Matching",
|
||||||
|
"composerKindMap": "Map",
|
||||||
|
"composerLabelFront": "Front",
|
||||||
|
"composerLabelQuestion": "Question",
|
||||||
|
"composerLabelExpression": "Expression",
|
||||||
|
"composerLabelPrompt": "Prompt",
|
||||||
|
"composerLabelBack": "Back",
|
||||||
|
"composerLabelAnswers": "Answers",
|
||||||
|
"composerLabelAnswer": "Answer",
|
||||||
|
"composerPlaceholderFront": "Add question",
|
||||||
|
"composerPlaceholderExpression": "Add expression",
|
||||||
|
"composerPlaceholderPrompt": "Add prompt",
|
||||||
|
"composerAddAnswer": "Add answer",
|
||||||
|
"composerCorrectAnswer": "Correct answer",
|
||||||
|
"composerWrongAnswer": "Wrong answer",
|
||||||
|
"composerRemoveAnswer": "Remove answer",
|
||||||
|
"composerPin": "Pin {field}: keep it for the next card",
|
||||||
|
"@composerPin": {"placeholders": {"field": {"type": "String"}}},
|
||||||
|
"composerUnpin": "Unpin {field}: clear it for the next card",
|
||||||
|
"@composerUnpin": {"placeholders": {"field": {"type": "String"}}},
|
||||||
|
"composerTags": "Tags",
|
||||||
|
"composerAddTags": "Add tags",
|
||||||
|
"composerMoreActions": "More actions",
|
||||||
|
"composerDuplicate": "Duplicate",
|
||||||
|
"composerClearFields": "Clear fields",
|
||||||
|
"composerDelete": "Delete",
|
||||||
|
"composerDeleteTitle": "Delete this flashcard?",
|
||||||
|
"composerDeleteDraftMessage": "The draft will be removed from this session. It has not been saved.",
|
||||||
|
"composerDeleteSavedMessage": "The flashcard will be removed from the studyset, on all your devices.",
|
||||||
|
"composerDiscardTitle": "{count, plural, =1{Discard unsaved flashcard?} other{Discard unsaved flashcards?}}",
|
||||||
|
"@composerDiscardTitle": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"composerDiscardMessage": "{count, plural, =1{1 flashcard hasn't been saved yet. Leaving now will discard it.} other{{count} flashcards haven't been saved yet. Leaving now will discard them.}}",
|
||||||
|
"@composerDiscardMessage": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"composerDiscardConfirm": "Discard",
|
||||||
|
"composerConflict": "This flashcard was changed differently on two devices. Choose the version to keep of each part below.",
|
||||||
|
"composerKeepThis": "Keep this",
|
||||||
|
"vocabularyReading": "Reading",
|
||||||
|
"vocabularyReadingHint": "Kana reading",
|
||||||
|
"vocabularyMeanings": "Meanings",
|
||||||
|
"vocabularyPrimaryMeaningHint": "Primary meaning",
|
||||||
|
"vocabularyMeaningHint": "Meaning",
|
||||||
|
"vocabularyAddMeaning": "Add meaning",
|
||||||
|
"vocabularyRemoveMeaning": "Remove meaning",
|
||||||
|
"matchingCategories": "Categories",
|
||||||
|
"matchingCategoriesHint": "The drop-zone cards learners drag items onto. Each category needs at least one item, so a card can't have more categories than items.",
|
||||||
|
"matchingCategory": "Category {number}",
|
||||||
|
"@matchingCategory": {"placeholders": {"number": {"type": "int"}}},
|
||||||
|
"matchingAddCategory": "Add category",
|
||||||
|
"matchingAddCategoryBlocked": "Add an item first: a card can't have more categories than items",
|
||||||
|
"matchingRemoveCategory": "Remove category",
|
||||||
|
"matchingMinCategories": "At least two categories required",
|
||||||
|
"matchingItems": "Items",
|
||||||
|
"matchingItemsHint": "The draggable items. Assign every item to its correct category: an unassigned item can never be placed right.",
|
||||||
|
"matchingItem": "Item {number}",
|
||||||
|
"@matchingItem": {"placeholders": {"number": {"type": "int"}}},
|
||||||
|
"matchingAddItem": "Add item",
|
||||||
|
"matchingUnassigned": "Unassigned",
|
||||||
|
"matchingRemoveItem": "Remove item",
|
||||||
|
"matchingMinItems": "At least two items required",
|
||||||
|
|
||||||
"commonCancel": "Cancel",
|
"commonCancel": "Cancel",
|
||||||
"commonClose": "Close",
|
"commonClose": "Close",
|
||||||
"commonUndo": "Undo",
|
"commonUndo": "Undo",
|
||||||
|
|
|
||||||
|
|
@ -970,6 +970,420 @@ abstract class AppLocalizations {
|
||||||
/// **'Manage tags'**
|
/// **'Manage tags'**
|
||||||
String get cardListManageTags;
|
String get cardListManageTags;
|
||||||
|
|
||||||
|
/// No description provided for @composerTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create flashcards'**
|
||||||
|
String get composerTitle;
|
||||||
|
|
||||||
|
/// No description provided for @composerTitleFor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create flashcards for \"{title}\"'**
|
||||||
|
String composerTitleFor(String title);
|
||||||
|
|
||||||
|
/// No description provided for @composerSaving.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saving…'**
|
||||||
|
String get composerSaving;
|
||||||
|
|
||||||
|
/// No description provided for @composerSaveFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save failed — retry'**
|
||||||
|
String get composerSaveFailed;
|
||||||
|
|
||||||
|
/// No description provided for @composerAllSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All changes saved'**
|
||||||
|
String get composerAllSaved;
|
||||||
|
|
||||||
|
/// No description provided for @composerNewFlashcard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New flashcard'**
|
||||||
|
String get composerNewFlashcard;
|
||||||
|
|
||||||
|
/// No description provided for @composerCollapseStack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Collapse stack'**
|
||||||
|
String get composerCollapseStack;
|
||||||
|
|
||||||
|
/// No description provided for @composerExpandStack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Expand stack'**
|
||||||
|
String get composerExpandStack;
|
||||||
|
|
||||||
|
/// No description provided for @composerStatusSaving.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saving'**
|
||||||
|
String get composerStatusSaving;
|
||||||
|
|
||||||
|
/// No description provided for @composerStatusSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saved'**
|
||||||
|
String get composerStatusSaved;
|
||||||
|
|
||||||
|
/// No description provided for @composerStatusFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save failed'**
|
||||||
|
String get composerStatusFailed;
|
||||||
|
|
||||||
|
/// No description provided for @composerSaveFailedInline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save failed.'**
|
||||||
|
String get composerSaveFailedInline;
|
||||||
|
|
||||||
|
/// No description provided for @composerRetry.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Retry'**
|
||||||
|
String get composerRetry;
|
||||||
|
|
||||||
|
/// No description provided for @composerEmptyPane.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a flashcard to get started.'**
|
||||||
|
String get composerEmptyPane;
|
||||||
|
|
||||||
|
/// No description provided for @composerKindFlashcard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Flashcard'**
|
||||||
|
String get composerKindFlashcard;
|
||||||
|
|
||||||
|
/// No description provided for @composerKindVocabulary.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Vocabulary'**
|
||||||
|
String get composerKindVocabulary;
|
||||||
|
|
||||||
|
/// No description provided for @composerKindMatching.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Matching'**
|
||||||
|
String get composerKindMatching;
|
||||||
|
|
||||||
|
/// No description provided for @composerKindMap.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Map'**
|
||||||
|
String get composerKindMap;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelFront.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Front'**
|
||||||
|
String get composerLabelFront;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelQuestion.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Question'**
|
||||||
|
String get composerLabelQuestion;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelExpression.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Expression'**
|
||||||
|
String get composerLabelExpression;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelPrompt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Prompt'**
|
||||||
|
String get composerLabelPrompt;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelBack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back'**
|
||||||
|
String get composerLabelBack;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelAnswers.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Answers'**
|
||||||
|
String get composerLabelAnswers;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelAnswer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Answer'**
|
||||||
|
String get composerLabelAnswer;
|
||||||
|
|
||||||
|
/// No description provided for @composerPlaceholderFront.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add question'**
|
||||||
|
String get composerPlaceholderFront;
|
||||||
|
|
||||||
|
/// No description provided for @composerPlaceholderExpression.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add expression'**
|
||||||
|
String get composerPlaceholderExpression;
|
||||||
|
|
||||||
|
/// No description provided for @composerPlaceholderPrompt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add prompt'**
|
||||||
|
String get composerPlaceholderPrompt;
|
||||||
|
|
||||||
|
/// No description provided for @composerAddAnswer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add answer'**
|
||||||
|
String get composerAddAnswer;
|
||||||
|
|
||||||
|
/// No description provided for @composerCorrectAnswer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Correct answer'**
|
||||||
|
String get composerCorrectAnswer;
|
||||||
|
|
||||||
|
/// No description provided for @composerWrongAnswer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Wrong answer'**
|
||||||
|
String get composerWrongAnswer;
|
||||||
|
|
||||||
|
/// No description provided for @composerRemoveAnswer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove answer'**
|
||||||
|
String get composerRemoveAnswer;
|
||||||
|
|
||||||
|
/// No description provided for @composerPin.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pin {field}: keep it for the next card'**
|
||||||
|
String composerPin(String field);
|
||||||
|
|
||||||
|
/// No description provided for @composerUnpin.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unpin {field}: clear it for the next card'**
|
||||||
|
String composerUnpin(String field);
|
||||||
|
|
||||||
|
/// No description provided for @composerTags.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tags'**
|
||||||
|
String get composerTags;
|
||||||
|
|
||||||
|
/// No description provided for @composerAddTags.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add tags'**
|
||||||
|
String get composerAddTags;
|
||||||
|
|
||||||
|
/// No description provided for @composerMoreActions.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'More actions'**
|
||||||
|
String get composerMoreActions;
|
||||||
|
|
||||||
|
/// No description provided for @composerDuplicate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Duplicate'**
|
||||||
|
String get composerDuplicate;
|
||||||
|
|
||||||
|
/// No description provided for @composerClearFields.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear fields'**
|
||||||
|
String get composerClearFields;
|
||||||
|
|
||||||
|
/// No description provided for @composerDelete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete'**
|
||||||
|
String get composerDelete;
|
||||||
|
|
||||||
|
/// No description provided for @composerDeleteTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete this flashcard?'**
|
||||||
|
String get composerDeleteTitle;
|
||||||
|
|
||||||
|
/// No description provided for @composerDeleteDraftMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The draft will be removed from this session. It has not been saved.'**
|
||||||
|
String get composerDeleteDraftMessage;
|
||||||
|
|
||||||
|
/// No description provided for @composerDeleteSavedMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The flashcard will be removed from the studyset, on all your devices.'**
|
||||||
|
String get composerDeleteSavedMessage;
|
||||||
|
|
||||||
|
/// No description provided for @composerDiscardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{Discard unsaved flashcard?} other{Discard unsaved flashcards?}}'**
|
||||||
|
String composerDiscardTitle(int count);
|
||||||
|
|
||||||
|
/// No description provided for @composerDiscardMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 flashcard hasn\'t been saved yet. Leaving now will discard it.} other{{count} flashcards haven\'t been saved yet. Leaving now will discard them.}}'**
|
||||||
|
String composerDiscardMessage(int count);
|
||||||
|
|
||||||
|
/// No description provided for @composerDiscardConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard'**
|
||||||
|
String get composerDiscardConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @composerConflict.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This flashcard was changed differently on two devices. Choose the version to keep of each part below.'**
|
||||||
|
String get composerConflict;
|
||||||
|
|
||||||
|
/// No description provided for @composerKeepThis.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep this'**
|
||||||
|
String get composerKeepThis;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyReading.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reading'**
|
||||||
|
String get vocabularyReading;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyReadingHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kana reading'**
|
||||||
|
String get vocabularyReadingHint;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyMeanings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Meanings'**
|
||||||
|
String get vocabularyMeanings;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyPrimaryMeaningHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary meaning'**
|
||||||
|
String get vocabularyPrimaryMeaningHint;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyMeaningHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Meaning'**
|
||||||
|
String get vocabularyMeaningHint;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyAddMeaning.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add meaning'**
|
||||||
|
String get vocabularyAddMeaning;
|
||||||
|
|
||||||
|
/// No description provided for @vocabularyRemoveMeaning.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove meaning'**
|
||||||
|
String get vocabularyRemoveMeaning;
|
||||||
|
|
||||||
|
/// No description provided for @matchingCategories.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Categories'**
|
||||||
|
String get matchingCategories;
|
||||||
|
|
||||||
|
/// No description provided for @matchingCategoriesHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The drop-zone cards learners drag items onto. Each category needs at least one item, so a card can\'t have more categories than items.'**
|
||||||
|
String get matchingCategoriesHint;
|
||||||
|
|
||||||
|
/// No description provided for @matchingCategory.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Category {number}'**
|
||||||
|
String matchingCategory(int number);
|
||||||
|
|
||||||
|
/// No description provided for @matchingAddCategory.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add category'**
|
||||||
|
String get matchingAddCategory;
|
||||||
|
|
||||||
|
/// No description provided for @matchingAddCategoryBlocked.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add an item first: a card can\'t have more categories than items'**
|
||||||
|
String get matchingAddCategoryBlocked;
|
||||||
|
|
||||||
|
/// No description provided for @matchingRemoveCategory.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove category'**
|
||||||
|
String get matchingRemoveCategory;
|
||||||
|
|
||||||
|
/// No description provided for @matchingMinCategories.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'At least two categories required'**
|
||||||
|
String get matchingMinCategories;
|
||||||
|
|
||||||
|
/// No description provided for @matchingItems.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Items'**
|
||||||
|
String get matchingItems;
|
||||||
|
|
||||||
|
/// No description provided for @matchingItemsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The draggable items. Assign every item to its correct category: an unassigned item can never be placed right.'**
|
||||||
|
String get matchingItemsHint;
|
||||||
|
|
||||||
|
/// No description provided for @matchingItem.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Item {number}'**
|
||||||
|
String matchingItem(int number);
|
||||||
|
|
||||||
|
/// No description provided for @matchingAddItem.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add item'**
|
||||||
|
String get matchingAddItem;
|
||||||
|
|
||||||
|
/// No description provided for @matchingUnassigned.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unassigned'**
|
||||||
|
String get matchingUnassigned;
|
||||||
|
|
||||||
|
/// No description provided for @matchingRemoveItem.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove item'**
|
||||||
|
String get matchingRemoveItem;
|
||||||
|
|
||||||
|
/// No description provided for @matchingMinItems.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'At least two items required'**
|
||||||
|
String get matchingMinItems;
|
||||||
|
|
||||||
/// No description provided for @commonCancel.
|
/// No description provided for @commonCancel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,246 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardListManageTags => 'Manage tags';
|
String get cardListManageTags => 'Manage tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerTitle => 'Create flashcards';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String composerTitleFor(String title) {
|
||||||
|
return 'Create flashcards for \"$title\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerSaving => 'Saving…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerSaveFailed => 'Save failed — retry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerAllSaved => 'All changes saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerNewFlashcard => 'New flashcard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerCollapseStack => 'Collapse stack';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerExpandStack => 'Expand stack';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerStatusSaving => 'Saving';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerStatusSaved => 'Saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerStatusFailed => 'Save failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerSaveFailedInline => 'Save failed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerRetry => 'Retry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerEmptyPane => 'Add a flashcard to get started.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKindFlashcard => 'Flashcard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKindVocabulary => 'Vocabulary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKindMatching => 'Matching';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKindMap => 'Map';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelFront => 'Front';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelQuestion => 'Question';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelExpression => 'Expression';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelPrompt => 'Prompt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelBack => 'Back';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelAnswers => 'Answers';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelAnswer => 'Answer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerPlaceholderFront => 'Add question';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerPlaceholderExpression => 'Add expression';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerPlaceholderPrompt => 'Add prompt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerAddAnswer => 'Add answer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerCorrectAnswer => 'Correct answer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerWrongAnswer => 'Wrong answer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerRemoveAnswer => 'Remove answer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String composerPin(String field) {
|
||||||
|
return 'Pin $field: keep it for the next card';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String composerUnpin(String field) {
|
||||||
|
return 'Unpin $field: clear it for the next card';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerTags => 'Tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerAddTags => 'Add tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerMoreActions => 'More actions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerDuplicate => 'Duplicate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerClearFields => 'Clear fields';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerDelete => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerDeleteTitle => 'Delete this flashcard?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerDeleteDraftMessage =>
|
||||||
|
'The draft will be removed from this session. It has not been saved.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerDeleteSavedMessage =>
|
||||||
|
'The flashcard will be removed from the studyset, on all your devices.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String composerDiscardTitle(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Discard unsaved flashcards?',
|
||||||
|
one: 'Discard unsaved flashcard?',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String composerDiscardMessage(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other:
|
||||||
|
'$count flashcards haven\'t been saved yet. Leaving now will discard them.',
|
||||||
|
one: '1 flashcard hasn\'t been saved yet. Leaving now will discard it.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerDiscardConfirm => 'Discard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerConflict =>
|
||||||
|
'This flashcard was changed differently on two devices. Choose the version to keep of each part below.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKeepThis => 'Keep this';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyReading => 'Reading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyReadingHint => 'Kana reading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyMeanings => 'Meanings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyPrimaryMeaningHint => 'Primary meaning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyMeaningHint => 'Meaning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyAddMeaning => 'Add meaning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vocabularyRemoveMeaning => 'Remove meaning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingCategories => 'Categories';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingCategoriesHint =>
|
||||||
|
'The drop-zone cards learners drag items onto. Each category needs at least one item, so a card can\'t have more categories than items.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String matchingCategory(int number) {
|
||||||
|
return 'Category $number';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingAddCategory => 'Add category';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingAddCategoryBlocked =>
|
||||||
|
'Add an item first: a card can\'t have more categories than items';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingRemoveCategory => 'Remove category';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingMinCategories => 'At least two categories required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingItems => 'Items';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingItemsHint =>
|
||||||
|
'The draggable items. Assign every item to its correct category: an unassigned item can never be placed right.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String matchingItem(int number) {
|
||||||
|
return 'Item $number';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingAddItem => 'Add item';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingUnassigned => 'Unassigned';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingRemoveItem => 'Remove item';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get matchingMinItems => 'At least two items required';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get commonCancel => 'Cancel';
|
String get commonCancel => 'Cancel';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_screen.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_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/home/widget/home_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||||
|
|
@ -17,8 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||||
// Material's Card widget would clash with the domain's Card.
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
|
||||||
|
|
||||||
/// The router of one person's app: the four tabs in a shell, each with its own
|
/// The router of one person's app: the four tabs in a shell, each with its own
|
||||||
/// stack of screens, and the focused surfaces over them. Every route is built
|
/// stack of screens, and the focused surfaces over them. Every route is built
|
||||||
|
|
@ -102,39 +100,21 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Before the card route: its `new` would otherwise read as a card's id.
|
// The composer: writing a studyset's cards, opened on a new card or on
|
||||||
|
// one of them. Before the card route: its `new` would otherwise read as
|
||||||
|
// a card's id.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/studysets/:studysetId/cards/new',
|
path: '/studysets/:studysetId/cards/new',
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) =>
|
||||||
create: (context) => CardEditBloc(
|
_composer(context, state.pathParameters['studysetId']!),
|
||||||
PersonScope.of(context).cards,
|
|
||||||
PersonScope.of(context).tags,
|
|
||||||
state.pathParameters['studysetId']!,
|
|
||||||
null,
|
|
||||||
)..add(const CardEditEventStarted()),
|
|
||||||
child: const CardScreen(card: null),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/studysets/:studysetId/cards/:cardId',
|
path: '/studysets/:studysetId/cards/:cardId',
|
||||||
builder: (context, state) {
|
builder: (context, state) => _composer(
|
||||||
final person = PersonScope.of(context);
|
context,
|
||||||
final studysetId = state.pathParameters['studysetId']!;
|
state.pathParameters['studysetId']!,
|
||||||
return FoundById<Card>(
|
cardId: state.pathParameters['cardId'],
|
||||||
id: state.pathParameters['cardId']!,
|
|
||||||
items: () => person.cards.watch(studysetId),
|
|
||||||
idOf: (card) => card.id,
|
|
||||||
builder: (card, _) => BlocProvider(
|
|
||||||
create: (context) => CardEditBloc(
|
|
||||||
person.cards,
|
|
||||||
person.tags,
|
|
||||||
studysetId,
|
|
||||||
card,
|
|
||||||
)..add(const CardEditEventStarted()),
|
|
||||||
child: CardScreen(card: card),
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.settings,
|
path: RoutePaths.settings,
|
||||||
|
|
@ -149,3 +129,23 @@ Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
|
||||||
idOf: (studyset) => studyset.id,
|
idOf: (studyset) => studyset.id,
|
||||||
builder: (studyset, all) => StudysetScreen(studyset: studyset, all: all),
|
builder: (studyset, all) => StudysetScreen(studyset: studyset, all: all),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// The composer of the studyset [studysetId]'s cards, opened on [cardId] if
|
||||||
|
/// given; a studyset gone (deleted, or an old link) says so.
|
||||||
|
Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
|
||||||
|
final person = PersonScope.of(context);
|
||||||
|
return FoundById<Studyset>(
|
||||||
|
id: studysetId,
|
||||||
|
items: person.studysets.watchAll,
|
||||||
|
idOf: (studyset) => studyset.id,
|
||||||
|
builder: (studyset, _) => BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
CardComposerBloc(person.cards, person.tags, studysetId)
|
||||||
|
..add(const CardComposerEventStarted()),
|
||||||
|
child: CardComposerScreen(
|
||||||
|
studysetTitle: studyset.content.title,
|
||||||
|
initialCardId: cardId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ class const RichContentEditor({
|
||||||
|
|
||||||
/// Called with the whole content after every change to the document.
|
/// Called with the whole content after every change to the document.
|
||||||
required final ValueChanged<RichContent> onChanged,
|
required final ValueChanged<RichContent> onChanged,
|
||||||
|
|
||||||
|
/// Whether the editor takes the focus as it opens, its cursor at the end.
|
||||||
|
final bool autofocus = false,
|
||||||
|
|
||||||
|
/// The editor's focus, to follow or move it; one of its own otherwise.
|
||||||
|
final FocusNode? focusNode,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -48,8 +54,11 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) => AppFlowyEditor(
|
||||||
AppFlowyEditor(editorState: _editorState);
|
editorState: _editorState,
|
||||||
|
autoFocus: widget.autofocus,
|
||||||
|
focusNode: widget.focusNode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Document _editable(Document document) {
|
Document _editable(Document document) {
|
||||||
|
|
|
||||||
|
|
@ -440,9 +440,14 @@ final class const CardDraft({
|
||||||
(conflicts.isNotEmpty && unresolved.isEmpty),
|
(conflicts.isNotEmpty && unresolved.isEmpty),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Whether leaving it saves it: changed, complete, and every conflict
|
/// Whether leaving it saves it: changed, every conflict settled, and
|
||||||
/// settled.
|
/// complete; a stored card whose content is as stored (its tags changed,
|
||||||
bool get canSave => isDirty && content.isComplete && unresolved.isEmpty;
|
/// say) needn't be, as its content isn't written.
|
||||||
|
bool get canSave =>
|
||||||
|
isDirty &&
|
||||||
|
unresolved.isEmpty &&
|
||||||
|
(content.isComplete ||
|
||||||
|
(cardId != null && content.normalized == saved.normalized));
|
||||||
|
|
||||||
/// This draft with some fields replaced.
|
/// This draft with some fields replaced.
|
||||||
CardDraft copyWith({
|
CardDraft copyWith({
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_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';
|
|
||||||
|
|
||||||
/// Creates a card, or edits one: keeps the changes to its sides and tags
|
|
||||||
/// until they are saved, has the person choose a version for every side
|
|
||||||
/// devices changed differently, and deletes on request.
|
|
||||||
class CardEditBloc(
|
|
||||||
final CardRepository _cards,
|
|
||||||
final TagRepository _tags,
|
|
||||||
final String _studysetId,
|
|
||||||
Card? card,
|
|
||||||
) extends Bloc<CardEditEvent, CardEditState> {
|
|
||||||
this
|
|
||||||
: super(
|
|
||||||
CardEditState(
|
|
||||||
content: card?.content ?? CardContent.empty(),
|
|
||||||
card: card,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
on<CardEditEventStarted>(
|
|
||||||
(event, emit) => emit.forEach(
|
|
||||||
_tags.watch(),
|
|
||||||
onData: (tagging) {
|
|
||||||
// The card's tags as stored, once; later changes elsewhere don't
|
|
||||||
// overwrite the person's choice.
|
|
||||||
final stored = switch (state.card) {
|
|
||||||
final card? => {for (final tag in tagging.of(card.id)) tag.id},
|
|
||||||
null => <String>{},
|
|
||||||
};
|
|
||||||
return state.copyWith(
|
|
||||||
tagging: tagging,
|
|
||||||
savedTagIds: state.savedTagIds ?? stored,
|
|
||||||
tagIds: state.tagIds ?? stored,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Without tags the card can still be edited.
|
|
||||||
onError: (_, _) => state,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
on<CardEditEventTagsChanged>(
|
|
||||||
(event, emit) => emit(state.copyWith(tagIds: event.tagIds)),
|
|
||||||
);
|
|
||||||
on<CardEditEventSideChanged>(
|
|
||||||
(event, emit) => emit(_withSide(event.side, event.content)),
|
|
||||||
);
|
|
||||||
on<CardEditEventVersionChosen>(
|
|
||||||
(event, emit) => emit(_withSide(event.side, event.version)),
|
|
||||||
);
|
|
||||||
on<CardEditEventSaveRequested>(_onSaveRequested);
|
|
||||||
on<CardEditEventDeleteRequested>(_onDeleteRequested);
|
|
||||||
}
|
|
||||||
|
|
||||||
CardEditState _withSide(CardField side, Object? content) => state.copyWith(
|
|
||||||
content: state.content.withField(side, content),
|
|
||||||
resolvedSides: {...state.resolvedSides, side},
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _onSaveRequested(
|
|
||||||
CardEditEventSaveRequested event,
|
|
||||||
Emitter<CardEditState> emit,
|
|
||||||
) async {
|
|
||||||
if (!state.canSave) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final String id;
|
|
||||||
if (state.card case final card?) {
|
|
||||||
id = card.id;
|
|
||||||
if (state.contentChanged) {
|
|
||||||
await _cards.save(card, state.content);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
id = await _cards.create(_studysetId, state.content);
|
|
||||||
}
|
|
||||||
if (state.tagIds case final tagIds? when state.tagsChanged) {
|
|
||||||
await _tags.setTagsOf(id, tagIds);
|
|
||||||
}
|
|
||||||
emit(state.copyWith(isDone: true));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onDeleteRequested(
|
|
||||||
CardEditEventDeleteRequested event,
|
|
||||||
Emitter<CardEditState> emit,
|
|
||||||
) async {
|
|
||||||
if (state.card case final card?) {
|
|
||||||
await _cards.delete(card);
|
|
||||||
}
|
|
||||||
emit(state.copyWith(isDone: true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What happens while a card is edited.
|
|
||||||
sealed class const CardEditEvent() with Equatable {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The editor opens: follow the tags, to show and choose the card's.
|
|
||||||
final class const CardEditEventStarted() extends CardEditEvent;
|
|
||||||
|
|
||||||
/// The person chose the tags [tagIds] for the card.
|
|
||||||
final class const CardEditEventTagsChanged(
|
|
||||||
/// The ids of the tags chosen.
|
|
||||||
final Set<String> tagIds,
|
|
||||||
) extends CardEditEvent {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [tagIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [side] was edited to [content].
|
|
||||||
final class const CardEditEventSideChanged(
|
|
||||||
/// The side edited.
|
|
||||||
final CardField side,
|
|
||||||
|
|
||||||
/// Its new content.
|
|
||||||
final Object? content,
|
|
||||||
) extends CardEditEvent {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [side, content];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// For a side changed differently on two devices, the person keeps [version].
|
|
||||||
final class const CardEditEventVersionChosen(
|
|
||||||
/// The conflicted side.
|
|
||||||
final CardField side,
|
|
||||||
|
|
||||||
/// The version to keep.
|
|
||||||
final Object? version,
|
|
||||||
) extends CardEditEvent {
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [side, version];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store the card.
|
|
||||||
final class const CardEditEventSaveRequested() extends CardEditEvent;
|
|
||||||
|
|
||||||
/// Delete the card; the person has confirmed it.
|
|
||||||
final class const CardEditEventDeleteRequested() extends CardEditEvent;
|
|
||||||
|
|
||||||
/// A card being created or edited.
|
|
||||||
final class const CardEditState({
|
|
||||||
/// The content with the changes made so far.
|
|
||||||
required final CardContent content,
|
|
||||||
|
|
||||||
/// The card as stored; null for a new card.
|
|
||||||
final Card? card,
|
|
||||||
|
|
||||||
/// The conflicted sides the person has settled.
|
|
||||||
final Set<CardField> resolvedSides = const {},
|
|
||||||
|
|
||||||
/// Saved or deleted; the screen closes then.
|
|
||||||
final bool isDone = false,
|
|
||||||
|
|
||||||
/// The person's tags, to show the chosen ones by name and colour.
|
|
||||||
final Tagging tagging = Tagging.empty,
|
|
||||||
|
|
||||||
/// The ids of the tags the card has as stored; null until read.
|
|
||||||
final Set<String>? savedTagIds,
|
|
||||||
|
|
||||||
/// The ids of the tags chosen; null until the stored ones are read.
|
|
||||||
final Set<String>? tagIds,
|
|
||||||
}) with Equatable {
|
|
||||||
/// The sides devices changed differently that still need a choice.
|
|
||||||
Set<CardField> get unresolvedSides => {
|
|
||||||
...?card?.conflicts.keys,
|
|
||||||
}.difference(resolvedSides);
|
|
||||||
|
|
||||||
/// Whether a stored card's sides would be stored anew: changed, or in
|
|
||||||
/// conflict.
|
|
||||||
bool get contentChanged => switch (card) {
|
|
||||||
null => true,
|
|
||||||
final card => content != card.content || card.hasConflict,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Whether other tags are chosen than the card has.
|
|
||||||
bool get tagsChanged => switch ((tagIds, savedTagIds)) {
|
|
||||||
(final chosen?, final saved?) =>
|
|
||||||
chosen.length != saved.length || !chosen.containsAll(saved),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Whether saving would store something: every conflicted side settled,
|
|
||||||
/// and a new card with content, or a stored one with changes or in
|
|
||||||
/// conflict.
|
|
||||||
bool get canSave =>
|
|
||||||
unresolvedSides.isEmpty &&
|
|
||||||
switch (card) {
|
|
||||||
null => content != CardContent.empty(),
|
|
||||||
_ => contentChanged || tagsChanged,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// This state with some fields replaced.
|
|
||||||
CardEditState copyWith({
|
|
||||||
CardContent? content,
|
|
||||||
Set<CardField>? resolvedSides,
|
|
||||||
bool? isDone,
|
|
||||||
Tagging? tagging,
|
|
||||||
Set<String>? savedTagIds,
|
|
||||||
Set<String>? tagIds,
|
|
||||||
}) => CardEditState(
|
|
||||||
content: content ?? this.content,
|
|
||||||
card: card,
|
|
||||||
resolvedSides: resolvedSides ?? this.resolvedSides,
|
|
||||||
isDone: isDone ?? this.isDone,
|
|
||||||
tagging: tagging ?? this.tagging,
|
|
||||||
savedTagIds: savedTagIds ?? this.savedTagIds,
|
|
||||||
tagIds: tagIds ?? this.tagIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [
|
|
||||||
content,
|
|
||||||
card,
|
|
||||||
resolvedSides,
|
|
||||||
isDone,
|
|
||||||
tagging,
|
|
||||||
savedTagIds,
|
|
||||||
tagIds,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -58,6 +58,13 @@ final class const CardAnswer({
|
||||||
/// On a map, the code of the region it names, e.g. `JP-13`.
|
/// On a map, the code of the region it names, e.g. `JP-13`.
|
||||||
final String? region,
|
final String? region,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
/// This answer with some fields replaced.
|
||||||
|
CardAnswer copyWith({RichContent? content, bool? correct}) => CardAnswer(
|
||||||
|
content: content ?? this.content,
|
||||||
|
correct: correct ?? this.correct,
|
||||||
|
region: region,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [content, correct, region];
|
List<Object?> get props => [content, correct, region];
|
||||||
}
|
}
|
||||||
|
|
@ -226,15 +233,22 @@ final class const CardContent({
|
||||||
answers.any((answer) => answer.correct),
|
answers.any((answer) => answer.correct),
|
||||||
CardType.vocabulary =>
|
CardType.vocabulary =>
|
||||||
!_blank(front) && meanings.any((meaning) => meaning.trim().isNotEmpty),
|
!_blank(front) && meanings.any((meaning) => meaning.trim().isNotEmpty),
|
||||||
|
// Two of each at least, every item in a target, and no target left
|
||||||
|
// empty: an item in none can't be placed right, an empty target is dead.
|
||||||
CardType.matching =>
|
CardType.matching =>
|
||||||
matching.targets.isNotEmpty &&
|
matching.targets.length >= 2 &&
|
||||||
matching.items.isNotEmpty &&
|
matching.items.length >= 2 &&
|
||||||
|
matching.targets.every((target) => target.trim().isNotEmpty) &&
|
||||||
matching.items.every(
|
matching.items.every(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.text.trim().isNotEmpty &&
|
item.text.trim().isNotEmpty &&
|
||||||
item.target >= 0 &&
|
item.target >= 0 &&
|
||||||
item.target < matching.targets.length,
|
item.target < matching.targets.length,
|
||||||
),
|
) &&
|
||||||
|
[
|
||||||
|
for (var t = 0; t < matching.targets.length; t++)
|
||||||
|
matching.items.any((item) => item.target == t),
|
||||||
|
].every((filled) => filled),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a list shows beside the front: the back, the right answers, the
|
/// What a list shows beside the front: the back, the right answers, the
|
||||||
|
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
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/feature/card/bloc/card_edit_bloc.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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';
|
|
||||||
// Material's Card widget would clash with the domain's Card.
|
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
|
||||||
|
|
||||||
/// Creates a card, or edits one: both sides, saved on request. A side that
|
|
||||||
/// devices changed differently shows every version to choose from first;
|
|
||||||
/// deleting asks.
|
|
||||||
class const CardScreen({
|
|
||||||
/// The card as the screen opens; null for a new card.
|
|
||||||
required final Card? card,
|
|
||||||
super.key,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) =>
|
|
||||||
BlocListener<CardEditBloc, CardEditState>(
|
|
||||||
// Once: the state goes on changing after (the tags it follows,
|
|
||||||
// say), and closing again would close the screen below.
|
|
||||||
listenWhen: (previous, current) => !previous.isDone && current.isDone,
|
|
||||||
listener: (context, state) => Navigator.of(context).pop(),
|
|
||||||
child: Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(card == null ? 'New card' : 'Card'),
|
|
||||||
actions: [
|
|
||||||
BlocSelector<CardEditBloc, CardEditState, bool>(
|
|
||||||
selector: (state) => state.canSave,
|
|
||||||
builder: (context, canSave) => IconButton(
|
|
||||||
icon: const Icon(Icons.save_outlined),
|
|
||||||
tooltip: 'Save',
|
|
||||||
onPressed: canSave
|
|
||||||
? () => context.read<CardEditBloc>().add(
|
|
||||||
const CardEditEventSaveRequested(),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (card != null)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
tooltip: 'Delete',
|
|
||||||
onPressed: () async {
|
|
||||||
final bloc = context.read<CardEditBloc>();
|
|
||||||
if (await _confirmDelete(context)) {
|
|
||||||
bloc.add(const CardEditEventDeleteRequested());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: BlocBuilder<CardEditBloc, CardEditState>(
|
|
||||||
builder: (context, state) => ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
if (state.unresolvedSides.isNotEmpty) ...[
|
|
||||||
const Text(
|
|
||||||
'This card was changed differently on two devices. '
|
|
||||||
'Choose the version to keep.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
for (final side in const [CardField.front, CardField.back]) ...[
|
|
||||||
Text(
|
|
||||||
side == CardField.front ? 'Front' : 'Back',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
if (state.unresolvedSides.contains(side))
|
|
||||||
for (final version in state.card!.conflicts[side]!)
|
|
||||||
_Version(side: side, version: version! as RichContent)
|
|
||||||
else
|
|
||||||
SizedBox(
|
|
||||||
height: 200,
|
|
||||||
child: RichContentEditor(
|
|
||||||
key: ValueKey(side),
|
|
||||||
initialContent: state.content[side]! as RichContent,
|
|
||||||
onChanged: (content) => context
|
|
||||||
.read<CardEditBloc>()
|
|
||||||
.add(CardEditEventSideChanged(side, content)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context).cardTagsLabel,
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_Tags(state: state),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The tags chosen for the card, and choosing them.
|
|
||||||
class const _Tags({required final CardEditState state})
|
|
||||||
extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
final tagIds = state.tagIds;
|
|
||||||
if (tagIds == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
final chosen = [
|
|
||||||
for (final tag in state.tagging.all)
|
|
||||||
if (tagIds.contains(tag.id)) tag,
|
|
||||||
];
|
|
||||||
return Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
for (final tag in chosen) TagChip(tag: tag),
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(LucideIcons.pencil, size: 16),
|
|
||||||
label: Text(chosen.isEmpty ? l10n.cardTagsAdd : l10n.cardTagsEdit),
|
|
||||||
onPressed: () {
|
|
||||||
final bloc = context.read<CardEditBloc>();
|
|
||||||
showTagPicker(
|
|
||||||
context,
|
|
||||||
selected: tagIds,
|
|
||||||
onChanged: (tagIds) => bloc.add(CardEditEventTagsChanged(tagIds)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One version of a side devices changed differently, and keeping it.
|
|
||||||
class const _Version({
|
|
||||||
required final CardField side,
|
|
||||||
required final RichContent version,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
RichContentView(content: version),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => context.read<CardEditBloc>().add(
|
|
||||||
CardEditEventVersionChosen(side, version),
|
|
||||||
),
|
|
||||||
child: const Text('Keep this'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Asks before deleting a card.
|
|
||||||
Future<bool> _confirmDelete(BuildContext context) async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Delete this card?'),
|
|
||||||
content: const Text('It is deleted on all your devices.'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return confirmed ?? false;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
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/theme/theme_extras.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The most answers a multiple-choice card has.
|
||||||
|
const maxAnswers = 6;
|
||||||
|
|
||||||
|
/// A card's answer side: one answer makes a front/back card; "Add answer"
|
||||||
|
/// turns it into a multiple-choice card of up to [maxAnswers], each marked
|
||||||
|
/// right or wrong, at least one right. The first answer stays; the others
|
||||||
|
/// can go.
|
||||||
|
class const AnswersEditor({
|
||||||
|
/// The answers, at least one.
|
||||||
|
required final List<CardAnswer> answers,
|
||||||
|
|
||||||
|
/// Called with the answers after every change.
|
||||||
|
required final ValueChanged<List<CardAnswer>> onChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final rightOnes = answers.where((answer) => answer.correct).length;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (answers.length == 1)
|
||||||
|
CardFace(
|
||||||
|
key: const ValueKey('answer-0'),
|
||||||
|
content: answers.single.content,
|
||||||
|
placeholder: l10n.composerAddAnswer,
|
||||||
|
onChanged: (content) => onChanged([
|
||||||
|
CardAnswer(content: content, correct: true),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (final (i, answer) in answers.indexed) ...[
|
||||||
|
if (i > 0) const SizedBox(height: 12),
|
||||||
|
_AnswerCard(
|
||||||
|
key: ValueKey('answer-$i'),
|
||||||
|
answer: answer,
|
||||||
|
// The last right answer can't turn wrong: one stays right.
|
||||||
|
canTurnWrong: !(answer.correct && rightOnes <= 1),
|
||||||
|
canRemove: i > 0,
|
||||||
|
onChanged: (changed) => onChanged([...answers]..[i] = changed),
|
||||||
|
onRemove: () => onChanged([...answers]..removeAt(i)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (answers.length < maxAnswers) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => onChanged([
|
||||||
|
// A front/back card's answer is the right one.
|
||||||
|
if (answers.length == 1)
|
||||||
|
answers.single.copyWith(correct: true)
|
||||||
|
else
|
||||||
|
...answers,
|
||||||
|
CardAnswer(content: RichContent.empty()),
|
||||||
|
]),
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 16),
|
||||||
|
label: Text(l10n.composerAddAnswer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One answer of a multiple-choice card: marked right (green) or wrong
|
||||||
|
/// (red) by its label and switch, the field itself left plain.
|
||||||
|
class const _AnswerCard({
|
||||||
|
required final CardAnswer answer,
|
||||||
|
required final bool canTurnWrong,
|
||||||
|
required final bool canRemove,
|
||||||
|
required final ValueChanged<CardAnswer> onChanged,
|
||||||
|
required final VoidCallback onRemove,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final right = theme.extras.success;
|
||||||
|
final wrong = theme.colorScheme.error;
|
||||||
|
final accent = answer.correct ? right : wrong;
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.06),
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.5)),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 4, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
// The switches line up whatever the label's length.
|
||||||
|
width: 140,
|
||||||
|
child: Text(
|
||||||
|
answer.correct
|
||||||
|
? l10n.composerCorrectAnswer
|
||||||
|
: l10n.composerWrongAnswer,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: accent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: answer.correct,
|
||||||
|
onChanged: answer.correct && !canTurnWrong
|
||||||
|
? null
|
||||||
|
: (correct) =>
|
||||||
|
onChanged(answer.copyWith(correct: correct)),
|
||||||
|
activeThumbColor: right,
|
||||||
|
inactiveThumbColor: wrong,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (canRemove)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onRemove,
|
||||||
|
tooltip: l10n.composerRemoveAnswer,
|
||||||
|
icon: const Icon(LucideIcons.x, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
CardFace(
|
||||||
|
content: answer.content,
|
||||||
|
placeholder: l10n.composerAddAnswer,
|
||||||
|
onChanged: (content) =>
|
||||||
|
onChanged(answer.copyWith(content: content)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Writes a studyset's cards, many in one sitting: the stack of cards on
|
||||||
|
/// the left (new ones on top, then the stored ones), the card being written
|
||||||
|
/// on the right. A card is saved as the person leaves it (another card, the
|
||||||
|
/// pane, the screen, the app); the title says how saving stands, and leaving
|
||||||
|
/// with cards that couldn't be saved asks first.
|
||||||
|
///
|
||||||
|
/// Opens on [initialCardId] when given; a studyset without cards opens on a
|
||||||
|
/// new one.
|
||||||
|
class const CardComposerScreen({
|
||||||
|
/// The studyset's title, for the screen's.
|
||||||
|
required final String studysetTitle,
|
||||||
|
|
||||||
|
/// The card to open on.
|
||||||
|
final String? initialCardId,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<CardComposerScreen> createState() => _CardComposerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How wide the stack is when extended and when collapsed.
|
||||||
|
const _stackWidth = 240.0;
|
||||||
|
const _collapsedStackWidth = 72.0;
|
||||||
|
|
||||||
|
class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
|
String? _selected;
|
||||||
|
bool? _stackExpanded;
|
||||||
|
var _focusFront = false;
|
||||||
|
var _seeded = false;
|
||||||
|
var _nextDraft = 1;
|
||||||
|
late final AppLifecycleListener _lifecycle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selected = widget.initialCardId;
|
||||||
|
// The app going to the background leaves the card too.
|
||||||
|
_lifecycle = AppLifecycleListener(onHide: _leaveSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_lifecycle.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
CardComposerBloc get _bloc => context.read<CardComposerBloc>();
|
||||||
|
|
||||||
|
void _leaveSelected() {
|
||||||
|
if (_selected case final id?) {
|
||||||
|
_bloc.add(CardComposerEventLeft(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _select(String id) {
|
||||||
|
if (id == _selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_leaveSelected();
|
||||||
|
setState(() {
|
||||||
|
_selected = id;
|
||||||
|
_focusFront = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addDraft() {
|
||||||
|
_leaveSelected();
|
||||||
|
final id = 'new-${_nextDraft++}';
|
||||||
|
_bloc.add(CardComposerEventDraftAdded(id, after: _selected));
|
||||||
|
setState(() {
|
||||||
|
_selected = id;
|
||||||
|
_focusFront = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _duplicate(CardDraft draft) {
|
||||||
|
_leaveSelected();
|
||||||
|
final id = 'new-${_nextDraft++}';
|
||||||
|
_bloc.add(CardComposerEventDuplicated(draft.id, id));
|
||||||
|
setState(() {
|
||||||
|
_selected = id;
|
||||||
|
_focusFront = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(CardDraft draft) async {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
// A new card with nothing in it has nothing to lose.
|
||||||
|
final nothingToLose = draft.cardId == null && !draft.isDirty;
|
||||||
|
if (!nothingToLose &&
|
||||||
|
!await confirmDestructiveAction(
|
||||||
|
context,
|
||||||
|
title: l10n.composerDeleteTitle,
|
||||||
|
message: draft.cardId == null
|
||||||
|
? l10n.composerDeleteDraftMessage
|
||||||
|
: l10n.composerDeleteSavedMessage,
|
||||||
|
confirmLabel: l10n.composerDelete,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bloc.add(CardComposerEventDeleted(draft.id));
|
||||||
|
setState(() => _selected = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaves the screen once the card being written is saved, asking first
|
||||||
|
/// when cards couldn't be.
|
||||||
|
Future<void> _close() async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
final leaving = _selected;
|
||||||
|
_leaveSelected();
|
||||||
|
// Saving the card left takes moments; wait for it, but not forever.
|
||||||
|
bool settled(CardComposerState state) =>
|
||||||
|
state.saving != CardComposerSaving.saving &&
|
||||||
|
!(state.byId(leaving)?.canSave ?? false);
|
||||||
|
// Listening starts at once, before the bloc handles the leaving, so no
|
||||||
|
// state of the saving is missed.
|
||||||
|
final state = settled(_bloc.state)
|
||||||
|
? _bloc.state
|
||||||
|
: await _bloc.stream
|
||||||
|
.firstWhere(settled)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => _bloc.state,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final unsaved = state.unsaved;
|
||||||
|
if (unsaved > 0) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
if (!await confirmDestructiveAction(
|
||||||
|
context,
|
||||||
|
title: l10n.composerDiscardTitle(unsaved),
|
||||||
|
message: l10n.composerDiscardMessage(unsaved),
|
||||||
|
confirmLabel: l10n.composerDiscardConfirm,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final expanded = _stackExpanded ?? context.windowSize.isAtLeastMedium;
|
||||||
|
return BlocConsumer<CardComposerBloc, CardComposerState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (!state.loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A studyset without cards opens on a new one, once.
|
||||||
|
if (!_seeded && state.items.isEmpty) {
|
||||||
|
_seeded = true;
|
||||||
|
_addDraft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_seeded = true;
|
||||||
|
// Nothing open, or the open card gone (deleted, here or elsewhere):
|
||||||
|
// open the first.
|
||||||
|
if (state.byId(_selected) == null && state.items.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_selected = state.items.first.id;
|
||||||
|
_focusFront = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
final selected = state.byId(_selected);
|
||||||
|
return PopScope(
|
||||||
|
canPop: false,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) {
|
||||||
|
unawaited(_close());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: BackButton(onPressed: _close),
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.studysetTitle.isEmpty
|
||||||
|
? l10n.composerTitle
|
||||||
|
: l10n.composerTitleFor(widget.studysetTitle),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (switch (state.saving) {
|
||||||
|
CardComposerSaving.idle => null,
|
||||||
|
CardComposerSaving.saving => l10n.composerSaving,
|
||||||
|
CardComposerSaving.failed => l10n.composerSaveFailed,
|
||||||
|
CardComposerSaving.saved => l10n.composerAllSaved,
|
||||||
|
}
|
||||||
|
case final status?)
|
||||||
|
Text(status, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: expanded ? _stackWidth : _collapsedStackWidth,
|
||||||
|
child: _Stack(
|
||||||
|
state: state,
|
||||||
|
selected: _selected,
|
||||||
|
expanded: expanded,
|
||||||
|
onAdd: _addDraft,
|
||||||
|
onSelect: _select,
|
||||||
|
onToggle: () => setState(() => _stackExpanded = !expanded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1, thickness: 1),
|
||||||
|
Expanded(
|
||||||
|
// The person leaving the pane leaves the card, which saves
|
||||||
|
// it; moving between its fields doesn't.
|
||||||
|
child: Focus(
|
||||||
|
skipTraversal: true,
|
||||||
|
onFocusChange: (focused) {
|
||||||
|
if (!focused) {
|
||||||
|
_leaveSelected();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: selected == null
|
||||||
|
? Center(child: Text(l10n.composerEmptyPane))
|
||||||
|
: CardDraftEditor(
|
||||||
|
key: ValueKey(selected.id),
|
||||||
|
draft: selected,
|
||||||
|
pins: state.pins,
|
||||||
|
tagging: state.tagging,
|
||||||
|
focusFront: _focusFront,
|
||||||
|
onDuplicate: () => _duplicate(selected),
|
||||||
|
onDelete: () => _delete(selected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stack: "New flashcard" on top, the cards below, and collapsing the
|
||||||
|
/// stack at the bottom.
|
||||||
|
class const _Stack({
|
||||||
|
required final CardComposerState state,
|
||||||
|
required final String? selected,
|
||||||
|
required final bool expanded,
|
||||||
|
required final VoidCallback onAdd,
|
||||||
|
required final ValueChanged<String> onSelect,
|
||||||
|
required final VoidCallback onToggle,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final items = state.items;
|
||||||
|
return FocusTraversalGroup(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: expanded ? 12 : 8,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (expanded)
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: onAdd,
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: Text(l10n.composerNewFlashcard),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Center(
|
||||||
|
child: IconButton.filledTonal(
|
||||||
|
tooltip: l10n.composerNewFlashcard,
|
||||||
|
onPressed: onAdd,
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (context, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, i) => CardStackTile(
|
||||||
|
key: ValueKey(items[i].id),
|
||||||
|
draft: items[i],
|
||||||
|
position: i + 1,
|
||||||
|
selected: items[i].id == selected,
|
||||||
|
collapsed: !expanded,
|
||||||
|
onTap: () => onSelect(items[i].id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: expanded ? Alignment.centerRight : Alignment.center,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: expanded
|
||||||
|
? l10n.composerCollapseStack
|
||||||
|
: l10n.composerExpandStack,
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
expanded ? LucideIcons.chevronLeft : LucideIcons.chevronRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
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_view.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/field_label.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/matching_fields.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabulary_fields.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.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:halkyon_learn_flutter/src/feature/tag/widget/tags_pill.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// What kind of card the composer writes, as its type switch offers them: a
|
||||||
|
/// flashcard is a front/back card with one answer and a multiple-choice card
|
||||||
|
/// with more.
|
||||||
|
enum CardKind() {
|
||||||
|
/// A front/back or multiple-choice card.
|
||||||
|
flashcard,
|
||||||
|
|
||||||
|
/// A word.
|
||||||
|
vocabulary,
|
||||||
|
|
||||||
|
/// Items to sort.
|
||||||
|
matching;
|
||||||
|
|
||||||
|
/// The kind of [content].
|
||||||
|
static CardKind of(CardContent content) => switch (content.type) {
|
||||||
|
CardType.frontBack || CardType.multipleChoice => flashcard,
|
||||||
|
CardType.vocabulary => vocabulary,
|
||||||
|
CardType.matching => matching,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// [content] as this kind, every field kept for switching back.
|
||||||
|
CardContent apply(CardContent content) => switch (this) {
|
||||||
|
flashcard => content.copyWith(
|
||||||
|
type: content.answers.length > 1
|
||||||
|
? CardType.multipleChoice
|
||||||
|
: CardType.frontBack,
|
||||||
|
),
|
||||||
|
vocabulary => content.copyWith(type: CardType.vocabulary),
|
||||||
|
matching => content.copyWith(type: CardType.matching),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The answers of a flashcard: a front/back card's back as its one right
|
||||||
|
/// answer.
|
||||||
|
List<CardAnswer> answersOf(CardContent content) =>
|
||||||
|
content.type == CardType.multipleChoice && content.answers.length > 1
|
||||||
|
? content.answers
|
||||||
|
: [CardAnswer(content: content.back, correct: true)];
|
||||||
|
|
||||||
|
/// [content] with [answers]: one makes a front/back card of it, more a
|
||||||
|
/// multiple-choice card.
|
||||||
|
CardContent withAnswers(CardContent content, List<CardAnswer> answers) =>
|
||||||
|
answers.length == 1
|
||||||
|
? content
|
||||||
|
.copyWith(type: CardType.frontBack, back: answers.single.content)
|
||||||
|
.withField(CardField.answers, const <CardAnswer>[])
|
||||||
|
: content.copyWith(type: CardType.multipleChoice, answers: answers);
|
||||||
|
|
||||||
|
/// The composer's editor pane for [draft]: its tags and actions on top, its
|
||||||
|
/// type, the front, and below a line its answer side as its type has it.
|
||||||
|
/// Fields devices changed differently come first, each with its versions.
|
||||||
|
class const CardDraftEditor({
|
||||||
|
/// The card being written.
|
||||||
|
required final CardDraft draft,
|
||||||
|
|
||||||
|
/// What is pinned for the next card.
|
||||||
|
required final Set<CardPin> pins,
|
||||||
|
|
||||||
|
/// The person's tags.
|
||||||
|
required final Tagging tagging,
|
||||||
|
|
||||||
|
/// Whether the front opens as an editor, for a card just added.
|
||||||
|
required final bool focusFront,
|
||||||
|
|
||||||
|
/// Copies the card into a new one.
|
||||||
|
required final VoidCallback onDuplicate,
|
||||||
|
|
||||||
|
/// Deletes the card, asking where it matters.
|
||||||
|
required final VoidCallback onDelete,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<CardDraftEditor> createState() => _CardDraftEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
|
/// The content this pane last reported. Content that differs arrived from
|
||||||
|
/// elsewhere (cleared, a version chosen, another device), and the fields
|
||||||
|
/// start afresh with it.
|
||||||
|
late var _sent = widget.draft.content;
|
||||||
|
var _revision = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(CardDraftEditor oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.draft.id != oldWidget.draft.id ||
|
||||||
|
widget.draft.content != _sent) {
|
||||||
|
_sent = widget.draft.content;
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _change(CardContent content) {
|
||||||
|
_sent = content;
|
||||||
|
context.read<CardComposerBloc>().add(
|
||||||
|
CardComposerEventChanged(widget.draft.id, content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _send(CardComposerEvent event) =>
|
||||||
|
context.read<CardComposerBloc>().add(event);
|
||||||
|
|
||||||
|
Key _key(String field) => ValueKey('${widget.draft.id}-$field-$_revision');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final draft = widget.draft;
|
||||||
|
final content = draft.content;
|
||||||
|
final kind = CardKind.of(content);
|
||||||
|
final chosenTags = [
|
||||||
|
for (final tag in widget.tagging.all)
|
||||||
|
if (draft.tagIds.contains(tag.id)) tag,
|
||||||
|
];
|
||||||
|
final frontLabel = switch (kind) {
|
||||||
|
CardKind.flashcard => l10n.composerLabelFront,
|
||||||
|
CardKind.vocabulary => l10n.composerLabelExpression,
|
||||||
|
CardKind.matching => l10n.composerLabelPrompt,
|
||||||
|
};
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TagsPill(
|
||||||
|
hasTags: chosenTags.isNotEmpty,
|
||||||
|
pinned: widget.pins.contains(CardPin.tags),
|
||||||
|
onTagsPressed: () => showTagPicker(
|
||||||
|
context,
|
||||||
|
selected: draft.tagIds,
|
||||||
|
onChanged: (tagIds) =>
|
||||||
|
_send(CardComposerEventTagsChanged(draft.id, tagIds)),
|
||||||
|
),
|
||||||
|
onPinPressed: () =>
|
||||||
|
_send(const CardComposerEventPinToggled(CardPin.tags)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final tag in chosenTags) TagChip(tag: tag),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_Actions(
|
||||||
|
onDuplicate: widget.onDuplicate,
|
||||||
|
onClear: () => _send(CardComposerEventCleared(draft.id)),
|
||||||
|
onDelete: widget.onDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SegmentedButton<CardKind>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(
|
||||||
|
value: CardKind.flashcard,
|
||||||
|
label: Text(l10n.composerKindFlashcard),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: CardKind.vocabulary,
|
||||||
|
label: Text(l10n.composerKindVocabulary),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: CardKind.matching,
|
||||||
|
label: Text(l10n.composerKindMatching),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {kind},
|
||||||
|
showSelectedIcon: false,
|
||||||
|
onSelectionChanged: (selection) =>
|
||||||
|
_change(selection.single.apply(content)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (draft.unresolved.isNotEmpty) ...[
|
||||||
|
_Conflicts(draft: draft),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
CardFieldLabel(
|
||||||
|
label: frontLabel,
|
||||||
|
pinned: widget.pins.contains(CardPin.front),
|
||||||
|
onPinPressed: () => _send(
|
||||||
|
const CardComposerEventPinToggled(CardPin.front),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CardFace(
|
||||||
|
key: _key('front'),
|
||||||
|
content: content.front,
|
||||||
|
placeholder: switch (kind) {
|
||||||
|
CardKind.flashcard => l10n.composerPlaceholderFront,
|
||||||
|
CardKind.vocabulary => l10n.composerPlaceholderExpression,
|
||||||
|
CardKind.matching => l10n.composerPlaceholderPrompt,
|
||||||
|
},
|
||||||
|
startExpanded: widget.focusFront,
|
||||||
|
onChanged: (front) => _change(_sent.copyWith(front: front)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (kind == CardKind.flashcard)
|
||||||
|
CardFieldLabel(
|
||||||
|
label: answersOf(content).length > 1
|
||||||
|
? l10n.composerLabelAnswers
|
||||||
|
: l10n.composerLabelBack,
|
||||||
|
pinned: widget.pins.contains(CardPin.answer),
|
||||||
|
onPinPressed: () => _send(
|
||||||
|
const CardComposerEventPinToggled(CardPin.answer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
switch (kind) {
|
||||||
|
CardKind.flashcard => AnswersEditor(
|
||||||
|
key: _key('answers'),
|
||||||
|
answers: answersOf(content),
|
||||||
|
onChanged: (answers) =>
|
||||||
|
_change(withAnswers(_sent, answers)),
|
||||||
|
),
|
||||||
|
CardKind.vocabulary => VocabularyFields(
|
||||||
|
key: _key('vocabulary'),
|
||||||
|
reading: content.reading,
|
||||||
|
meanings: content.meanings,
|
||||||
|
onReadingChanged: (reading) =>
|
||||||
|
_change(_sent.copyWith(reading: reading)),
|
||||||
|
onMeaningsChanged: (meanings) =>
|
||||||
|
_change(_sent.copyWith(meanings: meanings)),
|
||||||
|
),
|
||||||
|
CardKind.matching => MatchingFields(
|
||||||
|
key: _key('matching'),
|
||||||
|
matching: content.matching,
|
||||||
|
onChanged: (matching) =>
|
||||||
|
_change(_sent.copyWith(matching: matching)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (draft.status == CardDraftStatus.failed) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.composerSaveFailedInline,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => _send(CardComposerEventLeft(draft.id)),
|
||||||
|
icon: const Icon(LucideIcons.rotateCcw),
|
||||||
|
label: Text(l10n.composerRetry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card's actions: duplicate, clear its fields, delete.
|
||||||
|
class const _Actions({
|
||||||
|
required final VoidCallback onDuplicate,
|
||||||
|
required final VoidCallback onClear,
|
||||||
|
required final VoidCallback onDelete,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return MenuAnchor(
|
||||||
|
menuChildren: [
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(LucideIcons.copy, size: 18),
|
||||||
|
onPressed: onDuplicate,
|
||||||
|
child: Text(l10n.composerDuplicate),
|
||||||
|
),
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(LucideIcons.eraser, size: 18),
|
||||||
|
onPressed: onClear,
|
||||||
|
child: Text(l10n.composerClearFields),
|
||||||
|
),
|
||||||
|
MenuItemButton(
|
||||||
|
leadingIcon: const Icon(LucideIcons.trash2, size: 18),
|
||||||
|
onPressed: onDelete,
|
||||||
|
child: Text(l10n.composerDelete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: (context, menu, _) => IconButton(
|
||||||
|
tooltip: l10n.composerMoreActions,
|
||||||
|
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||||
|
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields devices changed differently, each with every version and a
|
||||||
|
/// way to keep one.
|
||||||
|
class const _Conflicts({required final CardDraft draft})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.composerConflict,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final field in draft.unresolved)
|
||||||
|
for (final version in draft.conflicts[field]!)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outline),
|
||||||
|
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<CardComposerBloc>().add(
|
||||||
|
CardComposerEventVersionChosen(
|
||||||
|
draft.id,
|
||||||
|
field,
|
||||||
|
version,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(l10n.composerKeepThis),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fieldName(AppLocalizations l10n, CardField field) => switch (field) {
|
||||||
|
CardField.type => l10n.composerKindFlashcard,
|
||||||
|
CardField.front => l10n.composerLabelFront,
|
||||||
|
CardField.back => l10n.composerLabelBack,
|
||||||
|
CardField.answers => l10n.composerLabelAnswers,
|
||||||
|
CardField.reading => l10n.vocabularyReading,
|
||||||
|
CardField.meanings => l10n.vocabularyMeanings,
|
||||||
|
CardField.matching => l10n.composerKindMatching,
|
||||||
|
CardField.map => l10n.composerKindMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
final List<CardAnswer> answers => Text(
|
||||||
|
[
|
||||||
|
for (final answer in answers)
|
||||||
|
'${answer.correct ? '✓' : '✗'} ${answer.content.plainText}',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
final List<String> meanings => Text(meanings.join('; ')),
|
||||||
|
final Matching matching => Text(
|
||||||
|
[
|
||||||
|
for (final (t, target) in matching.targets.indexed)
|
||||||
|
'$target: ${[
|
||||||
|
for (final item in matching.items)
|
||||||
|
if (item.target == t) item.text,
|
||||||
|
].join(', ')}',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
final CardType type => Text(type.name),
|
||||||
|
_ => Text('${version ?? ''}'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
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:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A rich field of a card: read-only and compact until tapped, then an
|
||||||
|
/// editor of a fixed height with the cursor in it, compact again once the
|
||||||
|
/// focus leaves. Only the field being written holds an editor, so a card
|
||||||
|
/// with many fields stays light.
|
||||||
|
class const CardFace({
|
||||||
|
/// The field's content.
|
||||||
|
required final RichContent content,
|
||||||
|
|
||||||
|
/// What an empty field invites to write.
|
||||||
|
required final String placeholder,
|
||||||
|
|
||||||
|
/// Called with the content after every change.
|
||||||
|
required final ValueChanged<RichContent> onChanged,
|
||||||
|
|
||||||
|
/// Whether it opens as an editor, e.g. for a card just added.
|
||||||
|
final bool startExpanded = false,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
/// How tall the editor is.
|
||||||
|
static const editorHeight = 200.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CardFace> createState() => _CardFaceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CardFaceState() extends State<CardFace> {
|
||||||
|
late var _expanded = widget.startExpanded;
|
||||||
|
final _focus = FocusNode();
|
||||||
|
var _hovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focus.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focus
|
||||||
|
..removeListener(_onFocusChange)
|
||||||
|
..dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
// A dialog opening over the editor takes the focus too; collapsing
|
||||||
|
// then would drop the editor that dialog works for.
|
||||||
|
if (!_focus.hasFocus &&
|
||||||
|
mounted &&
|
||||||
|
(ModalRoute.of(context)?.isCurrent ?? true)) {
|
||||||
|
setState(() => _expanded = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
if (_expanded) {
|
||||||
|
return Container(
|
||||||
|
height: CardFace.editorHeight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: colors.primary, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: RichContentEditor(
|
||||||
|
initialContent: widget.content,
|
||||||
|
onChanged: widget.onChanged,
|
||||||
|
focusNode: _focus,
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final empty = widget.content.plainText.trim().isEmpty;
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = true),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hovered ? colors.surfaceContainerHighest : null,
|
||||||
|
border: Border.all(
|
||||||
|
color: _hovered ? colors.outline : colors.outlineVariant,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: empty
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.plus,
|
||||||
|
size: 18,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
widget.placeholder,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Tall content shows its start; the editor shows it all.
|
||||||
|
: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 160),
|
||||||
|
child: ClipRect(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: RichContentView(content: widget.content),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A card in the composer's stack: its front above a line, its answer
|
||||||
|
/// below, and a dot for how its saving stands; as a numbered square while
|
||||||
|
/// the stack is collapsed. The card being written has a primary border.
|
||||||
|
class const CardStackTile({
|
||||||
|
/// The card.
|
||||||
|
required final CardDraft draft,
|
||||||
|
|
||||||
|
/// Its place in the stack, from 1, shown while collapsed.
|
||||||
|
required final int position,
|
||||||
|
|
||||||
|
/// Whether it is the card being written.
|
||||||
|
required final bool selected,
|
||||||
|
|
||||||
|
/// Whether the stack is collapsed.
|
||||||
|
required final bool collapsed,
|
||||||
|
|
||||||
|
/// Opens it.
|
||||||
|
required final VoidCallback onTap,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final border = Border.all(
|
||||||
|
color: selected ? colors.primary : colors.outlineVariant,
|
||||||
|
width: selected ? 2 : 1,
|
||||||
|
);
|
||||||
|
final dot = _StatusDot(status: draft.status);
|
||||||
|
if (collapsed) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: border,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'$position',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: selected ? colors.primary : colors.onSurfaceVariant,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(top: 4, right: 4, child: dot),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final front = draft.content.front.plainText.split('\n').first;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: border,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
front,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dot,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Divider(height: 1, color: colors.outlineVariant),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
draft.content.answerPreview,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small dot for a card being saved, saved or failed; nothing otherwise.
|
||||||
|
class const _StatusDot({required final CardDraftStatus status})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final (color, label) = switch (status) {
|
||||||
|
CardDraftStatus.saving => (colors.tertiary, l10n.composerStatusSaving),
|
||||||
|
CardDraftStatus.saved => (colors.primary, l10n.composerStatusSaved),
|
||||||
|
CardDraftStatus.failed => (colors.error, l10n.composerStatusFailed),
|
||||||
|
CardDraftStatus.clean || CardDraftStatus.dirty => (null, null),
|
||||||
|
};
|
||||||
|
if (color == null || label == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Tooltip(
|
||||||
|
message: label,
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(left: 8),
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A field's label with a pin beside it: pinned, the next new card starts
|
||||||
|
/// with this field's content, as Anki's sticky fields do. Pinned shows in
|
||||||
|
/// three ways at once (the glyph, a tonal fill, the colour), so a glance
|
||||||
|
/// tells it apart at 16 dp in any theme.
|
||||||
|
class const CardFieldLabel({
|
||||||
|
/// The field's name.
|
||||||
|
required final String label,
|
||||||
|
|
||||||
|
/// Whether the field is pinned.
|
||||||
|
required final bool pinned,
|
||||||
|
|
||||||
|
/// Pins or unpins it.
|
||||||
|
required final VoidCallback onPinPressed,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: theme.textTheme.labelLarge)),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onPinPressed,
|
||||||
|
tooltip: pinned ? l10n.composerUnpin(label) : l10n.composerPin(label),
|
||||||
|
icon: Icon(pinned ? LucideIcons.pin : LucideIcons.pinOff, size: 16),
|
||||||
|
color: pinned
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
style: pinned
|
||||||
|
? IconButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.primaryContainer,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A matching card's categories and items, each item assigned to one
|
||||||
|
/// category: plain one-line fields, as the labels are short. Two of each at
|
||||||
|
/// least; no more categories than items, as each needs one. Its own text is
|
||||||
|
/// the truth while it shows; give it a key per card.
|
||||||
|
class const MatchingFields({
|
||||||
|
/// The categories and items.
|
||||||
|
required final Matching matching,
|
||||||
|
|
||||||
|
/// Called with all of it after every change, blank rows included; an item
|
||||||
|
/// not assigned yet has the target -1.
|
||||||
|
required final ValueChanged<Matching> onChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<MatchingFields> createState() => _MatchingFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MatchingFieldsState() extends State<MatchingFields> {
|
||||||
|
late final _targets = [
|
||||||
|
for (final target in _atLeastTwo(widget.matching.targets))
|
||||||
|
TextEditingController(text: target),
|
||||||
|
];
|
||||||
|
late final _items = [
|
||||||
|
for (final item in _atLeastTwo(widget.matching.items.map((i) => i.text)))
|
||||||
|
TextEditingController(text: item),
|
||||||
|
];
|
||||||
|
late final _itemTargets = [
|
||||||
|
for (final item in widget.matching.items) item.target,
|
||||||
|
for (var i = widget.matching.items.length; i < _items.length; i++) -1,
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<String> _atLeastTwo(Iterable<String> texts) => [
|
||||||
|
...texts,
|
||||||
|
for (var i = texts.length; i < 2; i++) '',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final controller in [..._targets, ..._items]) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emit() => widget.onChanged(
|
||||||
|
Matching(
|
||||||
|
targets: [for (final target in _targets) target.text],
|
||||||
|
items: [
|
||||||
|
for (var i = 0; i < _items.length; i++)
|
||||||
|
MatchingItem(text: _items[i].text, target: _itemTargets[i]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _removeTarget(int index) {
|
||||||
|
setState(() {
|
||||||
|
_targets.removeAt(index).dispose();
|
||||||
|
// Items in it are unassigned; those after it move up one.
|
||||||
|
for (var i = 0; i < _itemTargets.length; i++) {
|
||||||
|
final target = _itemTargets[i];
|
||||||
|
_itemTargets[i] = target == index
|
||||||
|
? -1
|
||||||
|
: target > index
|
||||||
|
? target - 1
|
||||||
|
: target;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final hintStyle = theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
String targetLabel(int t) => _targets[t].text.trim().isEmpty
|
||||||
|
? l10n.matchingCategory(t + 1)
|
||||||
|
: _targets[t].text.trim();
|
||||||
|
final canAddTarget = _targets.length < _items.length;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l10n.matchingCategories, style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(l10n.matchingCategoriesHint, style: hintStyle),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final (t, target) in _targets.indexed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: target,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.matchingCategory(t + 1),
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(_emit),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: _targets.length > 2
|
||||||
|
? l10n.matchingRemoveCategory
|
||||||
|
: l10n.matchingMinCategories,
|
||||||
|
onPressed: _targets.length > 2
|
||||||
|
? () => _removeTarget(t)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(LucideIcons.x, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Tooltip(
|
||||||
|
message: canAddTarget
|
||||||
|
? l10n.matchingAddCategory
|
||||||
|
: l10n.matchingAddCategoryBlocked,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: canAddTarget
|
||||||
|
? () {
|
||||||
|
setState(() => _targets.add(TextEditingController()));
|
||||||
|
_emit();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: Text(l10n.matchingAddCategory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(l10n.matchingItems, style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(l10n.matchingItemsHint, style: hintStyle),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final (i, item) in _items.indexed)
|
||||||
|
Padding(
|
||||||
|
key: ObjectKey(item),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextField(
|
||||||
|
controller: item,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.matchingItem(i + 1),
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (_) => _emit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
initialValue: _itemTargets[i] < _targets.length
|
||||||
|
? _itemTargets[i]
|
||||||
|
: -1,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: -1,
|
||||||
|
child: Text(l10n.matchingUnassigned),
|
||||||
|
),
|
||||||
|
for (var t = 0; t < _targets.length; t++)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Text(
|
||||||
|
targetLabel(t),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (target) {
|
||||||
|
setState(() => _itemTargets[i] = target ?? -1);
|
||||||
|
_emit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: _items.length > 2
|
||||||
|
? l10n.matchingRemoveItem
|
||||||
|
: l10n.matchingMinItems,
|
||||||
|
onPressed: _items.length > 2
|
||||||
|
? () {
|
||||||
|
setState(() {
|
||||||
|
_items.removeAt(i).dispose();
|
||||||
|
_itemTargets.removeAt(i);
|
||||||
|
});
|
||||||
|
_emit();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(LucideIcons.x, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_items.add(TextEditingController());
|
||||||
|
_itemTargets.add(-1);
|
||||||
|
});
|
||||||
|
_emit();
|
||||||
|
},
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: Text(l10n.matchingAddItem),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A word's reading and meanings, the main meaning first: plain one-line
|
||||||
|
/// fields, as readings and meanings are short. Its own text is the truth
|
||||||
|
/// while it shows; give it a key per card, so another card starts afresh.
|
||||||
|
class const VocabularyFields({
|
||||||
|
/// The reading.
|
||||||
|
required final String reading,
|
||||||
|
|
||||||
|
/// The meanings.
|
||||||
|
required final List<String> meanings,
|
||||||
|
|
||||||
|
/// Called with the reading after every change.
|
||||||
|
required final ValueChanged<String> onReadingChanged,
|
||||||
|
|
||||||
|
/// Called with all meanings after every change, blank rows included.
|
||||||
|
required final ValueChanged<List<String>> onMeaningsChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<VocabularyFields> createState() => _VocabularyFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VocabularyFieldsState() extends State<VocabularyFields> {
|
||||||
|
late final _reading = TextEditingController(text: widget.reading);
|
||||||
|
late final _meanings = [
|
||||||
|
for (final meaning in widget.meanings.isEmpty ? [''] : widget.meanings)
|
||||||
|
TextEditingController(text: meaning),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_reading.dispose();
|
||||||
|
for (final meaning in _meanings) {
|
||||||
|
meaning.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitMeanings() =>
|
||||||
|
widget.onMeaningsChanged([for (final meaning in _meanings) meaning.text]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final labelStyle = theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l10n.vocabularyReading, style: labelStyle),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _reading,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.vocabularyReadingHint,
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: widget.onReadingChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(l10n.vocabularyMeanings, style: labelStyle),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final (i, meaning) in _meanings.indexed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: meaning,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: i == 0
|
||||||
|
? l10n.vocabularyPrimaryMeaningHint
|
||||||
|
: l10n.vocabularyMeaningHint,
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (_) => _emitMeanings(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_meanings.length > 1)
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.vocabularyRemoveMeaning,
|
||||||
|
icon: const Icon(LucideIcons.x, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _meanings.removeAt(i).dispose());
|
||||||
|
_emitMeanings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _meanings.add(TextEditingController()),
|
||||||
|
),
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: Text(l10n.vocabularyAddMeaning),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Choosing a card's tags and pinning them, as one pill with two halves: the
|
||||||
|
/// tags ("Add tags" while there are none) and the pin, which keeps them for
|
||||||
|
/// the next new card. Pinned shows by glyph, fill and colour at once.
|
||||||
|
class const TagsPill({
|
||||||
|
/// Whether the card has tags.
|
||||||
|
required final bool hasTags,
|
||||||
|
|
||||||
|
/// Whether the tags are pinned.
|
||||||
|
required final bool pinned,
|
||||||
|
|
||||||
|
/// Opens the tag picker.
|
||||||
|
required final VoidCallback onTagsPressed,
|
||||||
|
|
||||||
|
/// Pins or unpins the tags.
|
||||||
|
required final VoidCallback onPinPressed,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final label = hasTags ? l10n.composerTags : l10n.composerAddTags;
|
||||||
|
return Material(
|
||||||
|
color: colors.surface,
|
||||||
|
shape: StadiumBorder(side: BorderSide(color: colors.outlineVariant)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTagsPressed,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 6, 10, 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
LucideIcons.tag,
|
||||||
|
size: 16,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: theme.textTheme.labelLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(width: 1, color: colors.outlineVariant),
|
||||||
|
Tooltip(
|
||||||
|
message: pinned
|
||||||
|
? l10n.composerUnpin(l10n.composerTags)
|
||||||
|
: l10n.composerPin(l10n.composerTags),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: pinned ? colors.primaryContainer : Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPinPressed,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
pinned ? LucideIcons.pin : LucideIcons.pinOff,
|
||||||
|
size: 16,
|
||||||
|
color: pinned
|
||||||
|
? colors.onPrimaryContainer
|
||||||
|
: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
|
||||||
|
|
@ -151,173 +148,4 @@ void main() {
|
||||||
expect(state.shown, hasLength(3));
|
expect(state.shown, hasLength(3));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('CardEditBloc', () {
|
|
||||||
test('a new card can be saved once it has content, and then is '
|
|
||||||
'done', () async {
|
|
||||||
final bloc = CardEditBloc(cards, tags, 'japanese', null);
|
|
||||||
addTearDown(bloc.close);
|
|
||||||
expect(bloc.state.canSave, isFalse);
|
|
||||||
|
|
||||||
bloc.add(CardEditEventSideChanged(CardField.front, text('猫')));
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
|
||||||
);
|
|
||||||
bloc.add(const CardEditEventSaveRequested());
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
(await cards.cardsOf('japanese')).single.content.front,
|
|
||||||
text('猫'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an existing card can be saved only when changed', () async {
|
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
|
||||||
final bloc = CardEditBloc(
|
|
||||||
cards,
|
|
||||||
tags,
|
|
||||||
'japanese',
|
|
||||||
(await cards.cardsOf('japanese')).single,
|
|
||||||
);
|
|
||||||
addTearDown(bloc.close);
|
|
||||||
expect(bloc.state.canSave, isFalse);
|
|
||||||
|
|
||||||
bloc.add(CardEditEventSideChanged(CardField.back, text('a cat')));
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('with a card changed differently on two devices', () {
|
|
||||||
late Card conflicted;
|
|
||||||
late CardEditBloc bloc;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
|
||||||
final created = (await cards.cardsOf('japanese')).single;
|
|
||||||
await cards.save(created, card('猫', 'a cat'));
|
|
||||||
gateway.receiveRemoteRevision(
|
|
||||||
id: 'rev-9000',
|
|
||||||
cardId: created.id,
|
|
||||||
parents: created.heads,
|
|
||||||
payload: encodeCardPayload(card('猫', 'the cat')),
|
|
||||||
);
|
|
||||||
conflicted = (await cards.cardsOf('japanese')).single;
|
|
||||||
bloc = CardEditBloc(cards, tags, 'japanese', conflicted);
|
|
||||||
});
|
|
||||||
tearDown(() => bloc.close());
|
|
||||||
|
|
||||||
test('waits for a version of every conflicted side', () {
|
|
||||||
expect(bloc.state.unresolvedSides, {CardField.back});
|
|
||||||
expect(bloc.state.canSave, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('choosing a version resolves the side; saving resolves the '
|
|
||||||
'card', () async {
|
|
||||||
bloc.add(CardEditEventVersionChosen(CardField.back, text('a cat')));
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(
|
|
||||||
isA<CardEditState>()
|
|
||||||
.having((s) => s.unresolvedSides, 'unresolved', isEmpty)
|
|
||||||
.having((s) => s.canSave, 'can save', true),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
bloc.add(const CardEditEventSaveRequested());
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
|
|
||||||
);
|
|
||||||
|
|
||||||
final resolved = (await cards.cardsOf('japanese')).single;
|
|
||||||
expect(resolved.hasConflict, isFalse);
|
|
||||||
expect(resolved.content.back, text('a cat'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows a card's tags; changing only them can be saved, without a "
|
|
||||||
'new revision of its sides', () async {
|
|
||||||
final cardId = await cards.create('japanese', card('猫', 'cat'));
|
|
||||||
final noun = await tags.create('noun');
|
|
||||||
final animal = await tags.create('animal');
|
|
||||||
await tags.setTagsOf(cardId, {noun});
|
|
||||||
final bloc = CardEditBloc(
|
|
||||||
cards,
|
|
||||||
tags,
|
|
||||||
'japanese',
|
|
||||||
(await cards.cardsOf('japanese')).single,
|
|
||||||
)..add(const CardEditEventStarted());
|
|
||||||
addTearDown(bloc.close);
|
|
||||||
final loaded = await bloc.stream.firstWhere((s) => s.tagIds != null);
|
|
||||||
expect(loaded.tagIds, {noun});
|
|
||||||
expect(loaded.canSave, isFalse);
|
|
||||||
|
|
||||||
bloc.add(CardEditEventTagsChanged({noun, animal}));
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
|
||||||
);
|
|
||||||
bloc.add(const CardEditEventSaveRequested());
|
|
||||||
await bloc.stream.firstWhere((s) => s.isDone);
|
|
||||||
|
|
||||||
final tagging = await tags.read();
|
|
||||||
expect(tagging.of(cardId).map((tag) => tag.name), ['animal', 'noun']);
|
|
||||||
expect(await gateway.revisionsOf({cardId}), hasLength(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a new card gets the tags chosen once saved', () async {
|
|
||||||
final noun = await tags.create('noun');
|
|
||||||
final bloc = CardEditBloc(cards, tags, 'japanese', null)
|
|
||||||
..add(const CardEditEventStarted());
|
|
||||||
addTearDown(bloc.close);
|
|
||||||
await bloc.stream.firstWhere((s) => s.tagIds != null);
|
|
||||||
|
|
||||||
bloc
|
|
||||||
..add(CardEditEventTagsChanged({noun}))
|
|
||||||
..add(CardEditEventSideChanged(CardField.front, text('猫')))
|
|
||||||
..add(const CardEditEventSaveRequested());
|
|
||||||
await bloc.stream.firstWhere((s) => s.isDone);
|
|
||||||
|
|
||||||
final cardId = (await cards.cardsOf('japanese')).single.id;
|
|
||||||
expect((await tags.read()).of(cardId).map((tag) => tag.id), [noun]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a new card with only tags has nothing to save', () async {
|
|
||||||
final noun = await tags.create('noun');
|
|
||||||
final bloc = CardEditBloc(cards, tags, 'japanese', null)
|
|
||||||
..add(const CardEditEventStarted());
|
|
||||||
addTearDown(bloc.close);
|
|
||||||
await bloc.stream.firstWhere((s) => s.tagIds != null);
|
|
||||||
|
|
||||||
bloc.add(CardEditEventTagsChanged({noun}));
|
|
||||||
final state = await bloc.stream.first;
|
|
||||||
|
|
||||||
expect(state.canSave, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deleting the card is done too', () async {
|
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
|
||||||
final bloc = CardEditBloc(
|
|
||||||
cards,
|
|
||||||
tags,
|
|
||||||
'japanese',
|
|
||||||
(await cards.cardsOf('japanese')).single,
|
|
||||||
)..add(const CardEditEventDeleteRequested());
|
|
||||||
addTearDown(bloc.close);
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
bloc.stream,
|
|
||||||
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
|
|
||||||
);
|
|
||||||
expect(await cards.cardsOf('japanese'), isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,21 @@ void main() {
|
||||||
expect((await tags.read()).cardsWith(noun), {neko});
|
expect((await tags.read()).cardsWith(noun), {neko});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an incomplete one still saves its tags, and only them', () async {
|
||||||
|
final neko = await cards.create('japanese', card('猫', ''));
|
||||||
|
final noun = await tags.create('noun');
|
||||||
|
start();
|
||||||
|
await loaded();
|
||||||
|
|
||||||
|
bloc
|
||||||
|
..add(CardComposerEventTagsChanged(neko, {noun}))
|
||||||
|
..add(CardComposerEventLeft(neko));
|
||||||
|
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
|
||||||
|
|
||||||
|
expect(gateway.written, hasLength(1));
|
||||||
|
expect((await tags.read()).cardsWith(noun), {neko});
|
||||||
|
});
|
||||||
|
|
||||||
test('a failed save says so, and leaving again tries anew', () async {
|
test('a failed save says so, and leaving again tries anew', () async {
|
||||||
final neko = await cards.create('japanese', card('猫', 'cat'));
|
final neko = await cards.create('japanese', card('猫', 'cat'));
|
||||||
start();
|
start();
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue