fix(app): Look up tags a word as the dictionary screen does

A vocabulary card filled through Look up in the composer got the word but
none of its tags, while the dictionary screen tags every word with its
JLPT level and word class. Both now take the tags from the word itself
(DictionaryWord.tagNames). Looking another word up on the same card swaps
the tags the word before brought; tags the person gave the card stay.

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 11:40:03 +02:00
commit d67dc8aa70
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 166 additions and 35 deletions

View file

@ -36,6 +36,7 @@ class CardComposerBloc(
(draft) => draft.copyWith(tagIds: event.tagIds),
),
);
on<CardComposerEventLookedUp>(_onLookedUp);
on<CardComposerEventVersionChosen>(
(event, emit) => _update(
emit,
@ -256,6 +257,29 @@ 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(
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),
);
});
}
Future<void> _onLeft(
CardComposerEventLeft event,
Emitter<CardComposerState> emit,
@ -442,6 +466,10 @@ 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 {},
}) with Equatable {
/// The fields in conflict still waiting for a version.
Set<CardField> get unresolved => conflicts.keys.toSet().difference(resolved);
@ -477,6 +505,7 @@ final class const CardDraft({
Map<CardField, List<Object?>>? conflicts,
Set<CardField>? resolved,
bool? savedHere,
Set<String>? lookedUpTagIds,
}) => CardDraft(
id: id,
content: content ?? this.content,
@ -489,6 +518,7 @@ final class const CardDraft({
conflicts: conflicts ?? this.conflicts,
resolved: resolved ?? this.resolved,
savedHere: savedHere ?? this.savedHere,
lookedUpTagIds: lookedUpTagIds ?? this.lookedUpTagIds,
);
@override
@ -504,6 +534,7 @@ final class const CardDraft({
conflicts,
resolved,
savedHere,
lookedUpTagIds,
];
}
@ -635,6 +666,22 @@ final class const CardComposerEventTagsChanged(
List<Object?> get props => [id, tagIds];
}
/// 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.
final class const CardComposerEventLookedUp(
/// The draft's id.
final String id,
/// Its content with the word.
final CardContent content,
/// The word's tags, by name.
final List<String> tagNames,
) extends CardComposerEvent {
@override
List<Object?> get props => [id, content, tagNames];
}
/// For a field devices changed differently, the person keeps [version].
final class const CardComposerEventVersionChosen(
/// The draft's id.

View file

@ -142,7 +142,8 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
/// Looks the word on the front up in [dictionary], and writes the card
/// with the word chosen: its headword, reading and senses, and where it
/// is from. Hints, an explanation and the tags stay.
/// 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 {
final word = await lookUpWord(
context,
@ -162,7 +163,8 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
.normalized;
// The fields start afresh with it, as with content from elsewhere.
setState(() => _revision++);
_change(filled);
_sent = filled;
_send(CardComposerEventLookedUp(widget.draft.id, filled, word.tagNames));
}
void _send(CardComposerEvent event) =>

View file

@ -9,8 +9,8 @@ typedef DictionaryAdded = ({int added, int skipped});
/// Adds dictionary words to a studyset as vocabulary cards, each once: a
/// word a card of the studyset was made from already is left out. Each card
/// is tagged with the word's JLPT level (`JLPT-N5`) and its word class
/// (`Verb`), by its first sense, so a level or a class can be studied alone.
/// is tagged with the word's [DictionaryWord.tagNames]: its JLPT level and
/// its word class.
class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
/// The dictionary entries the cards of [studysetId] were made from.
Future<Set<DictionaryRef>> entriesIn(String studysetId) async => {
@ -37,7 +37,7 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
final tagIdOf = <String, String>{};
final tagsByCard = <String, Set<String>>{};
for (final (index, word) in fresh.indexed) {
for (final name in tagNamesOf(word)) {
for (final name in word.tagNames) {
final tagId = tagIdOf[name] ??= await _tags.create(name);
(tagsByCard[ids[index]] ??= {}).add(tagId);
}
@ -46,29 +46,3 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
return (added: fresh.length, skipped: words.length - fresh.length);
}
}
/// The tags a card made from [word] gets: its JLPT level and its word class
/// by its first sense (JMdict's codes, grouped), in English as the
/// dictionary's codes are.
List<String> tagNamesOf(DictionaryWord word) => [
if (word.jlpt case final level?) 'JLPT-N$level',
...{
for (final code in word.senses.firstOrNull?.pos ?? const <String>[])
?_wordClass(code),
},
];
String? _wordClass(String code) => switch (code) {
'n' || 'n-adv' || 'n-t' || 'n-pr' => 'Noun',
'pn' => 'Pronoun',
'adj-i' || 'adj-ix' => 'i-Adjective',
'adj-na' => 'na-Adjective',
'adv' || 'adv-to' => 'Adverb',
'prt' => 'Particle',
'conj' => 'Conjunction',
'int' => 'Interjection',
'ctr' => 'Counter',
'exp' => 'Expression',
_ when code.startsWith('v') && code != 'vi' && code != 'vt' => 'Verb',
_ => null,
};

View file

@ -88,6 +88,18 @@ final class const DictionaryWord({
int.parse(match[1]!),
].firstOrNull;
/// The tags a card made from it gets, wherever it is made: its JLPT level
/// (`JLPT-N5`) and its word class by its first sense (JMdict's codes,
/// grouped: `Verb`, `Noun`, …), in English as the dictionary's codes are,
/// so a level or a class can be studied alone.
List<String> get tagNames => [
if (jlpt case final level?) 'JLPT-N$level',
...{
for (final code in senses.firstOrNull?.pos ?? const <String>[])
?_wordClass(code),
},
];
/// The word as a vocabulary card: its headword on the front, its reading
/// (none for a word written in kana) and senses, and where it came from.
CardContent get card => CardContent(
@ -102,3 +114,20 @@ final class const DictionaryWord({
@override
List<Object?> get props => [ref, headword, reading, labels, senses];
}
/// 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) {
'n' || 'n-adv' || 'n-t' || 'n-pr' => 'Noun',
'pn' => 'Pronoun',
'adj-i' || 'adj-ix' => 'i-Adjective',
'adj-na' => 'na-Adjective',
'adv' || 'adv-to' => 'Adverb',
'prt' => 'Particle',
'conj' => 'Conjunction',
'int' => 'Interjection',
'ctr' => 'Counter',
'exp' => 'Expression',
_ when code.startsWith('v') && code != 'vi' && code != 'vt' => 'Verb',
_ => null,
};

View file

@ -442,6 +442,82 @@ void main() {
expect(state.byId('copy')!.status, CardDraftStatus.dirty);
});
group('A word looked up', () {
CardContent word(String headword) => card(
headword,
'',
).copyWith(type: CardType.vocabulary, meanings: const ['a meaning']);
Future<Set<String>> namesOf(CardComposerState state) async {
final tagging = await tags.read();
return {
for (final id in state.byId('new')!.tagIds) tagging.byId(id)!.name,
};
}
test("fills the card and gives it the word's tags, made where "
'missing, and saved with it', () async {
final noun = await tags.create('Noun');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventLookedUp('new', word('食べる'), const [
'JLPT-N5',
'Verb',
'Noun',
]),
);
final state = await until((s) => s.byId('new')?.tagIds.length == 3);
expect(state.byId('new')!.content.front, text('食べる'));
expect(await namesOf(state), {'JLPT-N5', 'Verb', 'Noun'});
expect(state.byId('new')!.tagIds, contains(noun));
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
(s) => s.byId('new')?.status == CardDraftStatus.saved,
);
final tagging = await tags.read();
expect(
{for (final tag in tagging.of(saved.byId('new')!.cardId!)) tag.name},
{'JLPT-N5', 'Verb', 'Noun'},
);
});
test('again swaps the tags the word before brought, and keeps the '
"person's own", () 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(
CardComposerEventLookedUp('new', word('食べる'), const [
'JLPT-N5',
'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),
);
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'});
});
});
test('clearing empties the fields and keeps the type', () async {
start();
await loaded();

View file

@ -162,12 +162,12 @@ void main() {
});
test('names the tags by level and word class', () {
expect(tagNamesOf(taberu), ['JLPT-N5', 'Verb']);
expect(taberu.tagNames, ['JLPT-N5', 'Verb']);
expect(
tagNamesOf(word('1', 'x', pos: ['adj-na', 'n'])),
word('1', 'x', pos: ['adj-na', 'n']).tagNames,
['na-Adjective', 'Noun'],
);
expect(tagNamesOf(word('2', 'y', pos: ['vt'])), isEmpty);
expect(word('2', 'y', pos: ['vt']).tagNames, isEmpty);
});
});
}

View file

@ -172,6 +172,9 @@ void main() {
expect(find.text('食べる', findRichText: true), findsWidgets);
expect(find.widgetWithText(TextField, 'たべる'), findsOneWidget);
expect(find.text('to eat'), findsWidgets);
// Tagged as words from the dictionary screen are.
expect(find.text('JLPT-N5'), findsOneWidget);
expect(find.text('Verb'), findsOneWidget);
});
testWidgets("About names whose work the instance's dictionaries are", (