fix(app): the dictionary adds each word once, says when it can't, and Look up tags hold
- A word tapped twice, or added while a level is being added, makes one card: adds run one at a time, and the word counts as in the studyset at once. The adder reads the studyset's cards once rather than for every word. - Adding that fails says so, and a level shows "Making the cards…" once its words have come. - Finding a studyset's dictionary tells finding, none and unreachable apart: the dictionary screen says there is none or offers Retry, and an instance unreachable at start is asked again once a sync goes through (Look up and From the dictionary appear then). - Look up's tags wait on the card by name and are made when it is saved: leaving the card at once no longer saves it untagged, a word replaced leaves no tag behind, and a new word replaces any level or word class on the card, pinned ones too. Look up marks words already made into cards of the studyset. - The tag picker's Create keeps the case typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
50ca46f6b1
commit
49e10895f4
18 changed files with 516 additions and 107 deletions
|
|
@ -494,6 +494,9 @@
|
|||
"dictionaryNothingFound": "Nichts gefunden für „{query}“.",
|
||||
"dictionaryUnreachable": "Das Wörterbuch ist gerade nicht erreichbar.",
|
||||
"dictionaryRetry": "Erneut versuchen",
|
||||
"dictionaryAddFailed": "Die Wörter konnten nicht hinzugefügt werden. Versuche es erneut.",
|
||||
"dictionaryMakingCards": "Die Karteikarten werden erstellt…",
|
||||
"dictionaryNone": "Für die Sprache dieses Lernsets gibt es auf dieser Instanz kein Wörterbuch.",
|
||||
"dictionaryAdd": "Hinzufügen",
|
||||
"dictionaryInStudyset": "In diesem Lernset",
|
||||
"dictionaryAdded": "„{word}“ hinzugefügt.",
|
||||
|
|
|
|||
|
|
@ -543,6 +543,9 @@
|
|||
"@dictionaryNothingFound": {"placeholders": {"query": {"type": "String"}}},
|
||||
"dictionaryUnreachable": "The dictionary can't be reached right now.",
|
||||
"dictionaryRetry": "Retry",
|
||||
"dictionaryAddFailed": "Couldn't add the words. Try again.",
|
||||
"dictionaryMakingCards": "Making the cards…",
|
||||
"dictionaryNone": "This studyset's language has no dictionary on this instance.",
|
||||
"dictionaryAdd": "Add",
|
||||
"dictionaryInStudyset": "In this studyset",
|
||||
"dictionaryAdded": "Added “{word}”.",
|
||||
|
|
|
|||
|
|
@ -2986,6 +2986,24 @@ abstract class AppLocalizations {
|
|||
/// **'Retry'**
|
||||
String get dictionaryRetry;
|
||||
|
||||
/// No description provided for @dictionaryAddFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t add the words. Try again.'**
|
||||
String get dictionaryAddFailed;
|
||||
|
||||
/// No description provided for @dictionaryMakingCards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Making the cards…'**
|
||||
String get dictionaryMakingCards;
|
||||
|
||||
/// No description provided for @dictionaryNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This studyset\'s language has no dictionary on this instance.'**
|
||||
String get dictionaryNone;
|
||||
|
||||
/// No description provided for @dictionaryAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1768,6 +1768,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get dictionaryRetry => 'Erneut versuchen';
|
||||
|
||||
@override
|
||||
String get dictionaryAddFailed =>
|
||||
'Die Wörter konnten nicht hinzugefügt werden. Versuche es erneut.';
|
||||
|
||||
@override
|
||||
String get dictionaryMakingCards => 'Die Karteikarten werden erstellt…';
|
||||
|
||||
@override
|
||||
String get dictionaryNone =>
|
||||
'Für die Sprache dieses Lernsets gibt es auf dieser Instanz kein Wörterbuch.';
|
||||
|
||||
@override
|
||||
String get dictionaryAdd => 'Hinzufügen';
|
||||
|
||||
|
|
|
|||
|
|
@ -1758,6 +1758,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get dictionaryRetry => 'Retry';
|
||||
|
||||
@override
|
||||
String get dictionaryAddFailed => 'Couldn\'t add the words. Try again.';
|
||||
|
||||
@override
|
||||
String get dictionaryMakingCards => 'Making the cards…';
|
||||
|
||||
@override
|
||||
String get dictionaryNone =>
|
||||
'This studyset\'s language has no dictionary on this instance.';
|
||||
|
||||
@override
|
||||
String get dictionaryAdd => 'Add';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
|
|
@ -160,18 +161,22 @@ class CardComposerBloc(
|
|||
}
|
||||
|
||||
/// The state with the person's [tagging]: cards the person hasn't given
|
||||
/// other tags take their stored ones.
|
||||
/// other tags take their stored ones, and tags a card waits for that now
|
||||
/// exist are its own.
|
||||
CardComposerState _withTagging(Tagging tagging) {
|
||||
CardDraft updated(CardDraft draft) {
|
||||
final id = draft.cardId;
|
||||
if (id == null) {
|
||||
return draft;
|
||||
return _withExisting(draft, tagging);
|
||||
}
|
||||
final stored = {for (final tag in tagging.of(id)) tag.id};
|
||||
final unchanged = _sameSet(draft.tagIds, draft.savedTagIds);
|
||||
return draft.copyWith(
|
||||
savedTagIds: stored,
|
||||
tagIds: unchanged ? stored : null,
|
||||
return _withExisting(
|
||||
draft.copyWith(
|
||||
savedTagIds: stored,
|
||||
tagIds: unchanged ? stored : null,
|
||||
),
|
||||
tagging,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +196,7 @@ class CardComposerBloc(
|
|||
final previous = state.byId(event.after);
|
||||
var content = CardContent.empty();
|
||||
var tagIds = <String>{};
|
||||
var newTagNames = const <String>[];
|
||||
if (previous != null) {
|
||||
final from = previous.content;
|
||||
content = content.withField(CardField.type, from.type);
|
||||
|
|
@ -211,6 +217,7 @@ class CardComposerBloc(
|
|||
}
|
||||
if (state.pins.contains(CardPin.tags)) {
|
||||
tagIds = {...previous.tagIds};
|
||||
newTagNames = previous.newTagNames;
|
||||
}
|
||||
}
|
||||
emit(
|
||||
|
|
@ -223,6 +230,7 @@ class CardComposerBloc(
|
|||
saved: CardContent.empty(),
|
||||
tagIds: tagIds,
|
||||
savedTagIds: const {},
|
||||
newTagNames: newTagNames,
|
||||
),
|
||||
),
|
||||
...state.drafts,
|
||||
|
|
@ -249,6 +257,7 @@ class CardComposerBloc(
|
|||
saved: CardContent.empty(),
|
||||
tagIds: source.tagIds,
|
||||
savedTagIds: const {},
|
||||
newTagNames: source.newTagNames,
|
||||
),
|
||||
),
|
||||
...state.drafts,
|
||||
|
|
@ -257,28 +266,27 @@ class CardComposerBloc(
|
|||
);
|
||||
}
|
||||
|
||||
/// The card shows the word at once; its tags follow once found or made.
|
||||
/// Those an earlier word brought go, unless the person had them before.
|
||||
Future<void> _onLookedUp(
|
||||
/// The card takes the word, and its tags in place of those another word
|
||||
/// brought (a level, a word class), the person's own staying. Tags that
|
||||
/// don't exist yet wait on the card by name until it is saved, so a word
|
||||
/// looked up and then replaced, or a card never saved, leaves none behind.
|
||||
void _onLookedUp(
|
||||
CardComposerEventLookedUp event,
|
||||
Emitter<CardComposerState> emit,
|
||||
) async {
|
||||
_update(emit, event.id, (draft) => draft.copyWith(content: event.content));
|
||||
final Set<String> tagIds;
|
||||
try {
|
||||
tagIds = {for (final name in event.tagNames) await _tags.create(name)};
|
||||
} on Object catch (error) {
|
||||
log('Tagging a word failed', name: 'Composer', error: error);
|
||||
return;
|
||||
}
|
||||
_update(emit, event.id, (draft) {
|
||||
final own = draft.tagIds.difference(draft.lookedUpTagIds);
|
||||
return draft.copyWith(
|
||||
tagIds: {...own, ...tagIds},
|
||||
lookedUpTagIds: tagIds.difference(own),
|
||||
);
|
||||
});
|
||||
}
|
||||
) => _update(emit, event.id, (draft) {
|
||||
final tagging = state.tagging;
|
||||
return _withExisting(
|
||||
draft.copyWith(
|
||||
content: event.content,
|
||||
tagIds: {
|
||||
for (final id in draft.tagIds)
|
||||
if (!isWordTagName(tagging.byId(id)?.name ?? '')) id,
|
||||
},
|
||||
newTagNames: event.tagNames,
|
||||
),
|
||||
tagging,
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> _onLeft(
|
||||
CardComposerEventLeft event,
|
||||
|
|
@ -291,9 +299,14 @@ class CardComposerBloc(
|
|||
return;
|
||||
}
|
||||
final content = draft.content.normalized;
|
||||
final tagIds = draft.tagIds;
|
||||
_update(emit, draft.id, (d) => d.copyWith(status: CardDraftStatus.saving));
|
||||
// The tags it waits for are made as it is saved.
|
||||
final made = <String, String>{};
|
||||
try {
|
||||
for (final name in draft.newTagNames) {
|
||||
made[name] = await _tags.create(name);
|
||||
}
|
||||
final tagIds = {...draft.tagIds, ...made.values};
|
||||
final String cardId;
|
||||
var base = draft.base;
|
||||
if (draft.cardId case final id?) {
|
||||
|
|
@ -310,23 +323,35 @@ class CardComposerBloc(
|
|||
if (!_sameSet(tagIds, draft.savedTagIds)) {
|
||||
await _tags.setTagsOf(cardId, tagIds);
|
||||
}
|
||||
_update(
|
||||
emit,
|
||||
draft.id,
|
||||
(d) => d.copyWith(
|
||||
_update(emit, draft.id, (d) {
|
||||
// Tags made for names it still waits for are its own now.
|
||||
final waiting = [
|
||||
for (final name in d.newTagNames)
|
||||
if (!made.containsKey(name)) name,
|
||||
];
|
||||
final now = {
|
||||
...d.tagIds,
|
||||
for (final name in d.newTagNames) ?made[name],
|
||||
};
|
||||
return d.copyWith(
|
||||
cardId: cardId,
|
||||
saved: content,
|
||||
// What a change made while saving builds on.
|
||||
base: base,
|
||||
tagIds: now,
|
||||
savedTagIds: tagIds,
|
||||
newTagNames: waiting,
|
||||
resolved: const {},
|
||||
savedHere: true,
|
||||
// Changed again while saving, it waits for the next leaving.
|
||||
status: d.content.normalized == content && _sameSet(d.tagIds, tagIds)
|
||||
status:
|
||||
d.content.normalized == content &&
|
||||
_sameSet(now, tagIds) &&
|
||||
waiting.isEmpty
|
||||
? CardDraftStatus.saved
|
||||
: CardDraftStatus.dirty,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
} on Object catch (error) {
|
||||
log('Saving a card failed', name: 'Composer', error: error);
|
||||
_update(
|
||||
|
|
@ -396,6 +421,23 @@ CardDraft _withStatus(CardDraft draft) => draft.copyWith(
|
|||
},
|
||||
);
|
||||
|
||||
/// [draft] with the tags it waits for that exist in [tagging] as its own.
|
||||
CardDraft _withExisting(CardDraft draft, Tagging tagging) {
|
||||
if (draft.newTagNames.isEmpty) {
|
||||
return draft;
|
||||
}
|
||||
final found = {
|
||||
for (final name in draft.newTagNames) ?tagging.named(name)?.id,
|
||||
};
|
||||
return draft.copyWith(
|
||||
tagIds: {...draft.tagIds, ...found},
|
||||
newTagNames: [
|
||||
for (final name in draft.newTagNames)
|
||||
if (tagging.named(name) == null) name,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _sameSet(Set<String> a, Set<String> b) =>
|
||||
a.length == b.length && a.containsAll(b);
|
||||
|
||||
|
|
@ -467,9 +509,9 @@ final class const CardDraft({
|
|||
/// Whether it was saved in this sitting.
|
||||
final bool savedHere = false,
|
||||
|
||||
/// The ids of the tags the word looked up last brought, which the person
|
||||
/// hadn't given it: a word looked up after it takes them away.
|
||||
final Set<String> lookedUpTagIds = const {},
|
||||
/// The names of tags it is to have that don't exist yet (a word's level
|
||||
/// and word class, looked up): they are made when it is saved.
|
||||
final List<String> newTagNames = const [],
|
||||
}) with Equatable {
|
||||
/// The fields in conflict still waiting for a version.
|
||||
Set<CardField> get unresolved => conflicts.keys.toSet().difference(resolved);
|
||||
|
|
@ -477,10 +519,11 @@ final class const CardDraft({
|
|||
/// Whether it differs from what is stored, or settles a conflict; a new
|
||||
/// card, whether it holds anything, whatever its type.
|
||||
bool get isDirty => switch (cardId) {
|
||||
null => !content.isBlank || tagIds.isNotEmpty,
|
||||
null => !content.isBlank || tagIds.isNotEmpty || newTagNames.isNotEmpty,
|
||||
_ =>
|
||||
content.normalized != saved.normalized ||
|
||||
!_sameSet(tagIds, savedTagIds) ||
|
||||
newTagNames.isNotEmpty ||
|
||||
(conflicts.isNotEmpty && unresolved.isEmpty),
|
||||
};
|
||||
|
||||
|
|
@ -505,7 +548,7 @@ final class const CardDraft({
|
|||
Map<CardField, List<Object?>>? conflicts,
|
||||
Set<CardField>? resolved,
|
||||
bool? savedHere,
|
||||
Set<String>? lookedUpTagIds,
|
||||
List<String>? newTagNames,
|
||||
}) => CardDraft(
|
||||
id: id,
|
||||
content: content ?? this.content,
|
||||
|
|
@ -518,7 +561,7 @@ final class const CardDraft({
|
|||
conflicts: conflicts ?? this.conflicts,
|
||||
resolved: resolved ?? this.resolved,
|
||||
savedHere: savedHere ?? this.savedHere,
|
||||
lookedUpTagIds: lookedUpTagIds ?? this.lookedUpTagIds,
|
||||
newTagNames: newTagNames ?? this.newTagNames,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -534,7 +577,7 @@ final class const CardDraft({
|
|||
conflicts,
|
||||
resolved,
|
||||
savedHere,
|
||||
lookedUpTagIds,
|
||||
newTagNames,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -667,7 +710,7 @@ final class const CardComposerEventTagsChanged(
|
|||
}
|
||||
|
||||
/// The person looked a word up for the card [id]: it is now [content], and
|
||||
/// gets the tags named [tagNames] in place of those the word before brought.
|
||||
/// gets the tags named [tagNames] in place of those a word brought.
|
||||
final class const CardComposerEventLookedUp(
|
||||
/// The draft's id.
|
||||
final String id,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repositor
|
|||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.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_picker_sheet.dart';
|
||||
|
|
@ -145,10 +146,16 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
/// is from, and its tags (level and word class) as the dictionary screen
|
||||
/// gives them. Hints, an explanation and the person's tags stay.
|
||||
Future<void> _lookUp(Dictionary dictionary) async {
|
||||
// The words other cards of the studyset were made from show as such.
|
||||
final made = {
|
||||
for (final draft in context.read<CardComposerBloc>().state.items)
|
||||
if (draft.id != widget.draft.id) ?draft.content.entry,
|
||||
};
|
||||
final word = await lookUpWord(
|
||||
context,
|
||||
dictionary,
|
||||
query: _sent.front.plainText.trim(),
|
||||
inStudyset: made,
|
||||
);
|
||||
if (word == null || !mounted) {
|
||||
return;
|
||||
|
|
@ -240,6 +247,9 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
final chosenTags = [
|
||||
for (final tag in widget.tagging.all)
|
||||
if (draft.tagIds.contains(tag.id)) tag,
|
||||
// Made once the card is saved; shown as they will be.
|
||||
for (final name in draft.newTagNames)
|
||||
Tag(id: '', name: name, colorName: defaultTagColorName),
|
||||
];
|
||||
final frontLabel = switch (kind) {
|
||||
CardKind.flashcard => l10n.composerLabelFront,
|
||||
|
|
@ -273,7 +283,8 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final tag in chosenTags) TagChip(tag: tag),
|
||||
for (final tag in chosenTags)
|
||||
TagChip(key: ValueKey(tag.name), tag: tag),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ class DictionaryBloc(
|
|||
DictionaryEventStarted event,
|
||||
Emitter<DictionaryState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(inStudyset: await _adder.entriesIn(_studysetId)));
|
||||
try {
|
||||
emit(state.copyWith(inStudyset: await _adder.entriesIn(_studysetId)));
|
||||
final labels = levelLabelsOf(_dictionary);
|
||||
final counts = {
|
||||
for (final label in labels)
|
||||
|
|
@ -96,17 +96,29 @@ class DictionaryBloc(
|
|||
}
|
||||
}
|
||||
|
||||
/// The word counts as in the studyset at once, so a second tap adds
|
||||
/// nothing; should adding it fail, it doesn't.
|
||||
Future<void> _onWordAdded(
|
||||
DictionaryEventWordAdded event,
|
||||
Emitter<DictionaryState> emit,
|
||||
) async {
|
||||
final added = await _adder.add(_studysetId, [event.word]);
|
||||
emit(
|
||||
state.copyWith(
|
||||
inStudyset: {...state.inStudyset, event.word.ref},
|
||||
outcome: (added: added, word: event.word.headword),
|
||||
),
|
||||
);
|
||||
final ref = event.word.ref;
|
||||
if (state.inStudyset.contains(ref)) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(inStudyset: {...state.inStudyset, ref}));
|
||||
try {
|
||||
final added = await _adder.add(_studysetId, [event.word]);
|
||||
emit(state.copyWith(outcome: (added: added, word: event.word.headword)));
|
||||
} on Object catch (error) {
|
||||
log('Adding a word failed', name: 'Dictionary', error: error);
|
||||
emit(
|
||||
state.copyWith(
|
||||
inStudyset: {...state.inStudyset}..remove(ref),
|
||||
failed: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLevelAdded(
|
||||
|
|
@ -126,6 +138,8 @@ class DictionaryBloc(
|
|||
onProgress: (done) =>
|
||||
emit(state.copyWith(adding: (done: done, count: count))),
|
||||
);
|
||||
// Every word has come; the cards are being made.
|
||||
emit(state.copyWith(adding: (done: count, count: count)));
|
||||
final added = await _adder.add(_studysetId, words);
|
||||
emit(
|
||||
state.copyWith(
|
||||
|
|
@ -136,7 +150,17 @@ class DictionaryBloc(
|
|||
);
|
||||
} on Object catch (error) {
|
||||
log('Adding a level failed', name: 'Dictionary', error: error);
|
||||
emit(state.copyWith(adding: null, unreachable: true));
|
||||
emit(state.copyWith(adding: null, failed: true));
|
||||
try {
|
||||
// What did get added before it failed.
|
||||
emit(state.copyWith(inStudyset: await _adder.entriesIn(_studysetId)));
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'Reading the entries held failed',
|
||||
name: 'Dictionary',
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,9 +181,14 @@ final class const DictionaryState({
|
|||
final String? level,
|
||||
final ({int done, int count})? adding,
|
||||
final DictionaryOutcome? outcome,
|
||||
|
||||
/// Adding words failed just now; cleared by the next change, as
|
||||
/// [outcome] is.
|
||||
final bool failed = false,
|
||||
}) with Equatable {
|
||||
/// This state with some fields replaced; [adding] and [level] can be
|
||||
/// cleared by passing null on purpose, [outcome] is replaced each time.
|
||||
/// cleared by passing null on purpose, [outcome] and [failed] are
|
||||
/// replaced each time.
|
||||
DictionaryState copyWith({
|
||||
String? query,
|
||||
List<DictionaryWord>? results,
|
||||
|
|
@ -170,6 +199,7 @@ final class const DictionaryState({
|
|||
String? level,
|
||||
Object? adding = _keep,
|
||||
DictionaryOutcome? outcome,
|
||||
bool failed = false,
|
||||
}) => DictionaryState(
|
||||
query: query ?? this.query,
|
||||
results: results ?? this.results,
|
||||
|
|
@ -182,6 +212,7 @@ final class const DictionaryState({
|
|||
? this.adding
|
||||
: adding as ({int done, int count})?,
|
||||
outcome: outcome,
|
||||
failed: failed,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -195,6 +226,7 @@ final class const DictionaryState({
|
|||
level,
|
||||
adding,
|
||||
outcome,
|
||||
failed,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.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/dictionary/model/dictionary.dart';
|
||||
|
|
@ -11,28 +12,51 @@ typedef DictionaryAdded = ({int added, int skipped});
|
|||
/// word a card of the studyset was made from already is left out. Each card
|
||||
/// is tagged with the word's [DictionaryWord.tagNames]: its JLPT level and
|
||||
/// its word class.
|
||||
///
|
||||
/// Adds run one at a time, so two at once (a word tapped twice, a word
|
||||
/// while a level is added) never make the same card twice. Which entries a
|
||||
/// studyset holds is read once, then kept as words are added: reading every
|
||||
/// card for each word would take seconds in a studyset of thousands.
|
||||
class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
||||
final _queue = SerialQueue();
|
||||
|
||||
/// The entries each studyset holds, as read once and added to since.
|
||||
final _held = <String, Set<DictionaryRef>>{};
|
||||
|
||||
/// The dictionary entries the cards of [studysetId] were made from.
|
||||
Future<Set<DictionaryRef>> entriesIn(String studysetId) async => {
|
||||
for (final card in await _cards.cardsOf(studysetId)) ?card.content.entry,
|
||||
};
|
||||
Future<Set<DictionaryRef>> entriesIn(String studysetId) =>
|
||||
_queue.run(() async => {...await _entriesIn(studysetId)});
|
||||
|
||||
Future<Set<DictionaryRef>> _entriesIn(String studysetId) async =>
|
||||
_held[studysetId] ??= {
|
||||
for (final card in await _cards.cardsOf(studysetId))
|
||||
?card.content.entry,
|
||||
};
|
||||
|
||||
/// Adds [words] to [studysetId], at its end, in their order.
|
||||
Future<DictionaryAdded> add(
|
||||
Future<DictionaryAdded> add(String studysetId, List<DictionaryWord> words) =>
|
||||
_queue.run(() => _add(studysetId, words));
|
||||
|
||||
Future<DictionaryAdded> _add(
|
||||
String studysetId,
|
||||
List<DictionaryWord> words,
|
||||
) async {
|
||||
final there = await entriesIn(studysetId);
|
||||
final there = await _entriesIn(studysetId);
|
||||
final fresh = [
|
||||
for (final word in words)
|
||||
if (there.add(word.ref)) word,
|
||||
if (!there.contains(word.ref)) word,
|
||||
];
|
||||
// Each word once, also when it comes twice.
|
||||
final once = <DictionaryRef>{};
|
||||
fresh.retainWhere((word) => once.add(word.ref));
|
||||
if (fresh.isEmpty) {
|
||||
return (added: 0, skipped: words.length);
|
||||
}
|
||||
final ids = await _cards.createAll(studysetId, [
|
||||
for (final word in fresh) word.card,
|
||||
], origin: fresh.first.ref.source);
|
||||
// Held from here on, even should tagging them fail.
|
||||
there.addAll(once);
|
||||
|
||||
final tagIdOf = <String, String>{};
|
||||
final tagsByCard = <String, Set<String>>{};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:convert';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
/// Who made what a dictionary is drawn from, under which licence.
|
||||
typedef DictionaryCredit = ({
|
||||
|
|
@ -115,6 +116,30 @@ final class const DictionaryWord({
|
|||
List<Object?> get props => [ref, headword, reading, labels, senses];
|
||||
}
|
||||
|
||||
/// Whether [name] is a tag a dictionary word brings, whatever its case: a
|
||||
/// JLPT level or a word class. Looking another word up for a card replaces
|
||||
/// these, as they belong to the word.
|
||||
bool isWordTagName(String name) =>
|
||||
_wordTagNames.contains(canonicalTagName(name));
|
||||
|
||||
final _wordTagNames = {
|
||||
for (final name in [
|
||||
for (var level = 1; level <= 5; level++) 'JLPT-N$level',
|
||||
'Noun',
|
||||
'Pronoun',
|
||||
'i-Adjective',
|
||||
'na-Adjective',
|
||||
'Adverb',
|
||||
'Particle',
|
||||
'Conjunction',
|
||||
'Interjection',
|
||||
'Counter',
|
||||
'Expression',
|
||||
'Verb',
|
||||
])
|
||||
canonicalTagName(name),
|
||||
};
|
||||
|
||||
/// The word class JMdict's part-of-speech [code] belongs to, as a tag
|
||||
/// names it; null for codes no class is tagged for.
|
||||
String? _wordClass(String code) => switch (code) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_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/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -13,18 +14,24 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// Looks a word up in [dictionary], starting from [query] (what the card
|
||||
/// says so far), to write a card with: completes with the word chosen, or
|
||||
/// with null.
|
||||
///
|
||||
/// Words a card of the studyset was made from already ([inStudyset]) are
|
||||
/// marked so: they can still be chosen.
|
||||
Future<DictionaryWord?> lookUpWord(
|
||||
BuildContext context,
|
||||
Dictionary dictionary, {
|
||||
String query = '',
|
||||
Set<DictionaryRef> inStudyset = const {},
|
||||
}) => showAdaptiveSheet<DictionaryWord>(
|
||||
context,
|
||||
builder: (context) => _LookUp(dictionary: dictionary, query: query),
|
||||
builder: (context) =>
|
||||
_LookUp(dictionary: dictionary, query: query, inStudyset: inStudyset),
|
||||
);
|
||||
|
||||
class const _LookUp({
|
||||
required final Dictionary dictionary,
|
||||
required final String query,
|
||||
required final Set<DictionaryRef> inStudyset,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_LookUp> createState() => _LookUpState();
|
||||
|
|
@ -119,7 +126,9 @@ class _LookUpState() extends State<_LookUp> {
|
|||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
)
|
||||
else if (results != null && results.isEmpty)
|
||||
else if (results != null &&
|
||||
results.isEmpty &&
|
||||
_query.text.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
|
|
@ -133,6 +142,15 @@ class _LookUpState() extends State<_LookUp> {
|
|||
DictionaryWordTile(
|
||||
word: word,
|
||||
onTap: () => Navigator.of(context).pop(word),
|
||||
trailing: widget.inStudyset.contains(word.ref)
|
||||
? Tooltip(
|
||||
message: l10n.dictionaryInStudyset,
|
||||
child: Icon(
|
||||
LucideIcons.check,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => StudysetDictionary(
|
||||
missing: (context, status, retry) => Scaffold(
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context).dictionaryTitle)),
|
||||
body: Center(
|
||||
child: switch (status) {
|
||||
DictionaryStatus.finding ||
|
||||
DictionaryStatus.found => const AppLoadingIndicator(),
|
||||
DictionaryStatus.none => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).dictionaryNone,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
DictionaryStatus.unreachable => _Unreachable(onRetry: retry),
|
||||
},
|
||||
),
|
||||
),
|
||||
builder: (context, dictionary) => Scaffold(
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context).dictionaryTitle)),
|
||||
body: switch (dictionary) {
|
||||
|
|
@ -74,9 +91,16 @@ class _DictionaryViewState() extends State<_DictionaryView> {
|
|||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return BlocListener<DictionaryBloc, DictionaryState>(
|
||||
listenWhen: (before, now) => now.outcome != null,
|
||||
listenWhen: (before, now) => now.outcome != null || now.failed,
|
||||
listener: (context, state) {
|
||||
final (:added, :word) = state.outcome!;
|
||||
final outcome = state.outcome;
|
||||
if (outcome == null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.dictionaryAddFailed)));
|
||||
return;
|
||||
}
|
||||
final (:added, :word) = outcome;
|
||||
final message = word != null && added.added == 1
|
||||
? l10n.dictionaryAdded(word)
|
||||
: [
|
||||
|
|
@ -268,9 +292,16 @@ class const _Levels({
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
if (adding case (:final done, count: final total)) ...[
|
||||
LinearProgressIndicator(value: total == 0 ? null : done / total),
|
||||
// Every word come, the cards take a moment more to be made.
|
||||
LinearProgressIndicator(
|
||||
value: total == 0 || done >= total ? null : done / total,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.dictionaryAddingLevel(done, total)),
|
||||
Text(
|
||||
done >= total
|
||||
? l10n.dictionaryMakingCards
|
||||
: l10n.dictionaryAddingLevel(done, total),
|
||||
),
|
||||
] else
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,49 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Where finding a studyset's dictionary stands.
|
||||
enum DictionaryStatus() {
|
||||
/// It is being asked for.
|
||||
finding,
|
||||
|
||||
/// There is one.
|
||||
found,
|
||||
|
||||
/// The instance has none for the studyset's languages, or the studyset
|
||||
/// has no front language.
|
||||
none,
|
||||
|
||||
/// The instance can't be reached; it is asked again once a sync goes
|
||||
/// through.
|
||||
unreachable,
|
||||
}
|
||||
|
||||
/// [builder]'s widget with the dictionary of the studyset around: the one
|
||||
/// for its languages (`DictionaryRepository.dictionaryFor`); null while it
|
||||
/// is being found, when there is none, or when the instance can't be
|
||||
/// reached. So whatever uses a dictionary shows only where there is one.
|
||||
/// [missing], when given, stands in for [builder] without a dictionary, and
|
||||
/// says why, with a way to ask again.
|
||||
///
|
||||
/// An instance that couldn't be reached (the app started offline) is asked
|
||||
/// again once a sync goes through.
|
||||
class const StudysetDictionary({
|
||||
required final Widget Function(BuildContext context, Dictionary? dictionary)
|
||||
builder,
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
DictionaryStatus status,
|
||||
VoidCallback retry,
|
||||
)?
|
||||
missing,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -19,7 +52,11 @@ class const StudysetDictionary({
|
|||
|
||||
class _StudysetDictionaryState() extends State<StudysetDictionary> {
|
||||
StudysetLanguages? _languages;
|
||||
Future<Dictionary?> _dictionary = Future.value();
|
||||
Dictionary? _dictionary;
|
||||
var _status = DictionaryStatus.finding;
|
||||
|
||||
/// Counts the askings, so only the latest one's answer counts.
|
||||
var _asked = 0;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
|
|
@ -27,16 +64,52 @@ class _StudysetDictionaryState() extends State<StudysetDictionary> {
|
|||
final languages = StudysetLanguageScope.of(context);
|
||||
if (languages != _languages) {
|
||||
_languages = languages;
|
||||
_dictionary = DependenciesScope.of(context).dictionaries
|
||||
.dictionaryFor(languages)
|
||||
// Without the instance, nothing uses a dictionary.
|
||||
.then<Dictionary?>((dictionary) => dictionary, onError: (_) => null);
|
||||
unawaited(_find());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _find() async {
|
||||
final asked = ++_asked;
|
||||
final dictionaries = DependenciesScope.of(context).dictionaries;
|
||||
final languages = _languages!;
|
||||
_status = DictionaryStatus.finding;
|
||||
try {
|
||||
final dictionary = await dictionaries.dictionaryFor(languages);
|
||||
if (mounted && asked == _asked) {
|
||||
setState(() {
|
||||
_dictionary = dictionary;
|
||||
_status = dictionary == null
|
||||
? DictionaryStatus.none
|
||||
: DictionaryStatus.found;
|
||||
});
|
||||
}
|
||||
} on Object {
|
||||
if (mounted && asked == _asked) {
|
||||
setState(() {
|
||||
_dictionary = null;
|
||||
_status = DictionaryStatus.unreachable;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() => _status = DictionaryStatus.finding);
|
||||
unawaited(_find());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<Dictionary?>(
|
||||
future: _dictionary,
|
||||
builder: (context, snapshot) => widget.builder(context, snapshot.data),
|
||||
);
|
||||
Widget build(BuildContext context) =>
|
||||
BlocListener<SyncStatusBloc, SyncStatus>(
|
||||
listenWhen: (before, now) => before.lastSyncedAt != now.lastSyncedAt,
|
||||
listener: (context, _) {
|
||||
if (_status == DictionaryStatus.unreachable) {
|
||||
_retry();
|
||||
}
|
||||
},
|
||||
child: switch ((_dictionary, widget.missing)) {
|
||||
(null, final missing?) => missing(context, _status, _retry),
|
||||
(final dictionary, _) => widget.builder(context, dictionary),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ class _TagPickerState() extends State<_TagPicker> {
|
|||
|
||||
List<Widget> _choices(BuildContext context, Tagging tagging) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// Matched whatever the case; a tag made from it keeps the case typed.
|
||||
final query = canonicalTagName(_query.text);
|
||||
final typed = tidyTagName(_query.text);
|
||||
final shown = [
|
||||
for (final tag in tagging.all)
|
||||
if (canonicalTagName(tag.name).startsWith(query)) tag,
|
||||
|
|
@ -172,8 +174,8 @@ class _TagPickerState() extends State<_TagPicker> {
|
|||
if (canCreate)
|
||||
ActionChip(
|
||||
avatar: const Icon(LucideIcons.plus, size: 16),
|
||||
label: Text(l10n.tagPickerCreate(query)),
|
||||
onPressed: () => _create(tagging, query),
|
||||
label: Text(l10n.tagPickerCreate(typed)),
|
||||
onPressed: () => _create(tagging, typed),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -448,15 +448,16 @@ void main() {
|
|||
'',
|
||||
).copyWith(type: CardType.vocabulary, meanings: const ['a meaning']);
|
||||
|
||||
Future<Set<String>> namesOf(CardComposerState state) async {
|
||||
Future<Set<String>> namesOf(CardDraft draft) async {
|
||||
final tagging = await tags.read();
|
||||
return {
|
||||
for (final id in state.byId('new')!.tagIds) tagging.byId(id)!.name,
|
||||
for (final id in draft.tagIds) tagging.byId(id)!.name,
|
||||
...draft.newTagNames,
|
||||
};
|
||||
}
|
||||
|
||||
test("fills the card and gives it the word's tags, made where "
|
||||
'missing, and saved with it', () async {
|
||||
test("fills the card and gives it the word's tags: those that exist at "
|
||||
'once, the others made when it is saved', () async {
|
||||
final noun = await tags.create('Noun');
|
||||
start();
|
||||
await loaded();
|
||||
|
|
@ -470,11 +471,17 @@ void main() {
|
|||
'Noun',
|
||||
]),
|
||||
);
|
||||
final state = await until((s) => s.byId('new')?.tagIds.length == 3);
|
||||
// Once the tags are read, the one that exists is the card's own.
|
||||
final state = await until(
|
||||
(s) =>
|
||||
s.byId('new')?.content.front == text('食べる') &&
|
||||
s.byId('new')!.tagIds.isNotEmpty,
|
||||
);
|
||||
|
||||
expect(state.byId('new')!.content.front, text('食べる'));
|
||||
expect(await namesOf(state), {'JLPT-N5', 'Verb', 'Noun'});
|
||||
expect(state.byId('new')!.tagIds, contains(noun));
|
||||
final draft = state.byId('new')!;
|
||||
expect(draft.tagIds, {noun});
|
||||
expect(draft.newTagNames, ['JLPT-N5', 'Verb']);
|
||||
expect((await tags.read()).named('Verb'), isNull);
|
||||
|
||||
bloc.add(const CardComposerEventLeft('new'));
|
||||
final saved = await until(
|
||||
|
|
@ -485,36 +492,70 @@ void main() {
|
|||
{for (final tag in tagging.of(saved.byId('new')!.cardId!)) tag.name},
|
||||
{'JLPT-N5', 'Verb', 'Noun'},
|
||||
);
|
||||
expect(saved.byId('new')!.newTagNames, isEmpty);
|
||||
});
|
||||
|
||||
test('again swaps the tags the word before brought, and keeps the '
|
||||
"person's own", () async {
|
||||
test("again replaces the tags a word brings and keeps the person's "
|
||||
'own; the word replaced leaves no tag behind', () async {
|
||||
final mine = await tags.create('Food');
|
||||
final verb = await tags.create('Verb');
|
||||
start();
|
||||
await loaded();
|
||||
bloc
|
||||
..add(const CardComposerEventDraftAdded('new'))
|
||||
..add(CardComposerEventTagsChanged('new', {mine, verb}))
|
||||
..add(CardComposerEventTagsChanged('new', {mine}))
|
||||
..add(
|
||||
CardComposerEventLookedUp('new', word('食べる'), const [
|
||||
'JLPT-N5',
|
||||
'Verb',
|
||||
]),
|
||||
)
|
||||
..add(CardComposerEventLookedUp('new', word('寿司'), const ['Noun']));
|
||||
final state = await until(
|
||||
(s) =>
|
||||
s.byId('new')?.content.front == text('寿司') &&
|
||||
s.tagging.all.length == 2,
|
||||
);
|
||||
|
||||
expect(state.byId('new')!.tagIds, {mine});
|
||||
expect(await namesOf(state.byId('new')!), {'Food', 'Noun'});
|
||||
expect((await tags.read()).named('JLPT-N5'), isNull);
|
||||
expect(state.byId('new')!.tagIds, isNot(contains(verb)));
|
||||
});
|
||||
|
||||
test('with tags pinned, the next card keeps the tags but takes the '
|
||||
"next word's level and class", () async {
|
||||
final mine = await tags.create('Lesson 3');
|
||||
start();
|
||||
await loaded();
|
||||
bloc
|
||||
..add(const CardComposerEventDraftAdded('first'))
|
||||
..add(CardComposerEventTagsChanged('first', {mine}))
|
||||
..add(
|
||||
CardComposerEventLookedUp('first', word('寿司'), const [
|
||||
'JLPT-N5',
|
||||
'Noun',
|
||||
]),
|
||||
)
|
||||
..add(const CardComposerEventPinToggled(CardPin.tags))
|
||||
..add(const CardComposerEventDraftAdded('second', after: 'first'))
|
||||
..add(
|
||||
CardComposerEventLookedUp('second', word('走る'), const [
|
||||
'JLPT-N3',
|
||||
'Verb',
|
||||
]),
|
||||
);
|
||||
await until((s) => s.byId('new')?.tagIds.length == 3);
|
||||
final level = (await tags.read()).named('JLPT-N5')!.id;
|
||||
|
||||
bloc.add(
|
||||
CardComposerEventLookedUp('new', word('寿司'), const ['Noun']),
|
||||
);
|
||||
final swapped = await until(
|
||||
(s) => !(s.byId('new')?.tagIds.contains(level) ?? true),
|
||||
final state = await until(
|
||||
(s) =>
|
||||
s.byId('second')?.content.front == text('走る') &&
|
||||
s.tagging.all.isNotEmpty,
|
||||
);
|
||||
|
||||
expect(swapped.byId('new')!.content.front, text('寿司'));
|
||||
// Verb was the person's before the first word came, so it stays.
|
||||
expect(await namesOf(swapped), {'Food', 'Verb', 'Noun'});
|
||||
expect(await namesOf(state.byId('second')!), {
|
||||
'Lesson 3',
|
||||
'JLPT-N3',
|
||||
'Verb',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,20 @@ void main() {
|
|||
expect((await cards.allCards()).every((card) => card.imported), isTrue);
|
||||
});
|
||||
|
||||
test('makes one card of a word added twice at once, or twice in one '
|
||||
'list', () async {
|
||||
final both = await Future.wait([
|
||||
adder.add('japanese', [taberu]),
|
||||
adder.add('japanese', [taberu]),
|
||||
]);
|
||||
expect(both, [(added: 1, skipped: 0), (added: 0, skipped: 1)]);
|
||||
expect(
|
||||
await adder.add('japanese', [terebi, terebi]),
|
||||
(added: 1, skipped: 1),
|
||||
);
|
||||
expect(await cards.cardsOf('japanese'), hasLength(2));
|
||||
});
|
||||
|
||||
test('names the tags by level and word class', () {
|
||||
expect(taberu.tagNames, ['JLPT-N5', 'Verb']);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -53,8 +53,14 @@ void main() {
|
|||
}
|
||||
|
||||
/// Opens the studyset [id]: `japanese` (Japanese with English meanings)
|
||||
/// or `german`, on an instance with JMdict.
|
||||
Future<void> show(WidgetTester tester, {String id = 'japanese'}) async {
|
||||
/// or `german`, on an instance with JMdict ([dictionaries]), at
|
||||
/// [location] (the studyset's screen unless given).
|
||||
Future<void> show(
|
||||
WidgetTester tester, {
|
||||
String id = 'japanese',
|
||||
FakeDictionaryGateway? dictionaries,
|
||||
String? location,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1400, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
|
@ -86,11 +92,11 @@ void main() {
|
|||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
dictionaryGateway: FakeDictionaryGateway(
|
||||
words: [taberu, terebi, toukyou],
|
||||
),
|
||||
dictionaryGateway:
|
||||
dictionaries ??
|
||||
FakeDictionaryGateway(words: [taberu, terebi, toukyou]),
|
||||
),
|
||||
location: RoutePaths.studyset(id),
|
||||
location: location ?? RoutePaths.studyset(id),
|
||||
);
|
||||
await settle(tester);
|
||||
}
|
||||
|
|
@ -134,6 +140,49 @@ void main() {
|
|||
expect(card.content.reading, 'たべる');
|
||||
});
|
||||
|
||||
testWidgets('tapping Add twice makes one card', (tester) async {
|
||||
await show(tester);
|
||||
await addMaterial(tester, 'From the dictionary');
|
||||
await tester.enterText(find.byType(TextField), 'たべ');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.tap(find.text('Add'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(await cards.cardsOf('japanese'), hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('without the instance, the dictionary says so and offers to '
|
||||
'try again, which finds it once the instance answers', (tester) async {
|
||||
final dictionaries = FakeDictionaryGateway(words: [taberu])
|
||||
..fail = Exception('offline');
|
||||
await show(
|
||||
tester,
|
||||
dictionaries: dictionaries,
|
||||
location: RoutePaths.dictionary('japanese'),
|
||||
);
|
||||
|
||||
expect(find.text("The dictionary can't be reached right now."), findsOne);
|
||||
dictionaries.fail = null;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('N5'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a studyset whose language has no dictionary says so', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, id: 'german', location: RoutePaths.dictionary('german'));
|
||||
|
||||
expect(
|
||||
find.text("This studyset's language has no dictionary on this instance."),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a whole level becomes flashcards, its words in its order', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -124,22 +124,23 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'Noun');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create "noun"'));
|
||||
// The tag keeps the case typed.
|
||||
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);
|
||||
expect(selected(tester, 'Noun'), isTrue);
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(chip<TagChip>('noun'), findsOneWidget);
|
||||
expect(chip<TagChip>('Noun'), findsOneWidget);
|
||||
// Leaving the composer saves the card.
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(TagLabel, 'noun'), findsOneWidget);
|
||||
expect(find.widgetWithText(TagLabel, 'Noun'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the picker narrows the tags to those starting with what is '
|
||||
|
|
|
|||
Loading…
Reference in a new issue