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:
Tizian 「ティツィアーン」 2026-10-01 18:42:25 +02:00
commit 0afc22d454
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 2701 additions and 735 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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