feat(app): choose, manage and filter by tags
The card editor offers its tags: a picker that searches by the start of a name, creates a tag (name and colour) and chooses it, and saves the choice with the card. A sheet lists every tag with its count, to add, rename (merging into a tag of that name), recolour and delete them, with an undo. The card list shows each card's tags and narrows to one tag. The editor closed twice once tags changed after saving; it now closes on becoming done only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c2d4dfc8b6
commit
9dc9b6a5ed
19 changed files with 2076 additions and 63 deletions
|
|
@ -120,6 +120,51 @@
|
|||
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
|
||||
"settingsAbout": "About Halkyon Learn",
|
||||
|
||||
"tagsReadFailed": "Couldn't read the tags.",
|
||||
"tagPickerTitle": "Tags",
|
||||
"tagPickerManage": "Manage tags",
|
||||
"tagPickerSearchLabel": "Search or create a tag",
|
||||
"tagPickerCreate": "Create \"{name}\"",
|
||||
"@tagPickerCreate": {"placeholders": {"name": {"type": "String"}}},
|
||||
"tagSheetCreateTitle": "Create tag",
|
||||
"tagSheetEditTitle": "Edit tag",
|
||||
"tagSheetNameLabel": "Name",
|
||||
"tagSheetNameHelper": "Stored and displayed in lowercase.",
|
||||
"tagSheetNameExists": "The tag '{name}' exists already.",
|
||||
"@tagSheetNameExists": {"placeholders": {"name": {"type": "String"}}},
|
||||
"tagSheetNameMerges": "There is a tag '{name}' already: saving merges this one into it.",
|
||||
"@tagSheetNameMerges": {"placeholders": {"name": {"type": "String"}}},
|
||||
"tagSheetColor": "Color",
|
||||
"tagSheetCreate": "Create",
|
||||
"tagSheetSave": "Save",
|
||||
"tagManageTitle": "Manage tags",
|
||||
"tagManageEmpty": "No tags yet. Add one below.",
|
||||
"tagManageAdd": "Add tag",
|
||||
"tagManageRow": "{name} ({count})",
|
||||
"@tagManageRow": {"description": "A tag in the list of tags, with how many flashcards have it.", "placeholders": {"name": {"type": "String"}, "count": {"type": "int"}}},
|
||||
"tagManageEdit": "Edit tag",
|
||||
"tagManageDelete": "Delete tag",
|
||||
"tagDeleteTitle": "Delete the tag '{name}'?",
|
||||
"@tagDeleteTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||
"tagDeleteMessage": "{count, plural, =0{It isn't on any flashcards.} =1{It is removed from 1 flashcard, on all your devices.} other{It is removed from {count} flashcards, on all your devices.}}",
|
||||
"@tagDeleteMessage": {"placeholders": {"count": {"type": "int"}}},
|
||||
"tagDeleteConfirm": "Delete",
|
||||
"tagDeleted": "Deleted the tag '{name}'",
|
||||
"@tagDeleted": {"placeholders": {"name": {"type": "String"}}},
|
||||
"cardTagsLabel": "Tags",
|
||||
"cardTagsAdd": "Add tags",
|
||||
"cardTagsEdit": "Edit tags",
|
||||
"cardFilterTooltip": "Filter",
|
||||
"cardFilterTitle": "Filter",
|
||||
"cardFilterTag": "Tag",
|
||||
"cardFilterAllTags": "All tags",
|
||||
"cardFilterNoTags": "None of these flashcards has a tag yet.",
|
||||
"cardFilterClear": "Clear all",
|
||||
"cardFilterApply": "Apply",
|
||||
"cardFilterNothing": "No flashcards have this tag.",
|
||||
"cardListMore": "More",
|
||||
"cardListManageTags": "Manage tags",
|
||||
|
||||
"commonCancel": "Cancel",
|
||||
"commonClose": "Close",
|
||||
"commonUndo": "Undo",
|
||||
|
|
|
|||
|
|
@ -712,6 +712,228 @@ abstract class AppLocalizations {
|
|||
/// **'About Halkyon Learn'**
|
||||
String get settingsAbout;
|
||||
|
||||
/// No description provided for @tagsReadFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t read the tags.'**
|
||||
String get tagsReadFailed;
|
||||
|
||||
/// No description provided for @tagPickerTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tags'**
|
||||
String get tagPickerTitle;
|
||||
|
||||
/// No description provided for @tagPickerManage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage tags'**
|
||||
String get tagPickerManage;
|
||||
|
||||
/// No description provided for @tagPickerSearchLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search or create a tag'**
|
||||
String get tagPickerSearchLabel;
|
||||
|
||||
/// No description provided for @tagPickerCreate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create \"{name}\"'**
|
||||
String tagPickerCreate(String name);
|
||||
|
||||
/// No description provided for @tagSheetCreateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create tag'**
|
||||
String get tagSheetCreateTitle;
|
||||
|
||||
/// No description provided for @tagSheetEditTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit tag'**
|
||||
String get tagSheetEditTitle;
|
||||
|
||||
/// No description provided for @tagSheetNameLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name'**
|
||||
String get tagSheetNameLabel;
|
||||
|
||||
/// No description provided for @tagSheetNameHelper.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stored and displayed in lowercase.'**
|
||||
String get tagSheetNameHelper;
|
||||
|
||||
/// No description provided for @tagSheetNameExists.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The tag \'{name}\' exists already.'**
|
||||
String tagSheetNameExists(String name);
|
||||
|
||||
/// No description provided for @tagSheetNameMerges.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'There is a tag \'{name}\' already: saving merges this one into it.'**
|
||||
String tagSheetNameMerges(String name);
|
||||
|
||||
/// No description provided for @tagSheetColor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Color'**
|
||||
String get tagSheetColor;
|
||||
|
||||
/// No description provided for @tagSheetCreate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create'**
|
||||
String get tagSheetCreate;
|
||||
|
||||
/// No description provided for @tagSheetSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get tagSheetSave;
|
||||
|
||||
/// No description provided for @tagManageTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage tags'**
|
||||
String get tagManageTitle;
|
||||
|
||||
/// No description provided for @tagManageEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No tags yet. Add one below.'**
|
||||
String get tagManageEmpty;
|
||||
|
||||
/// No description provided for @tagManageAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add tag'**
|
||||
String get tagManageAdd;
|
||||
|
||||
/// A tag in the list of tags, with how many flashcards have it.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{name} ({count})'**
|
||||
String tagManageRow(String name, int count);
|
||||
|
||||
/// No description provided for @tagManageEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit tag'**
|
||||
String get tagManageEdit;
|
||||
|
||||
/// No description provided for @tagManageDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete tag'**
|
||||
String get tagManageDelete;
|
||||
|
||||
/// No description provided for @tagDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete the tag \'{name}\'?'**
|
||||
String tagDeleteTitle(String name);
|
||||
|
||||
/// No description provided for @tagDeleteMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{It isn\'t on any flashcards.} =1{It is removed from 1 flashcard, on all your devices.} other{It is removed from {count} flashcards, on all your devices.}}'**
|
||||
String tagDeleteMessage(int count);
|
||||
|
||||
/// No description provided for @tagDeleteConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get tagDeleteConfirm;
|
||||
|
||||
/// No description provided for @tagDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deleted the tag \'{name}\''**
|
||||
String tagDeleted(String name);
|
||||
|
||||
/// No description provided for @cardTagsLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tags'**
|
||||
String get cardTagsLabel;
|
||||
|
||||
/// No description provided for @cardTagsAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add tags'**
|
||||
String get cardTagsAdd;
|
||||
|
||||
/// No description provided for @cardTagsEdit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit tags'**
|
||||
String get cardTagsEdit;
|
||||
|
||||
/// No description provided for @cardFilterTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filter'**
|
||||
String get cardFilterTooltip;
|
||||
|
||||
/// No description provided for @cardFilterTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filter'**
|
||||
String get cardFilterTitle;
|
||||
|
||||
/// No description provided for @cardFilterTag.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tag'**
|
||||
String get cardFilterTag;
|
||||
|
||||
/// No description provided for @cardFilterAllTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All tags'**
|
||||
String get cardFilterAllTags;
|
||||
|
||||
/// No description provided for @cardFilterNoTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None of these flashcards has a tag yet.'**
|
||||
String get cardFilterNoTags;
|
||||
|
||||
/// No description provided for @cardFilterClear.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear all'**
|
||||
String get cardFilterClear;
|
||||
|
||||
/// No description provided for @cardFilterApply.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply'**
|
||||
String get cardFilterApply;
|
||||
|
||||
/// No description provided for @cardFilterNothing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No flashcards have this tag.'**
|
||||
String get cardFilterNothing;
|
||||
|
||||
/// No description provided for @cardListMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More'**
|
||||
String get cardListMore;
|
||||
|
||||
/// No description provided for @cardListManageTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage tags'**
|
||||
String get cardListManageTags;
|
||||
|
||||
/// No description provided for @commonCancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -369,6 +369,138 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get settingsAbout => 'About Halkyon Learn';
|
||||
|
||||
@override
|
||||
String get tagsReadFailed => 'Couldn\'t read the tags.';
|
||||
|
||||
@override
|
||||
String get tagPickerTitle => 'Tags';
|
||||
|
||||
@override
|
||||
String get tagPickerManage => 'Manage tags';
|
||||
|
||||
@override
|
||||
String get tagPickerSearchLabel => 'Search or create a tag';
|
||||
|
||||
@override
|
||||
String tagPickerCreate(String name) {
|
||||
return 'Create \"$name\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tagSheetCreateTitle => 'Create tag';
|
||||
|
||||
@override
|
||||
String get tagSheetEditTitle => 'Edit tag';
|
||||
|
||||
@override
|
||||
String get tagSheetNameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get tagSheetNameHelper => 'Stored and displayed in lowercase.';
|
||||
|
||||
@override
|
||||
String tagSheetNameExists(String name) {
|
||||
return 'The tag \'$name\' exists already.';
|
||||
}
|
||||
|
||||
@override
|
||||
String tagSheetNameMerges(String name) {
|
||||
return 'There is a tag \'$name\' already: saving merges this one into it.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tagSheetColor => 'Color';
|
||||
|
||||
@override
|
||||
String get tagSheetCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get tagSheetSave => 'Save';
|
||||
|
||||
@override
|
||||
String get tagManageTitle => 'Manage tags';
|
||||
|
||||
@override
|
||||
String get tagManageEmpty => 'No tags yet. Add one below.';
|
||||
|
||||
@override
|
||||
String get tagManageAdd => 'Add tag';
|
||||
|
||||
@override
|
||||
String tagManageRow(String name, int count) {
|
||||
return '$name ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tagManageEdit => 'Edit tag';
|
||||
|
||||
@override
|
||||
String get tagManageDelete => 'Delete tag';
|
||||
|
||||
@override
|
||||
String tagDeleteTitle(String name) {
|
||||
return 'Delete the tag \'$name\'?';
|
||||
}
|
||||
|
||||
@override
|
||||
String tagDeleteMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'It is removed from $count flashcards, on all your devices.',
|
||||
one: 'It is removed from 1 flashcard, on all your devices.',
|
||||
zero: 'It isn\'t on any flashcards.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get tagDeleteConfirm => 'Delete';
|
||||
|
||||
@override
|
||||
String tagDeleted(String name) {
|
||||
return 'Deleted the tag \'$name\'';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardTagsLabel => 'Tags';
|
||||
|
||||
@override
|
||||
String get cardTagsAdd => 'Add tags';
|
||||
|
||||
@override
|
||||
String get cardTagsEdit => 'Edit tags';
|
||||
|
||||
@override
|
||||
String get cardFilterTooltip => 'Filter';
|
||||
|
||||
@override
|
||||
String get cardFilterTitle => 'Filter';
|
||||
|
||||
@override
|
||||
String get cardFilterTag => 'Tag';
|
||||
|
||||
@override
|
||||
String get cardFilterAllTags => 'All tags';
|
||||
|
||||
@override
|
||||
String get cardFilterNoTags => 'None of these flashcards has a tag yet.';
|
||||
|
||||
@override
|
||||
String get cardFilterClear => 'Clear all';
|
||||
|
||||
@override
|
||||
String get cardFilterApply => 'Apply';
|
||||
|
||||
@override
|
||||
String get cardFilterNothing => 'No flashcards have this tag.';
|
||||
|
||||
@override
|
||||
String get cardListMore => 'More';
|
||||
|
||||
@override
|
||||
String get cardListManageTags => 'Manage tags';
|
||||
|
||||
@override
|
||||
String get commonCancel => 'Cancel';
|
||||
|
||||
|
|
|
|||
|
|
@ -105,23 +105,29 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
builder: (context, state) => BlocProvider(
|
||||
create: (context) => CardEditBloc(
|
||||
PersonScope.of(context).cards,
|
||||
PersonScope.of(context).tags,
|
||||
state.pathParameters['studysetId']!,
|
||||
null,
|
||||
),
|
||||
)..add(const CardEditEventStarted()),
|
||||
child: const CardScreen(card: null),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/studysets/:studysetId/cards/:cardId',
|
||||
builder: (context, state) {
|
||||
final cards = PersonScope.of(context).cards;
|
||||
final person = PersonScope.of(context);
|
||||
final studysetId = state.pathParameters['studysetId']!;
|
||||
return FoundById<Card>(
|
||||
id: state.pathParameters['cardId']!,
|
||||
items: () => cards.watch(studysetId),
|
||||
items: () => person.cards.watch(studysetId),
|
||||
idOf: (card) => card.id,
|
||||
builder: (card, _) => BlocProvider(
|
||||
create: (context) => CardEditBloc(cards, studysetId, card),
|
||||
create: (context) => CardEditBloc(
|
||||
person.cards,
|
||||
person.tags,
|
||||
studysetId,
|
||||
card,
|
||||
)..add(const CardEditEventStarted()),
|
||||
child: CardScreen(card: card),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ class const SheetScaffold({
|
|||
/// Below the content, always in view.
|
||||
final Widget? footer,
|
||||
|
||||
/// Beside the title, before the close button: an action on the sheet as a
|
||||
/// whole, such as managing what it lists.
|
||||
final Widget? action,
|
||||
|
||||
/// What the close button does; by default it asks the sheet's route to
|
||||
/// close, which a `PopScope` in the sheet can stop (to ask before
|
||||
/// discarding changes, say).
|
||||
|
|
@ -106,6 +110,11 @@ class const SheetScaffold({
|
|||
/// The share of the window's height a bottom sheet takes; it hugs its
|
||||
/// content when null.
|
||||
final double? heightFactor,
|
||||
|
||||
/// Whether snack bars shown from inside the sheet show on it, where they
|
||||
/// can be acted on (an Undo, say); those of the screens below would sit
|
||||
/// behind its barrier. The sheet then always takes its largest height.
|
||||
final bool hasSnackBars = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The widest a sheet grows as a dialog.
|
||||
|
|
@ -126,7 +135,7 @@ class const SheetScaffold({
|
|||
: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(title: title, onClose: onClose),
|
||||
_Header(title: title, action: action, onClose: onClose),
|
||||
// Loose, so a short body keeps the sheet short; tight at a fixed
|
||||
// height, so the footer sits at the bottom.
|
||||
if (!isDialog && heightFactor != null)
|
||||
|
|
@ -136,6 +145,16 @@ class const SheetScaffold({
|
|||
if (footer case final footer?) ...[const SizedBox(height: 12), footer],
|
||||
],
|
||||
);
|
||||
final content = hasSnackBars
|
||||
? ScaffoldMessenger(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
// The sheet keeps clear of the keyboard itself.
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: column,
|
||||
),
|
||||
)
|
||||
: column;
|
||||
if (isDialog) {
|
||||
// The dialog keeps clear of the window's edges and the keyboard
|
||||
// itself; insetting here as well would count them twice.
|
||||
|
|
@ -146,7 +165,7 @@ class const SheetScaffold({
|
|||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 20),
|
||||
child: column,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -176,11 +195,11 @@ class const SheetScaffold({
|
|||
constraints: BoxConstraints(
|
||||
maxHeight: math.min(media.size.height * 0.85, available),
|
||||
),
|
||||
child: column,
|
||||
child: content,
|
||||
)
|
||||
: SizedBox(
|
||||
height: math.min(media.size.height * heightFactor, available),
|
||||
child: column,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -193,6 +212,7 @@ class const SheetScaffold({
|
|||
/// taller than the window, and a dialog can't be swiped at all.
|
||||
class const _Header({
|
||||
required final String title,
|
||||
required final Widget? action,
|
||||
required final VoidCallback? onClose,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -206,6 +226,7 @@ class const _Header({
|
|||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?action,
|
||||
IconButton(
|
||||
tooltip: AppLocalizations.of(context).commonClose,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.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 until they are saved, has
|
||||
/// the person choose a version for every side devices changed differently,
|
||||
/// and deletes on request.
|
||||
/// 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> {
|
||||
|
|
@ -20,6 +23,29 @@ class CardEditBloc(
|
|||
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)),
|
||||
);
|
||||
|
|
@ -42,10 +68,17 @@ class CardEditBloc(
|
|||
if (!state.canSave) {
|
||||
return;
|
||||
}
|
||||
final String id;
|
||||
if (state.card case final card?) {
|
||||
await _cards.save(card, state.content);
|
||||
id = card.id;
|
||||
if (state.contentChanged) {
|
||||
await _cards.save(card, state.content);
|
||||
}
|
||||
} else {
|
||||
await _cards.create(_studysetId, state.content);
|
||||
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));
|
||||
}
|
||||
|
|
@ -67,6 +100,18 @@ sealed class const CardEditEvent() with Equatable {
|
|||
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.
|
||||
|
|
@ -110,19 +155,43 @@ final class const CardEditState({
|
|||
|
||||
/// 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<CardSide> 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 changed or in conflict.
|
||||
/// 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(),
|
||||
final card => content != card.content || card.hasConflict,
|
||||
_ => contentChanged || tagsChanged,
|
||||
};
|
||||
|
||||
/// This state with some fields replaced.
|
||||
|
|
@ -130,13 +199,27 @@ final class const CardEditState({
|
|||
CardContent? content,
|
||||
Set<CardSide>? 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];
|
||||
List<Object?> get props => [
|
||||
content,
|
||||
card,
|
||||
resolvedSides,
|
||||
isDone,
|
||||
tagging,
|
||||
savedTagIds,
|
||||
tagIds,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,72 @@ 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/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
/// Shows a studyset's cards in order and moves them; the list follows every
|
||||
/// change, local or synced.
|
||||
class CardListBloc(final CardRepository _cards, final String _studysetId)
|
||||
extends Bloc<CardListEvent, CardListState> {
|
||||
/// Shows a studyset's cards in order with their tags, all of them or those
|
||||
/// with one tag, and moves them; the list follows every change, local or
|
||||
/// synced.
|
||||
class CardListBloc(
|
||||
final CardRepository _cards,
|
||||
final TagRepository _tags,
|
||||
final String _studysetId,
|
||||
) extends Bloc<CardListEvent, CardListState> {
|
||||
this : super(const CardListStateLoading()) {
|
||||
on<CardListEventStarted>(_onStarted);
|
||||
on<CardListEventMoved>(_onMoved);
|
||||
on<CardListEventTagFilterChanged>((event, emit) {
|
||||
_tagFilter = event.tagId;
|
||||
if (_loaded() case final loaded?) {
|
||||
emit(loaded);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<Card>? _cardsNow;
|
||||
var _tagging = Tagging.empty;
|
||||
String? _tagFilter;
|
||||
|
||||
Future<void> _onStarted(
|
||||
CardListEventStarted event,
|
||||
Emitter<CardListState> emit,
|
||||
) => emit.forEach(
|
||||
_cards.watch(_studysetId),
|
||||
onData: CardListStateLoaded.new,
|
||||
onError: (_, _) => const CardListStateFailed(),
|
||||
);
|
||||
) async {
|
||||
await Future.wait([
|
||||
emit.forEach(
|
||||
_cards.watch(_studysetId),
|
||||
onData: (cards) {
|
||||
_cardsNow = cards;
|
||||
return _loaded()!;
|
||||
},
|
||||
onError: (_, _) => const CardListStateFailed(),
|
||||
),
|
||||
emit.forEach(
|
||||
_tags.watch(),
|
||||
onData: (tagging) {
|
||||
_tagging = tagging;
|
||||
return _loaded() ?? state;
|
||||
},
|
||||
// Without tags the cards still show.
|
||||
onError: (_, _) => state,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _onMoved(
|
||||
CardListEventMoved event,
|
||||
Emitter<CardListState> emit,
|
||||
) => _cards.move(_studysetId, event.card, toIndex: event.toIndex);
|
||||
|
||||
/// The list as it stands, or null while the cards are being read.
|
||||
CardListStateLoaded? _loaded() => switch (_cardsNow) {
|
||||
final cards? => CardListStateLoaded(
|
||||
cards,
|
||||
tagging: _tagging,
|
||||
tagFilter: _tagFilter,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// What happens to the list of cards.
|
||||
|
|
@ -33,7 +76,7 @@ sealed class const CardListEvent() with Equatable {
|
|||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The list opens: follow the studyset's cards.
|
||||
/// The list opens: follow the studyset's cards and the tags.
|
||||
final class const CardListEventStarted() extends CardListEvent;
|
||||
|
||||
/// Move [card] to [toIndex].
|
||||
|
|
@ -48,6 +91,15 @@ final class const CardListEventMoved(
|
|||
List<Object?> get props => [card, toIndex];
|
||||
}
|
||||
|
||||
/// Show only the cards with the tag [tagId], or all when it is null.
|
||||
final class const CardListEventTagFilterChanged(
|
||||
/// The tag's id.
|
||||
final String? tagId,
|
||||
) extends CardListEvent {
|
||||
@override
|
||||
List<Object?> get props => [tagId];
|
||||
}
|
||||
|
||||
/// A studyset's cards, as the list shows them.
|
||||
sealed class const CardListState() with Equatable {
|
||||
@override
|
||||
|
|
@ -60,11 +112,48 @@ final class const CardListStateLoading() extends CardListState;
|
|||
/// The cards couldn't be read; the next change tries again.
|
||||
final class const CardListStateFailed() extends CardListState;
|
||||
|
||||
/// The cards, in order.
|
||||
/// The cards, in order, with their tags.
|
||||
final class const CardListStateLoaded(
|
||||
/// The cards of the studyset on this device.
|
||||
final List<Card> cards,
|
||||
) extends CardListState {
|
||||
final List<Card> cards, {
|
||||
|
||||
/// The person's tags and which cards have them.
|
||||
final Tagging tagging = Tagging.empty,
|
||||
|
||||
/// The id of the tag the list is narrowed to; null for all cards.
|
||||
final String? tagFilter,
|
||||
}) extends CardListState {
|
||||
/// The tag the list is narrowed to, while it exists.
|
||||
Tag? get filter => switch (tagFilter) {
|
||||
final id? => tagging.byId(id),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// The cards shown: those with the [filter] tag, or all.
|
||||
List<Card> get shown {
|
||||
final filter = this.filter;
|
||||
if (filter == null) {
|
||||
return cards;
|
||||
}
|
||||
final tagged = tagging.cardsWith(filter.id);
|
||||
return [
|
||||
for (final card in cards)
|
||||
if (tagged.contains(card.id)) card,
|
||||
];
|
||||
}
|
||||
|
||||
/// The tags any of the cards has, by name: those to filter by.
|
||||
List<Tag> get tagsInUse {
|
||||
final ids = {
|
||||
for (final card in cards)
|
||||
for (final tag in tagging.of(card.id)) tag.id,
|
||||
};
|
||||
return [
|
||||
for (final tag in tagging.all)
|
||||
if (ids.contains(tag.id)) tag,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cards];
|
||||
List<Object?> get props => [cards, tagging, tagFilter];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,29 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_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/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/manage_tags_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.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;
|
||||
|
||||
/// A studyset's cards: in order, draggable, each opening its editor. Lives
|
||||
/// inside a scrolling screen; adding and studying are the screen's.
|
||||
/// A studyset's cards: in order, draggable, each with its tags and opening
|
||||
/// its editor, all of them or those with one tag. Lives inside a scrolling
|
||||
/// screen; adding and studying are the screen's.
|
||||
class const CardList({
|
||||
/// The person's cards.
|
||||
required final CardRepository cards,
|
||||
|
||||
/// The person's tags.
|
||||
required final TagRepository tags,
|
||||
|
||||
/// The studyset whose cards these are.
|
||||
required final String studysetId,
|
||||
super.key,
|
||||
|
|
@ -21,22 +32,174 @@ class const CardList({
|
|||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) =>
|
||||
CardListBloc(cards, studysetId)..add(const CardListEventStarted()),
|
||||
CardListBloc(cards, tags, studysetId)
|
||||
..add(const CardListEventStarted()),
|
||||
child: BlocBuilder<CardListBloc, CardListState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
CardListStateLoading() => const SizedBox.shrink(),
|
||||
CardListStateFailed() => const Text("Couldn't read the cards."),
|
||||
CardListStateLoaded(cards: []) => const _NoCards(),
|
||||
CardListStateLoaded(cards: final list) => _Cards(
|
||||
cards,
|
||||
studysetId,
|
||||
list,
|
||||
final CardListStateLoaded loaded => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Toolbar(state: loaded),
|
||||
if (loaded.shown.isEmpty)
|
||||
const _NothingTagged()
|
||||
else
|
||||
_Cards(
|
||||
cards,
|
||||
studysetId,
|
||||
loaded.shown,
|
||||
loaded.tagging,
|
||||
reorderable: loaded.filter == null,
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Above the cards: narrowing them to a tag, and managing the tags.
|
||||
class const _Toolbar({required final CardListStateLoaded state})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.cardFilterTooltip,
|
||||
icon: Icon(
|
||||
LucideIcons.listFilter,
|
||||
// Tinted while it narrows the list.
|
||||
color: state.filter == null ? null : theme.colorScheme.primary,
|
||||
),
|
||||
onPressed: () async {
|
||||
final bloc = context.read<CardListBloc>();
|
||||
final chosen = await showAdaptiveSheet<({String? tagId})>(
|
||||
context,
|
||||
builder: (context) => _FilterSheet(
|
||||
tags: state.tagsInUse,
|
||||
tagId: state.filter?.id,
|
||||
),
|
||||
);
|
||||
if (chosen != null) {
|
||||
bloc.add(CardListEventTagFilterChanged(chosen.tagId));
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.tags, size: 18),
|
||||
onPressed: () => showManageTagsSheet(context),
|
||||
child: Text(l10n.cardListManageTags),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => IconButton(
|
||||
tooltip: l10n.cardListMore,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the tag to narrow the cards to, among [tags], or all of them;
|
||||
/// completes with the choice once applied, or with null when closed.
|
||||
class const _FilterSheet({
|
||||
required final List<Tag> tags,
|
||||
required final String? tagId,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_FilterSheet> createState() => _FilterSheetState();
|
||||
}
|
||||
|
||||
class _FilterSheetState() extends State<_FilterSheet> {
|
||||
late var _tagId = widget.tagId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return SheetScaffold(
|
||||
title: l10n.cardFilterTitle,
|
||||
footer: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _tagId == null
|
||||
? null
|
||||
: () => setState(() => _tagId = null),
|
||||
child: Text(l10n.cardFilterClear),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((tagId: _tagId)),
|
||||
child: Text(l10n.cardFilterApply),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.cardFilterTag, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.tags.isEmpty)
|
||||
Text(
|
||||
l10n.cardFilterNoTags,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(l10n.cardFilterAllTags),
|
||||
selected: _tagId == null,
|
||||
onSelected: (_) => setState(() => _tagId = null),
|
||||
),
|
||||
for (final tag in widget.tags)
|
||||
ChoiceChip(
|
||||
label: Text(tag.name),
|
||||
selected: tag.id == _tagId,
|
||||
onSelected: (_) => setState(() => _tagId = tag.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// No card has the tag the list is narrowed to.
|
||||
class const _NothingTagged() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).cardFilterNothing,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// No cards yet, and where they come from.
|
||||
class const _NoCards() extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -65,11 +228,17 @@ class const _Cards(
|
|||
final CardRepository repository,
|
||||
final String studysetId,
|
||||
final List<Card> cards,
|
||||
) extends StatelessWidget {
|
||||
final Tagging tagging, {
|
||||
|
||||
/// Whether cards can be dragged to another place: not while only some
|
||||
/// show, as the places between them are unclear.
|
||||
required final bool reorderable,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => ReorderableListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: reorderable,
|
||||
onReorderItem: (from, to) => context.read<CardListBloc>().add(
|
||||
CardListEventMoved(cards[from], toIndex: to),
|
||||
),
|
||||
|
|
@ -78,7 +247,10 @@ class const _Cards(
|
|||
ListTile(
|
||||
key: ValueKey(card.id),
|
||||
title: Text(_preview(card.content.front.plainText)),
|
||||
subtitle: Text(_preview(card.content.back.plainText)),
|
||||
subtitle: _Subtitle(
|
||||
back: _preview(card.content.back.plainText),
|
||||
tags: tagging.of(card.id),
|
||||
),
|
||||
leading: card.hasConflict
|
||||
? const Tooltip(
|
||||
message: 'Changed on two devices',
|
||||
|
|
@ -91,5 +263,33 @@ class const _Cards(
|
|||
);
|
||||
}
|
||||
|
||||
/// A card's back, and below it its tags, in one line that scrolls sideways
|
||||
/// rather than growing.
|
||||
class const _Subtitle({
|
||||
required final String back,
|
||||
required final List<Tag> tags,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tags.isEmpty) {
|
||||
return Text(back);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(back),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [for (final tag in tags) TagLabel(tag: tag)],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first line of [text], or a dash for nothing.
|
||||
String _preview(String text) => text.isEmpty ? '–' : text.split('\n').first;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
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;
|
||||
|
||||
|
|
@ -19,7 +23,9 @@ class const CardScreen({
|
|||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocListener<CardEditBloc, CardEditState>(
|
||||
listenWhen: (previous, current) => current.isDone,
|
||||
// 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(
|
||||
|
|
@ -85,6 +91,13 @@ class const CardScreen({
|
|||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -92,6 +105,43 @@ class const CardScreen({
|
|||
);
|
||||
}
|
||||
|
||||
/// 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 CardSide side,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ class const _Content({required final String studysetId})
|
|||
children: [
|
||||
CardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
tags: PersonScope.of(context).tags,
|
||||
studysetId: studysetId,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
109
halkyon_learn_flutter/lib/src/feature/tag/bloc/tags_bloc.dart
Normal file
109
halkyon_learn_flutter/lib/src/feature/tag/bloc/tags_bloc.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
/// Shows the person's tags and creates, changes and deletes them; the
|
||||
/// tagging follows every change, local or synced.
|
||||
class TagsBloc(final TagRepository _tags) extends Bloc<TagsEvent, TagsState> {
|
||||
this : super(const TagsStateLoading()) {
|
||||
on<TagsEventStarted>(
|
||||
(event, emit) => emit.forEach(
|
||||
_tags.watch(),
|
||||
onData: TagsStateLoaded.new,
|
||||
onError: (_, _) => const TagsStateFailed(),
|
||||
),
|
||||
);
|
||||
on<TagsEventCreateRequested>(
|
||||
(event, emit) => _tags.create(
|
||||
event.content.name,
|
||||
colorName: event.content.colorName,
|
||||
),
|
||||
);
|
||||
on<TagsEventUpdateRequested>(
|
||||
(event, emit) => _tags.update(
|
||||
event.id,
|
||||
name: event.content.name,
|
||||
colorName: event.content.colorName,
|
||||
),
|
||||
);
|
||||
on<TagsEventDeleteRequested>((event, emit) async {
|
||||
_deleting = _tags.delete(event.id);
|
||||
await _deleting;
|
||||
});
|
||||
on<TagsEventUndoDeleteRequested>((event, emit) async {
|
||||
final deleting = _deleting;
|
||||
_deleting = null;
|
||||
if (deleting != null) {
|
||||
await _tags.restore(await deleting);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The last deletion, which an undo takes back.
|
||||
Future<DeletedTag>? _deleting;
|
||||
}
|
||||
|
||||
/// What happens to the person's tags.
|
||||
sealed class const TagsEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Follow the person's tags.
|
||||
final class const TagsEventStarted() extends TagsEvent;
|
||||
|
||||
/// Create a tag with [content]; a name that exists already is that tag.
|
||||
final class const TagsEventCreateRequested(
|
||||
/// The new tag's name and colour.
|
||||
final TagContent content,
|
||||
) extends TagsEvent {
|
||||
@override
|
||||
List<Object?> get props => [content];
|
||||
}
|
||||
|
||||
/// Rename and recolour the tag [id]; a name another tag has merges the two.
|
||||
final class const TagsEventUpdateRequested(
|
||||
/// The tag's id.
|
||||
final String id,
|
||||
|
||||
/// Its new name and colour.
|
||||
final TagContent content,
|
||||
) extends TagsEvent {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
||||
/// Delete the tag [id] from every card and then itself.
|
||||
final class const TagsEventDeleteRequested(
|
||||
/// The tag's id.
|
||||
final String id,
|
||||
) extends TagsEvent {
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Undo the last deletion: a tag of its name and colour, on its cards.
|
||||
final class const TagsEventUndoDeleteRequested() extends TagsEvent;
|
||||
|
||||
/// The person's tags, as the sheets show them.
|
||||
sealed class const TagsState() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Reading the tags.
|
||||
final class const TagsStateLoading() extends TagsState;
|
||||
|
||||
/// The tags couldn't be read; the next change tries again.
|
||||
final class const TagsStateFailed() extends TagsState;
|
||||
|
||||
/// The tags and which cards have them.
|
||||
final class const TagsStateLoaded(
|
||||
/// The person's tagging.
|
||||
final Tagging tagging,
|
||||
) extends TagsState {
|
||||
@override
|
||||
List<Object?> get props => [tagging];
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ typedef DeletedTag = ({String name, String colorName, Set<String> cardIds});
|
|||
/// the same one, so they all end up alike.
|
||||
class TagRepository(
|
||||
final TagGateway _gateway, {
|
||||
|
||||
/// Fires when cards change; a deleted card takes its tags along.
|
||||
Stream<void> cardChanges = const Stream.empty(),
|
||||
}) {
|
||||
|
|
@ -33,8 +34,11 @@ class TagRepository(
|
|||
late final List<StreamSubscription<void>> _subscriptions;
|
||||
|
||||
// Every read and write waits for the one before, so a fold never meets a
|
||||
// half-done change, and an older tagging never overtakes a newer one.
|
||||
Future<void> _queue = Future.value();
|
||||
// half-done change, and an older tagging never overtakes a newer one. Null
|
||||
// while nothing runs: a completed future kept from before would run what
|
||||
// waits for it in the zone it was made in, which a widget test's fake
|
||||
// clock never reaches.
|
||||
Future<void>? _queue;
|
||||
|
||||
/// The tagging now and after every change, local or synced. A read that
|
||||
/// fails arrives as an error; the next change reads again.
|
||||
|
|
@ -60,14 +64,7 @@ class TagRepository(
|
|||
if (canonicalTagName(name).isEmpty) {
|
||||
throw ArgumentError.value(name, 'name', 'is blank');
|
||||
}
|
||||
final id = await _serially(() async {
|
||||
if ((await _read()).named(name) case final existing?) {
|
||||
return existing.id;
|
||||
}
|
||||
return await _gateway.insertTag(
|
||||
encodeTagPayload((name: name, colorName: colorName)),
|
||||
);
|
||||
});
|
||||
final id = await _serially(() => _idOf(name, colorName));
|
||||
await _refresh();
|
||||
return id;
|
||||
}
|
||||
|
|
@ -142,8 +139,8 @@ class TagRepository(
|
|||
if (deleted.name.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final id = await create(deleted.name, colorName: deleted.colorName);
|
||||
await _serially(() async {
|
||||
final id = await _idOf(deleted.name, deleted.colorName);
|
||||
final has = (await _read()).cardsWith(id);
|
||||
await _gateway.insertCardTags([
|
||||
for (final cardId in deleted.cardIds)
|
||||
|
|
@ -162,10 +159,19 @@ class TagRepository(
|
|||
}
|
||||
|
||||
/// Runs [action] once everything before it is done.
|
||||
Future<T> _serially<T>(Future<T> Function() action) {
|
||||
final result = _queue.then((_) => action());
|
||||
_queue = result.then<void>((_) {}, onError: (_) {});
|
||||
return result;
|
||||
Future<T> _serially<T>(Future<T> Function() action) async {
|
||||
final previous = _queue;
|
||||
final done = Completer<void>();
|
||||
_queue = done.future;
|
||||
try {
|
||||
await previous;
|
||||
return await action();
|
||||
} finally {
|
||||
done.complete();
|
||||
if (identical(_queue, done.future)) {
|
||||
_queue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed read goes to the listeners and never ends the queue: the next
|
||||
|
|
@ -243,6 +249,17 @@ class TagRepository(
|
|||
return folded;
|
||||
}
|
||||
|
||||
/// The id of the tag named [name]: an existing one, or a new one in
|
||||
/// [colorName]; call it only in [_serially].
|
||||
Future<String> _idOf(String name, String colorName) async {
|
||||
if ((await _read()).named(name) case final existing?) {
|
||||
return existing.id;
|
||||
}
|
||||
return await _gateway.insertTag(
|
||||
encodeTagPayload((name: name, colorName: colorName)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Blanks the tag [id], then deletes it from every card and itself.
|
||||
Future<void> _deleteTag(String id) async {
|
||||
await _gateway.updateTagPayload(id, redactedPayload);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/tags_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.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_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows every tag with how many flashcards have it, to add, rename,
|
||||
/// recolour and delete tags; a deletion can be undone for a few seconds.
|
||||
Future<void> showManageTagsSheet(BuildContext context) =>
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) =>
|
||||
TagsBloc(PersonScope.of(context).tags)
|
||||
..add(const TagsEventStarted()),
|
||||
child: const _ManageTags(),
|
||||
),
|
||||
);
|
||||
|
||||
class const _ManageTags() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocBuilder<TagsBloc, TagsState>(
|
||||
builder: (context, state) => SheetScaffold(
|
||||
title: l10n.tagManageTitle,
|
||||
heightFactor: 0.8,
|
||||
// The Undo of a deletion shows on the sheet, where it can be tapped.
|
||||
hasSnackBars: true,
|
||||
footer: switch (state) {
|
||||
TagsStateLoaded(:final tagging) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _add(context, tagging),
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: Text(l10n.tagManageAdd),
|
||||
),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
body: switch (state) {
|
||||
TagsStateLoading() => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
TagsStateFailed() => Text(l10n.tagsReadFailed),
|
||||
TagsStateLoaded(tagging: Tagging(all: [])) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 20),
|
||||
child: Text(
|
||||
l10n.tagManageEmpty,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TagsStateLoaded(:final tagging) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
for (final tag in tagging.all)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _TagRow(tag: tag, tagging: tagging),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _add(BuildContext context, Tagging tagging) async {
|
||||
final bloc = context.read<TagsBloc>();
|
||||
if (await showTagSheet(context, tagging: tagging) case final content?) {
|
||||
bloc.add(TagsEventCreateRequested(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag with its count of flashcards, and editing and deleting it.
|
||||
class const _TagRow({required final Tag tag, required final Tagging tagging})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final color = tagColor(tag);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n.tagManageRow(tag.name, tagging.cardsWith(tag.id).length),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color.foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.tagManageEdit,
|
||||
onPressed: () => _edit(context),
|
||||
icon: const Icon(LucideIcons.pencil, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.tagManageDelete,
|
||||
onPressed: () => _delete(context),
|
||||
icon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _edit(BuildContext context) async {
|
||||
final bloc = context.read<TagsBloc>();
|
||||
if (await showTagSheet(context, tagging: tagging, initial: tag)
|
||||
case final content?) {
|
||||
bloc.add(TagsEventUpdateRequested(tag.id, content));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<TagsBloc>();
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.tagDeleteTitle(tag.name),
|
||||
message: l10n.tagDeleteMessage(tagging.cardsWith(tag.id).length),
|
||||
confirmLabel: l10n.tagDeleteConfirm,
|
||||
) ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
bloc.add(TagsEventDeleteRequested(tag.id));
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.tagDeleted(tag.name),
|
||||
onUndo: () => bloc.add(const TagsEventUndoDeleteRequested()),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_chip.dart
Normal file
102
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_chip.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A tag as a chip, filled with its colour and labelled in the colour's
|
||||
/// foreground: the theme's text colours are unreadable on half the palette.
|
||||
/// Without [onPressed] it only shows the tag.
|
||||
class const TagChip({
|
||||
/// The tag shown.
|
||||
required final Tag tag,
|
||||
|
||||
/// What tapping the chip does.
|
||||
final VoidCallback? onPressed,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = tagColor(tag);
|
||||
if (onPressed case final onPressed?) {
|
||||
return ActionChip(
|
||||
label: Text(tag.name),
|
||||
backgroundColor: color.background,
|
||||
side: BorderSide.none,
|
||||
labelStyle: TextStyle(color: color.foreground),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
return Chip(
|
||||
label: Text(tag.name),
|
||||
backgroundColor: color.background,
|
||||
side: BorderSide.none,
|
||||
labelStyle: TextStyle(color: color.foreground),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A chip toggling a tag: filled with its colour while selected, and
|
||||
/// outlined in it otherwise, so a long list still reads as these tags.
|
||||
class const SelectableTagChip({
|
||||
/// The tag toggled.
|
||||
required final Tag tag,
|
||||
|
||||
/// Whether it is selected.
|
||||
required final bool selected,
|
||||
|
||||
/// Called when it is toggled; null disables it.
|
||||
required final ValueChanged<bool>? onSelected,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = tagColor(tag);
|
||||
return FilterChip(
|
||||
label: Text(tag.name),
|
||||
selected: selected,
|
||||
onSelected: onSelected,
|
||||
// The fill says it is selected; a check mark as well would crowd
|
||||
// short names.
|
||||
showCheckmark: false,
|
||||
selectedColor: color.background,
|
||||
side: selected ? null : BorderSide(color: color.background),
|
||||
labelStyle: TextStyle(
|
||||
color: selected
|
||||
? color.foreground
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag as a small label, for lists where a chip would be too big.
|
||||
class const TagLabel({required final Tag tag, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = tagColor(tag);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
tag.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: color.foreground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [tag]'s colour; the default one for a colour only a newer app version
|
||||
/// knows.
|
||||
PaletteColor tagColor(Tag tag) =>
|
||||
PaletteColor.byName(tag.colorName) ??
|
||||
PaletteColor.byName(defaultTagColorName)!;
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/tags_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/manage_tags_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Lets the person choose tags, those with the ids in [selected] chosen at
|
||||
/// first. [onChanged] gets the choice after every tap, so however the sheet
|
||||
/// closes, nothing is lost.
|
||||
///
|
||||
/// Typing narrows the tags to those whose name starts with it; a name no tag
|
||||
/// has offers to create one, which is then chosen. Managing the tags
|
||||
/// (renaming, recolouring, deleting) is a tap away in the header.
|
||||
Future<void> showTagPicker(
|
||||
BuildContext context, {
|
||||
required Set<String> selected,
|
||||
required ValueChanged<Set<String>> onChanged,
|
||||
}) => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => BlocProvider(
|
||||
create: (context) =>
|
||||
TagsBloc(PersonScope.of(context).tags)..add(const TagsEventStarted()),
|
||||
child: _TagPicker(selected: selected, onChanged: onChanged),
|
||||
),
|
||||
);
|
||||
|
||||
class const _TagPicker({
|
||||
required final Set<String> selected,
|
||||
required final ValueChanged<Set<String>> onChanged,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_TagPicker> createState() => _TagPickerState();
|
||||
}
|
||||
|
||||
class _TagPickerState() extends State<_TagPicker> {
|
||||
late var _selected = {...widget.selected};
|
||||
final _query = TextEditingController();
|
||||
|
||||
/// Names of tags created here that aren't listed yet; each is chosen as
|
||||
/// it appears.
|
||||
final _created = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_query.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle(String id, {required bool selected}) {
|
||||
setState(
|
||||
() => _selected = selected
|
||||
? {..._selected, id}
|
||||
: ({..._selected}..remove(id)),
|
||||
);
|
||||
widget.onChanged(_selected);
|
||||
}
|
||||
|
||||
/// Creates a tag named [name], with a colour the person picks, and
|
||||
/// chooses it.
|
||||
Future<void> _create(Tagging tagging, String name) async {
|
||||
final bloc = context.read<TagsBloc>();
|
||||
final content = await showTagSheet(
|
||||
context,
|
||||
tagging: tagging,
|
||||
initialName: canonicalTagName(name),
|
||||
);
|
||||
if (content == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
_created.add(canonicalTagName(content.name));
|
||||
bloc.add(TagsEventCreateRequested(content));
|
||||
_query.clear();
|
||||
}
|
||||
|
||||
/// Chooses every tag created here that is now listed.
|
||||
void _chooseCreated(Tagging tagging) {
|
||||
final appeared = [for (final name in _created) ?tagging.named(name)];
|
||||
if (appeared.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final tag in appeared) {
|
||||
_created.remove(tag.name);
|
||||
}
|
||||
setState(
|
||||
() => _selected = {..._selected, for (final tag in appeared) tag.id},
|
||||
);
|
||||
widget.onChanged(_selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocConsumer<TagsBloc, TagsState>(
|
||||
listener: (context, state) {
|
||||
if (state case TagsStateLoaded(:final tagging)) {
|
||||
_chooseCreated(tagging);
|
||||
}
|
||||
},
|
||||
builder: (context, state) => SheetScaffold(
|
||||
title: l10n.tagPickerTitle,
|
||||
action: TextButton.icon(
|
||||
onPressed: () => showManageTagsSheet(context),
|
||||
icon: const Icon(LucideIcons.settings2, size: 16),
|
||||
label: Text(l10n.tagPickerManage),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
...switch (state) {
|
||||
TagsStateLoading() => [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
],
|
||||
TagsStateFailed() => [Text(l10n.tagsReadFailed)],
|
||||
TagsStateLoaded(:final tagging) => _choices(context, tagging),
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _choices(BuildContext context, Tagging tagging) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final query = canonicalTagName(_query.text);
|
||||
final shown = [
|
||||
for (final tag in tagging.all)
|
||||
if (tag.name.startsWith(query)) tag,
|
||||
];
|
||||
final canCreate = query.isNotEmpty && tagging.named(query) == null;
|
||||
return [
|
||||
TextField(
|
||||
// No keyboard popping up as the sheet opens: the person taps the
|
||||
// field when ready.
|
||||
controller: _query,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.tagPickerSearchLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(LucideIcons.search, size: 18),
|
||||
),
|
||||
onSubmitted: (text) => canCreate ? _create(tagging, text) : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in shown)
|
||||
SelectableTagChip(
|
||||
tag: tag,
|
||||
selected: _selected.contains(tag.id),
|
||||
onSelected: (selected) => _toggle(tag.id, selected: selected),
|
||||
),
|
||||
if (canCreate)
|
||||
ActionChip(
|
||||
avatar: const Icon(LucideIcons.plus, size: 16),
|
||||
label: Text(l10n.tagPickerCreate(query)),
|
||||
onPressed: () => _create(tagging, query),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
116
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_sheet.dart
Normal file
116
halkyon_learn_flutter/lib/src/feature/tag/widget/tag_sheet.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.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:material_ui/material_ui.dart';
|
||||
|
||||
/// Creates a tag's content, starting from [initialName], or edits
|
||||
/// [initial]'s: its name and colour. Completes with the content once the
|
||||
/// person saves, or with null when they close the sheet.
|
||||
///
|
||||
/// Names compare as tags do, against [tagging]: a new tag can't take a name
|
||||
/// that exists, and an edited one taking another's name says that saving
|
||||
/// merges the two.
|
||||
Future<TagContent?> showTagSheet(
|
||||
BuildContext context, {
|
||||
required Tagging tagging,
|
||||
Tag? initial,
|
||||
String initialName = '',
|
||||
}) => showAdaptiveSheet<TagContent>(
|
||||
context,
|
||||
builder: (context) =>
|
||||
_TagSheet(tagging: tagging, initial: initial, initialName: initialName),
|
||||
);
|
||||
|
||||
class const _TagSheet({
|
||||
required final Tagging tagging,
|
||||
required final Tag? initial,
|
||||
required final String initialName,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_TagSheet> createState() => _TagSheetState();
|
||||
}
|
||||
|
||||
class _TagSheetState() extends State<_TagSheet> {
|
||||
late final _name = TextEditingController(
|
||||
text: widget.initial?.name ?? widget.initialName,
|
||||
);
|
||||
late var _color = switch (widget.initial) {
|
||||
final initial? => tagColor(initial),
|
||||
null => PaletteColor.byName(defaultTagColorName)!,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// The button and the hint below the name follow it.
|
||||
_name.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The tag that has the typed name already, other than the one edited.
|
||||
Tag? get _namesake => switch (widget.tagging.named(_name.text)) {
|
||||
final namesake? when namesake.id != widget.initial?.id => namesake,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final creating = widget.initial == null;
|
||||
final namesake = _namesake;
|
||||
final blank = canonicalTagName(_name.text).isEmpty;
|
||||
return SheetScaffold(
|
||||
title: creating ? l10n.tagSheetCreateTitle : l10n.tagSheetEditTitle,
|
||||
footer: FilledButton(
|
||||
onPressed: blank || (creating && namesake != null)
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
).pop((name: _name.text, colorName: _color.name)),
|
||||
child: Text(creating ? l10n.tagSheetCreate : l10n.tagSheetSave),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
// No keyboard popping up as the sheet opens: the person taps the
|
||||
// field when ready. Names are lowercase, so the keyboard offers no
|
||||
// capitals either.
|
||||
controller: _name,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.tagSheetNameLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: namesake != null && !creating
|
||||
? l10n.tagSheetNameMerges(namesake.name)
|
||||
: l10n.tagSheetNameHelper,
|
||||
helperMaxLines: 2,
|
||||
errorText: namesake != null && creating
|
||||
? l10n.tagSheetNameExists(namesake.name)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(l10n.tagSheetColor, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
PaletteColorPicker(
|
||||
selected: _color,
|
||||
onChanged: (color) => setState(() => _color = color),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ 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/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 '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
|
||||
RichContent text(String text) => RichContent({
|
||||
'document': {'type': 'page', 'note': text},
|
||||
|
|
@ -19,17 +21,22 @@ CardContent card(String front, String back) =>
|
|||
void main() {
|
||||
late FakeCardGateway gateway;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeCardGateway();
|
||||
cards = CardRepository(gateway);
|
||||
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
|
||||
});
|
||||
tearDown(() async {
|
||||
await tags.dispose();
|
||||
await cards.dispose();
|
||||
});
|
||||
tearDown(() => cards.dispose());
|
||||
|
||||
group('CardListBloc', () {
|
||||
test('lists the cards and follows every change', () async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final bloc = CardListBloc(cards, 'japanese')
|
||||
final bloc = CardListBloc(cards, tags, 'japanese')
|
||||
..add(const CardListEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
|
|
@ -59,7 +66,7 @@ void main() {
|
|||
test('moves a card', () async {
|
||||
await cards.create('japanese', card('a', ''));
|
||||
await cards.create('japanese', card('b', ''));
|
||||
final bloc = CardListBloc(cards, 'japanese')
|
||||
final bloc = CardListBloc(cards, tags, 'japanese')
|
||||
..add(const CardListEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
final loaded = await bloc.stream.firstWhere(
|
||||
|
|
@ -81,10 +88,74 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('CardListBloc with tags', () {
|
||||
late CardListBloc bloc;
|
||||
late List<String> ids;
|
||||
|
||||
setUp(() async {
|
||||
for (final front in ['猫', '犬', '食べる']) {
|
||||
await cards.create('japanese', card(front, ''));
|
||||
}
|
||||
ids = [for (final card in await cards.cardsOf('japanese')) card.id];
|
||||
final noun = await tags.create('noun');
|
||||
final verb = await tags.create('verb');
|
||||
await tags.create('unused');
|
||||
await tags.setTagsOf(ids[0], {noun});
|
||||
await tags.setTagsOf(ids[1], {noun});
|
||||
await tags.setTagsOf(ids[2], {verb});
|
||||
bloc = CardListBloc(cards, tags, 'japanese')
|
||||
..add(const CardListEventStarted());
|
||||
});
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
/// The next state that has the tags read.
|
||||
Future<CardListStateLoaded> tagged() async => await bloc.stream.firstWhere(
|
||||
(state) => state is CardListStateLoaded && state.tagging.all.isNotEmpty,
|
||||
) as CardListStateLoaded;
|
||||
|
||||
test("shows each card's tags, and offers to filter by those the cards "
|
||||
'have', () async {
|
||||
final state = await tagged();
|
||||
|
||||
expect(state.tagging.of(ids[2]).map((tag) => tag.name), ['verb']);
|
||||
expect(state.tagsInUse.map((tag) => tag.name), ['noun', 'verb']);
|
||||
expect(state.shown, hasLength(3));
|
||||
});
|
||||
|
||||
test('a tag narrows the list to its cards, and clearing it shows them '
|
||||
'all again', () async {
|
||||
final noun = (await tagged()).tagging.named('noun')!;
|
||||
|
||||
bloc.add(CardListEventTagFilterChanged(noun.id));
|
||||
final narrowed = await bloc.stream.first as CardListStateLoaded;
|
||||
bloc.add(const CardListEventTagFilterChanged(null));
|
||||
final all = await bloc.stream.first as CardListStateLoaded;
|
||||
|
||||
expect(narrowed.filter, noun);
|
||||
expect(narrowed.shown.map((card) => card.id), [ids[0], ids[1]]);
|
||||
expect(all.shown, hasLength(3));
|
||||
});
|
||||
|
||||
test('a deleted tag no longer narrows the list', () async {
|
||||
final noun = (await tagged()).tagging.named('noun')!;
|
||||
bloc.add(CardListEventTagFilterChanged(noun.id));
|
||||
await bloc.stream.first;
|
||||
|
||||
await tags.delete(noun.id);
|
||||
final state = await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is CardListStateLoaded && state.tagging.named('noun') == null,
|
||||
) as CardListStateLoaded;
|
||||
|
||||
expect(state.filter, isNull);
|
||||
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, 'japanese', null);
|
||||
final bloc = CardEditBloc(cards, tags, 'japanese', null);
|
||||
addTearDown(bloc.close);
|
||||
expect(bloc.state.canSave, isFalse);
|
||||
|
||||
|
|
@ -109,6 +180,7 @@ void main() {
|
|||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final bloc = CardEditBloc(
|
||||
cards,
|
||||
tags,
|
||||
'japanese',
|
||||
(await cards.cardsOf('japanese')).single,
|
||||
);
|
||||
|
|
@ -138,7 +210,7 @@ void main() {
|
|||
payload: encodeCardPayload(card('猫', 'the cat')),
|
||||
);
|
||||
conflicted = (await cards.cardsOf('japanese')).single;
|
||||
bloc = CardEditBloc(cards, 'japanese', conflicted);
|
||||
bloc = CardEditBloc(cards, tags, 'japanese', conflicted);
|
||||
});
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
|
|
@ -171,10 +243,71 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
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(CardSide.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());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/bloc/tags_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeTagGateway gateway;
|
||||
late TagRepository tags;
|
||||
late TagsBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeTagGateway();
|
||||
tags = TagRepository(gateway);
|
||||
bloc = TagsBloc(tags)..add(const TagsEventStarted());
|
||||
});
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
await tags.dispose();
|
||||
});
|
||||
|
||||
/// The next state whose tags are [expected], as (name, colour).
|
||||
Future<TagsStateLoaded> showing(List<(String, String)> expected) async {
|
||||
bool matches(TagsState state) =>
|
||||
state is TagsStateLoaded &&
|
||||
state.tagging.all.map((tag) => (tag.name, tag.colorName)).join('|') ==
|
||||
expected.join('|');
|
||||
if (matches(bloc.state)) {
|
||||
return bloc.state as TagsStateLoaded;
|
||||
}
|
||||
return await bloc.stream.firstWhere(matches) as TagsStateLoaded;
|
||||
}
|
||||
|
||||
test('creates, renames and recolours tags', () async {
|
||||
await showing([]);
|
||||
|
||||
bloc.add(
|
||||
const TagsEventCreateRequested((name: 'Verb', colorName: 'fern')),
|
||||
);
|
||||
final created = await showing([('verb', 'fern')]);
|
||||
bloc.add(
|
||||
TagsEventUpdateRequested(
|
||||
created.tagging.all.single.id,
|
||||
(name: 'verbs', colorName: 'plum'),
|
||||
),
|
||||
);
|
||||
|
||||
await showing([('verbs', 'plum')]);
|
||||
});
|
||||
|
||||
test('deletes a tag, and an undo brings it back on its cards', () async {
|
||||
final verb = await tags.create('verb', colorName: 'fern');
|
||||
await tags.setTagsOf('card-1', {verb});
|
||||
await showing([('verb', 'fern')]);
|
||||
|
||||
bloc.add(TagsEventDeleteRequested(verb));
|
||||
await showing([]);
|
||||
bloc.add(const TagsEventUndoDeleteRequested());
|
||||
|
||||
final restored = await showing([('verb', 'fern')]);
|
||||
expect(restored.tagging.of('card-1').map((tag) => tag.name), ['verb']);
|
||||
});
|
||||
|
||||
test('an undo right after deleting waits for the deletion', () async {
|
||||
final verb = await tags.create('verb');
|
||||
await showing([('verb', defaultTagColorName)]);
|
||||
|
||||
bloc
|
||||
..add(TagsEventDeleteRequested(verb))
|
||||
..add(const TagsEventUndoDeleteRequested());
|
||||
|
||||
await showing([]);
|
||||
await showing([('verb', defaultTagColorName)]);
|
||||
});
|
||||
|
||||
test('a failed read shows as failed', () async {
|
||||
await bloc.close();
|
||||
gateway.failReads = Exception('disk');
|
||||
bloc = TagsBloc(tags)..add(const TagsEventStarted());
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(const TagsStateFailed()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
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/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
/// Rich content with one paragraph of [text].
|
||||
RichContent paragraph(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void main() {
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeTagGateway tagGateway;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
|
||||
setUp(() {
|
||||
cardGateway = FakeCardGateway();
|
||||
tagGateway = FakeTagGateway();
|
||||
cards = CardRepository(cardGateway);
|
||||
tags = TagRepository(tagGateway);
|
||||
});
|
||||
tearDown(() async {
|
||||
await tags.dispose();
|
||||
await cards.dispose();
|
||||
});
|
||||
|
||||
/// A card in the studyset `japanese` with [front], and the tags [named].
|
||||
Future<String> tagged(String front, [List<String> named = const []]) async {
|
||||
final id = await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph(front), back: paragraph('')),
|
||||
);
|
||||
await tags.setTagsOf(id, {
|
||||
for (final name in named) await tags.create(name),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Opens the studyset `japanese`, with the cards and tags the test stored
|
||||
/// through [cards] and [tags]: the app reads them from the same gateways.
|
||||
Future<void> showList(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
),
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
tagGateway: tagGateway,
|
||||
),
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
);
|
||||
// Flushes the description view's timers.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
/// Opens the card with [front] in its editor.
|
||||
Future<void> openCard(WidgetTester tester, String front) async {
|
||||
await tester.tap(find.text(front));
|
||||
await tester.pump();
|
||||
// Flushes the editor's timers.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the tags sheet from the card list's menu.
|
||||
Future<void> manageTags(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Manage tags'));
|
||||
// A menu item acts once the menu has closed, a frame later.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder chip<T extends Widget>(String label) => find.widgetWithText(T, label);
|
||||
|
||||
bool selected(WidgetTester tester, String label) =>
|
||||
tester.widget<FilterChip>(chip<FilterChip>(label)).selected;
|
||||
|
||||
testWidgets('a tag created in the picker is chosen, and saving gives it '
|
||||
'to the card, which the list then shows', (tester) async {
|
||||
await tagged('猫');
|
||||
await showList(tester);
|
||||
await openCard(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('Add tags'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'Noun');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create "noun"'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Create tag'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected(tester, 'noun'), isTrue);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(chip<TagChip>('noun'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(TagLabel, 'noun'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the picker narrows the tags to those starting with what is '
|
||||
'typed, and taking a tag away can be saved', (tester) async {
|
||||
await tagged('猫', ['verb']);
|
||||
await tags.create('noun');
|
||||
await tags.create('adjective');
|
||||
await showList(tester);
|
||||
await openCard(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('Edit tags'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(selected(tester, 'verb'), isTrue);
|
||||
expect(selected(tester, 'noun'), isFalse);
|
||||
await tester.enterText(find.byType(TextField).last, 'v');
|
||||
await tester.pump();
|
||||
expect(chip<FilterChip>('noun'), findsNothing);
|
||||
expect(chip<FilterChip>('adjective'), findsNothing);
|
||||
await tester.tap(chip<FilterChip>('verb'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add tags'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TagLabel, 'verb'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('managing tags: each with its count; renaming to an existing '
|
||||
'name says it merges; deleting asks and can be undone', (tester) async {
|
||||
await tagged('猫', ['noun']);
|
||||
await tags.create('verb');
|
||||
await showList(tester);
|
||||
await manageTags(tester);
|
||||
|
||||
expect(find.text('noun (1)'), findsOneWidget);
|
||||
expect(find.text('verb (0)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Edit tag').first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'Verb');
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text(
|
||||
"There is a tag 'verb' already: saving merges this one into it.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).last, 'nouns');
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('nouns (1)'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Delete tag').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Delete the tag 'nouns'?"), findsOneWidget);
|
||||
expect(
|
||||
find.text('It is removed from 1 flashcard, on all your devices.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('nouns (1)'), findsNothing);
|
||||
expect(find.text("Deleted the tag 'nouns'"), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('nouns (1)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a new tag cannot take a name that exists', (tester) async {
|
||||
await tags.create('noun');
|
||||
await tagged('猫');
|
||||
await showList(tester);
|
||||
await manageTags(tester);
|
||||
|
||||
await tester.tap(find.text('Add tag'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, ' Noun');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text("The tag 'noun' exists already."), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Create'))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the cards narrow to those with a tag, among the tags they '
|
||||
'have, and widen again', (tester) async {
|
||||
await tagged('猫', ['noun']);
|
||||
await tagged('食べる', ['verb']);
|
||||
await tags.create('unused');
|
||||
await showList(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Filter'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chip<ChoiceChip>('unused'), findsNothing);
|
||||
await tester.tap(chip<ChoiceChip>('verb'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('食べる'), findsOneWidget);
|
||||
expect(find.text('猫'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Filter'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Clear all'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('猫'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue