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:
Tizian 「ティツィアーン」 2026-10-09 12:45:18 +02:00
commit 49e10895f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 516 additions and 107 deletions

View file

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

View file

@ -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}”.",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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