fix(dictionary): words held in topics, a chosen destination, faster adds
A word counted as held only in the studyset itself, so adding from a
studyset whose topics held it (Vocabulary, its words in N3) made a
second card, and watching from a top-level studyset put every new word
into it. Now a word is held anywhere in the studyset and its topics,
archived ones too, and its progress shows from that card; new words go
where the person chose for the studyset ("Adds to N3" in the dictionary
and the Watch screen, the studyset and its topics but archived ones,
kept in their settings, schema 10), the studyset itself until then.
Adding a word took about two seconds on 17,000 card tags: each of its
four tags read every card's tags twice. They are now written with one
read, the Watch screen shows the word added at once and takes it back
should writing fail, and it no longer reads the studyset's progress
again after each word. On a copy of a real database an Add takes 0.3 to
0.4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5efa552489
commit
6737a42ff7
24 changed files with 954 additions and 77 deletions
File diff suppressed because one or more lines are too long
|
|
@ -702,6 +702,8 @@
|
|||
"dictionaryUnreachable": "Das Wörterbuch ist gerade nicht erreichbar.",
|
||||
"dictionaryRetry": "Erneut versuchen",
|
||||
"dictionaryAddFailed": "Die Wörter konnten nicht hinzugefügt werden. Versuche es erneut.",
|
||||
"addDestination": "Ziel: {studyset}",
|
||||
"addDestinationHint": "Wohin neue Karteikarten kommen: in dieses Lernset oder eines seiner Themen",
|
||||
"dictionaryMakingCards": "Die Karteikarten werden erstellt…",
|
||||
"dictionaryNone": "Für die Sprache dieses Lernsets gibt es auf dieser Instanz kein Wörterbuch.",
|
||||
"dictionaryAdd": "Hinzufügen",
|
||||
|
|
@ -2060,6 +2062,7 @@
|
|||
"watchWordCount": "{count}×",
|
||||
"watchAdd": "Hinzufügen",
|
||||
"watchAdded": "{word} hinzugefügt",
|
||||
"watchAddFailed": "{word} konnte nicht hinzugefügt werden. Versuch es noch einmal.",
|
||||
"watchStretchNone": "„{text}“ steht nicht als ein Wort im Wörterbuch.",
|
||||
"watchStretchFailed": "Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.",
|
||||
"watchStretchSearch": "Auf {site} suchen",
|
||||
|
|
|
|||
|
|
@ -795,6 +795,9 @@
|
|||
"dictionaryUnreachable": "The dictionary can't be reached right now.",
|
||||
"dictionaryRetry": "Retry",
|
||||
"dictionaryAddFailed": "Couldn't add the words. Try again.",
|
||||
"addDestination": "Adds to {studyset}",
|
||||
"@addDestination": {"placeholders": {"studyset": {"type": "String"}}},
|
||||
"addDestinationHint": "Where new cards go: this studyset or one of its topics",
|
||||
"dictionaryMakingCards": "Making the cards…",
|
||||
"dictionaryNone": "This studyset's language has no dictionary on this instance.",
|
||||
"dictionaryAdd": "Add",
|
||||
|
|
@ -2408,6 +2411,8 @@
|
|||
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"watchAdd": "Add",
|
||||
"watchAdded": "{word} added",
|
||||
"watchAddFailed": "Couldn't add {word}. Try again.",
|
||||
"@watchAddFailed": {"placeholders": {"word": {"type": "String"}}},
|
||||
"watchStretchNone": "{text} isn't in the dictionary as one word.",
|
||||
"@watchStretchNone": {"placeholders": {"text": {"type": "String"}}},
|
||||
"watchStretchFailed": "Couldn't look it up. Choose it again to try once more.",
|
||||
|
|
|
|||
|
|
@ -4198,6 +4198,18 @@ abstract class AppLocalizations {
|
|||
/// **'Couldn\'t add the words. Try again.'**
|
||||
String get dictionaryAddFailed;
|
||||
|
||||
/// No description provided for @addDestination.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Adds to {studyset}'**
|
||||
String addDestination(String studyset);
|
||||
|
||||
/// No description provided for @addDestinationHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where new cards go: this studyset or one of its topics'**
|
||||
String get addDestinationHint;
|
||||
|
||||
/// No description provided for @dictionaryMakingCards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -12153,6 +12165,12 @@ abstract class AppLocalizations {
|
|||
/// **'{word} added'**
|
||||
String watchAdded(String word);
|
||||
|
||||
/// No description provided for @watchAddFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t add {word}. Try again.'**
|
||||
String watchAddFailed(String word);
|
||||
|
||||
/// No description provided for @watchStretchNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2575,6 +2575,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get dictionaryAddFailed =>
|
||||
'Die Wörter konnten nicht hinzugefügt werden. Versuche es erneut.';
|
||||
|
||||
@override
|
||||
String addDestination(String studyset) {
|
||||
return 'Ziel: $studyset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addDestinationHint =>
|
||||
'Wohin neue Karteikarten kommen: in dieses Lernset oder eines seiner Themen';
|
||||
|
||||
@override
|
||||
String get dictionaryMakingCards => 'Die Karteikarten werden erstellt…';
|
||||
|
||||
|
|
@ -7687,6 +7696,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$word hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchAddFailed(String word) {
|
||||
return '$word konnte nicht hinzugefügt werden. Versuch es noch einmal.';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchStretchNone(String text) {
|
||||
return '„$text“ steht nicht als ein Wort im Wörterbuch.';
|
||||
|
|
|
|||
|
|
@ -2551,6 +2551,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get dictionaryAddFailed => 'Couldn\'t add the words. Try again.';
|
||||
|
||||
@override
|
||||
String addDestination(String studyset) {
|
||||
return 'Adds to $studyset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get addDestinationHint =>
|
||||
'Where new cards go: this studyset or one of its topics';
|
||||
|
||||
@override
|
||||
String get dictionaryMakingCards => 'Making the cards…';
|
||||
|
||||
|
|
@ -7613,6 +7622,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$word added';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchAddFailed(String word) {
|
||||
return 'Couldn\'t add $word. Try again.';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchStretchNone(String text) {
|
||||
return '$text isn\'t in the dictionary as one word.';
|
||||
|
|
|
|||
|
|
@ -92,8 +92,16 @@ class CardRepository(final CardGateway _gateway) {
|
|||
|
||||
/// The cards of [studysetId] on this device right now, in order, read
|
||||
/// once; [watch] follows every change.
|
||||
Future<List<Card>> cardsOf(String studysetId) async {
|
||||
final rows = await _gateway.cardsOf(studysetId);
|
||||
Future<List<Card>> cardsOf(String studysetId) => cardsIn([studysetId]);
|
||||
|
||||
/// The cards of the studysets [studysetIds] on this device right now: each
|
||||
/// studyset's in order, the studysets in the order given; their revisions
|
||||
/// read at once.
|
||||
Future<List<Card>> cardsIn(List<String> studysetIds) async {
|
||||
final rows = [
|
||||
for (final studysetId in studysetIds)
|
||||
..._sorted(await _gateway.cardsOf(studysetId)),
|
||||
];
|
||||
final revisionsByCard = <String, List<CardRevision>>{};
|
||||
for (final row in await _gateway.revisionsOf({
|
||||
for (final card in rows) card.id,
|
||||
|
|
@ -114,13 +122,18 @@ class CardRepository(final CardGateway _gateway) {
|
|||
heads: merged.heads,
|
||||
conflicts: merged.conflicts,
|
||||
),
|
||||
]..sort(
|
||||
];
|
||||
}
|
||||
|
||||
/// [rows] in their order: by position, and by id where two devices picked
|
||||
/// the same one.
|
||||
static List<CardRow> _sorted(List<CardRow> rows) => [...rows]
|
||||
..sort(
|
||||
(a, b) => switch (a.position.compareTo(b.position)) {
|
||||
0 => a.id.compareTo(b.id),
|
||||
final order => order,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Every card of the person on this device: its id, its studyset, when it
|
||||
/// was made, and whether it was imported (its first revision says where
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
||||
/// A studyset words may be added to, and how deep inside the studyset
|
||||
/// they are added from it sits (0 for that studyset itself).
|
||||
typedef AddDestination = ({Studyset studyset, int depth});
|
||||
|
||||
/// Where words added from a studyset go, and the others they could go to.
|
||||
final class const AddDestinationState({
|
||||
/// The studyset and its topics but archived ones, in the order of the
|
||||
/// tree; null until read.
|
||||
final List<AddDestination>? options,
|
||||
|
||||
/// The id of the one words go to; null until read.
|
||||
final String? chosen,
|
||||
}) with Equatable {
|
||||
/// The one words go to; null until read.
|
||||
Studyset? get chosenStudyset => options
|
||||
?.where((option) => option.studyset.id == chosen)
|
||||
.firstOrNull
|
||||
?.studyset;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [options, chosen];
|
||||
}
|
||||
|
||||
/// Where words added from the studyset [_studysetId] go (decided
|
||||
/// 2026-10-16): the topic the person chose for it, or the studyset itself,
|
||||
/// and choosing another. Follows the choice as it changes elsewhere too
|
||||
/// (another screen, another device).
|
||||
class AddDestinationCubit(
|
||||
final DictionaryAdder _adder,
|
||||
final SettingsRepository _settings,
|
||||
final String _studysetId,
|
||||
) extends Cubit<AddDestinationState> {
|
||||
this : super(const AddDestinationState()) {
|
||||
_subscription = _settings.watch().listen(
|
||||
(_) => unawaited(_read()),
|
||||
// A failed read keeps the choice shown; the next change reads again.
|
||||
onError: (Object _) {},
|
||||
);
|
||||
}
|
||||
|
||||
late final StreamSubscription<void> _subscription;
|
||||
|
||||
Future<void> _read() async {
|
||||
final options = await _adder.destinationsOf(_studysetId);
|
||||
final chosen = await _adder.destinationOf(_studysetId);
|
||||
final byId = {for (final studyset in options) studyset.id: studyset};
|
||||
int depthOf(Studyset studyset) => switch (byId[studyset.parentId]) {
|
||||
final parent? when studyset.id != _studysetId => depthOf(parent) + 1,
|
||||
_ => 0,
|
||||
};
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
AddDestinationState(
|
||||
options: [
|
||||
for (final studyset in options)
|
||||
(studyset: studyset, depth: depthOf(studyset)),
|
||||
],
|
||||
chosen: chosen,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the words added from now on to the studyset [id].
|
||||
Future<void> choose(String id) async {
|
||||
emit(AddDestinationState(options: state.options, chosen: id));
|
||||
await _adder.chooseDestination(_studysetId, id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _subscription.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@ 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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
|
|
@ -10,35 +14,78 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
|||
typedef DictionaryAdded = ({int added, int skipped});
|
||||
|
||||
/// Adds dictionary entries to a studyset as cards (vocabulary for a word, a
|
||||
/// character card for a character), each once: an entry a card of the
|
||||
/// studyset was made from already is left out. Each card is tagged with
|
||||
/// the entry's [DictionaryItem.tagNames]: a word's JLPT level and word
|
||||
/// class, a character's level, school grade or script.
|
||||
/// character card for a character), each once in the studyset and
|
||||
/// everything inside it, archived topics too (decided 2026-10-16: a word a
|
||||
/// topic holds is held, so the studyset of a language's levels never gets
|
||||
/// its N3 word again): an entry a card there was made from already is left
|
||||
/// out. New cards go where the person chose for the studyset, one of its
|
||||
/// topics, or into the studyset itself. Each card is tagged with the
|
||||
/// entry's [DictionaryItem.tagNames]: a word's JLPT level and word class, a
|
||||
/// character's level, school grade or script.
|
||||
///
|
||||
/// 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) {
|
||||
class DictionaryAdder(
|
||||
final CardRepository _cards,
|
||||
final TagRepository _tags,
|
||||
final StudysetRepository _studysets,
|
||||
final SettingsRepository _settings,
|
||||
) {
|
||||
final _queue = SerialQueue();
|
||||
|
||||
/// The entries each studyset holds, as read once and added to since.
|
||||
/// The entries held in each studyset and inside it, as read once and
|
||||
/// added to since.
|
||||
final _held = <String, Set<DictionaryRef>>{};
|
||||
|
||||
/// The dictionary entries the cards of [studysetId] were made from.
|
||||
/// The dictionary entries the cards of [studysetId] and everything inside
|
||||
/// it were made from.
|
||||
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))
|
||||
for (final card in await _cards.cardsIn(
|
||||
await _studysets.subtree(studysetId),
|
||||
))
|
||||
?card.content.entry,
|
||||
};
|
||||
|
||||
/// Gives every card of [studysetId] made from one of [entries] the tag
|
||||
/// [tagName] (made for Jellyfin if new): the words of an episode the
|
||||
/// studyset held before it was watched too. The tag as something refers
|
||||
/// to it.
|
||||
/// The studysets words added from [studysetId] may go to: it and every
|
||||
/// topic inside it but archived ones, in the order of the tree.
|
||||
Future<List<Studyset>> destinationsOf(String studysetId) async {
|
||||
final all = await _studysets.all();
|
||||
final byId = {for (final studyset in all) studyset.id: studyset};
|
||||
final studied = studiedIn(studysetId, all);
|
||||
return [
|
||||
for (final id in studied.isEmpty ? [studysetId] : studied) ?byId[id],
|
||||
];
|
||||
}
|
||||
|
||||
/// Where words added from [studysetId] go: the studyset chosen for it
|
||||
/// while it is one of [destinationsOf], else the studyset itself.
|
||||
Future<String> destinationOf(String studysetId) async {
|
||||
final chosen = (await _settings.read()).addDestinationOf(studysetId);
|
||||
if (chosen == null) {
|
||||
return studysetId;
|
||||
}
|
||||
return (await destinationsOf(studysetId)).any((each) => each.id == chosen)
|
||||
? chosen
|
||||
: studysetId;
|
||||
}
|
||||
|
||||
/// Sends words added from [studysetId] to [destinationId] from now on, a
|
||||
/// choice that follows the person to their other devices.
|
||||
Future<void> chooseDestination(String studysetId, String destinationId) =>
|
||||
_settings.update(
|
||||
(settings) => settings.withAddDestination(studysetId, destinationId),
|
||||
);
|
||||
|
||||
/// Gives every card of [studysetId] and inside it made from one of
|
||||
/// [entries] the tag [tagName] (made for Jellyfin if new): the words of an
|
||||
/// episode the studyset held before it was watched too. The tag as
|
||||
/// something refers to it.
|
||||
Future<TagRef> tagHeld(
|
||||
String studysetId,
|
||||
Set<DictionaryRef> entries,
|
||||
|
|
@ -46,16 +93,18 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
) => _queue.run(() async {
|
||||
final tagId = await _tags.create(tagName, fromJellyfin: true);
|
||||
await _tags.addTags({
|
||||
for (final card in await _cards.cardsOf(studysetId))
|
||||
for (final card in await _cards.cardsIn(
|
||||
await _studysets.subtree(studysetId),
|
||||
))
|
||||
if (entries.contains(card.content.entry)) card.id: {tagId},
|
||||
});
|
||||
return (id: tagId, name: tidyTagName(tagName));
|
||||
});
|
||||
|
||||
/// Adds [words] to [studysetId], at its end, in their order; each card
|
||||
/// also tagged [jellyfinTagNames], tags made for Jellyfin if they are new
|
||||
/// (the show and the episode, or the artist and the song, a word was met
|
||||
/// in).
|
||||
/// Adds [words] to [studysetId], at the end of where its words go, in
|
||||
/// their order; each card also tagged [jellyfinTagNames], tags made for
|
||||
/// Jellyfin if they are new (the show and the episode, or the artist and
|
||||
/// the song, a word was met in).
|
||||
Future<DictionaryAdded> add(
|
||||
String studysetId,
|
||||
List<DictionaryItem> words, {
|
||||
|
|
@ -78,28 +127,20 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
if (fresh.isEmpty) {
|
||||
return (added: 0, skipped: words.length);
|
||||
}
|
||||
final ids = await _cards.createAll(studysetId, [
|
||||
final ids = await _cards.createAll(await destinationOf(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>>{};
|
||||
for (final (index, word) in fresh.indexed) {
|
||||
for (final name in word.tagNames) {
|
||||
final tagId = tagIdOf[name] ??= await _tags.create(name);
|
||||
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
||||
}
|
||||
for (final name in jellyfinTagNames) {
|
||||
final tagId = tagIdOf[name] ??= await _tags.create(
|
||||
name,
|
||||
fromJellyfin: true,
|
||||
// Every name at once: each tag read takes in every card's tags.
|
||||
await _tags.addTagsNamed(
|
||||
{
|
||||
for (final (index, word) in fresh.indexed)
|
||||
ids[index]: {...word.tagNames, ...jellyfinTagNames},
|
||||
},
|
||||
fromJellyfin: {...jellyfinTagNames},
|
||||
);
|
||||
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
||||
}
|
||||
}
|
||||
await _tags.addTags(tagsByCard);
|
||||
return (added: fresh.length, skipped: words.length - fresh.length);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,25 @@ 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/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
|
||||
/// How far a studyset's words from the dictionary are learnt: the schedule
|
||||
/// of each card made from an entry (front to back), by the entry, so a
|
||||
/// word met again shows where its card stands.
|
||||
/// word met again shows where its card stands, in the studyset or any of
|
||||
/// its topics.
|
||||
class EntryProgress(
|
||||
final CardRepository _cards,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetRepository _studysets,
|
||||
) {
|
||||
/// The schedules of [studysetId]'s cards made from dictionary entries,
|
||||
/// by their entry; a card never studied has one of no reviews.
|
||||
/// The schedules of the cards of [studysetId] and inside it made from
|
||||
/// dictionary entries, by their entry; a card never studied has one of no
|
||||
/// reviews.
|
||||
Future<Map<DictionaryRef, CardSchedule>> of(String studysetId) async {
|
||||
final entryOf = <String, DictionaryRef>{
|
||||
for (final card in await _cards.cardsOf(studysetId))
|
||||
for (final card in await _cards.cardsIn(
|
||||
await _studysets.subtree(studysetId),
|
||||
))
|
||||
card.id: ?card.content.entry,
|
||||
};
|
||||
final schedules = await _reviews.schedulesOf(entryOf.keys.toList());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/add_destination_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Where words added from the studyset [studysetId] go, and a menu of the
|
||||
/// studyset and its topics to choose another (decided 2026-10-16: "a quick
|
||||
/// option button to choose the destination"). Nothing shows for a studyset
|
||||
/// without topics, where there is nothing to choose.
|
||||
class const AddDestinationButton({
|
||||
required final String studysetId,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) {
|
||||
final person = PersonScope.of(context);
|
||||
return AddDestinationCubit(
|
||||
DictionaryAdder(
|
||||
person.cards,
|
||||
person.tags,
|
||||
person.studysets,
|
||||
person.settings,
|
||||
),
|
||||
person.settings,
|
||||
studysetId,
|
||||
);
|
||||
},
|
||||
child: const _Button(),
|
||||
);
|
||||
}
|
||||
|
||||
class const _Button() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<AddDestinationCubit>().state;
|
||||
final options = state.options;
|
||||
final chosen = state.chosenStudyset;
|
||||
if (options == null || options.length < 2 || chosen == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
for (final (:studyset, :depth) in options)
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
studyset.id == chosen.id ? LucideIcons.check : null,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () =>
|
||||
context.read<AddDestinationCubit>().choose(studyset.id),
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: depth * 16.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(
|
||||
_name(
|
||||
studyset.content.secondaryTitle,
|
||||
studyset.content.title,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => Tooltip(
|
||||
message: l10n.addDestinationHint,
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
icon: const Icon(LucideIcons.folderInput, size: 18),
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: Text(
|
||||
l10n.addDestination(chosen.content.title),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A studyset's name in the menu: its secondary title before its title,
|
||||
/// as two topics may share one ("N5" of vocabulary and of kanji, say, with
|
||||
/// "JLPT" above each).
|
||||
static String _name(String? secondary, String title) =>
|
||||
secondary == null || secondary.isEmpty ? title : '$secondary $title';
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/dictionary_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/add_destination_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_lookup_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
||||
|
|
@ -81,6 +82,10 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(l10n.dictionaryTitle),
|
||||
actions: [
|
||||
AddDestinationButton(studysetId: studysetId),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: _Dictionary(
|
||||
dictionary: words,
|
||||
|
|
@ -99,6 +104,10 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(l10n.dictionaryTitle),
|
||||
actions: [
|
||||
AddDestinationButton(studysetId: studysetId),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(text: l10n.dictionaryTabWords),
|
||||
|
|
@ -148,7 +157,12 @@ class const _Dictionary({
|
|||
final person = PersonScope.of(context);
|
||||
return DictionaryBloc(
|
||||
DependenciesScope.of(context).dictionaries,
|
||||
DictionaryAdder(person.cards, person.tags),
|
||||
DictionaryAdder(
|
||||
person.cards,
|
||||
person.tags,
|
||||
person.studysets,
|
||||
person.settings,
|
||||
),
|
||||
dictionary,
|
||||
studysetId,
|
||||
)..add(const DictionaryEventStarted());
|
||||
|
|
|
|||
|
|
@ -540,19 +540,43 @@ class WatchCubit(
|
|||
/// episode. Its hints are the dictionary's examples alone, never the line
|
||||
/// it was met in (decided 2026-10-13): Tatoeba's sentences are checked,
|
||||
/// a line may be wrong or mistyped, and every episode's line would pile
|
||||
/// up on a common word.
|
||||
Future<void> add(DictionaryWord entry) async {
|
||||
/// up on a common word. It shows as held, and new, at once (decided
|
||||
/// 2026-10-16: writing the card and its tags takes a moment); whether it
|
||||
/// was written, once it is: a word that couldn't be is taken back.
|
||||
Future<bool> add(DictionaryWord entry) async {
|
||||
if (state.selectedPart == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
known: {...state.known, entry.ref},
|
||||
progress: {...state.progress, entry.ref: _neverStudied},
|
||||
),
|
||||
);
|
||||
try {
|
||||
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
||||
final progress = await _progress.of(studysetId);
|
||||
return true;
|
||||
} on Object catch (error, st) {
|
||||
log(
|
||||
"A word couldn't be added",
|
||||
name: 'Watch',
|
||||
error: error,
|
||||
stackTrace: st,
|
||||
);
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
state.copyWith(known: {...state.known, entry.ref}, progress: progress),
|
||||
state.copyWith(
|
||||
known: {...state.known}..remove(entry.ref),
|
||||
progress: {...state.progress}..remove(entry.ref),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A card's schedule before its first review.
|
||||
static const _neverStudied = CardSchedule(reviewCount: 0);
|
||||
|
||||
/// Plays from the start of [index]'s line.
|
||||
Future<void> playLine(int index) async {
|
||||
|
|
@ -603,12 +627,15 @@ class WatchCubit(
|
|||
fresh,
|
||||
jellyfinTagNames: _tagNames,
|
||||
);
|
||||
final progress = await _progress.of(studysetId);
|
||||
if (!isClosed) {
|
||||
// New cards, never studied: nothing to read again.
|
||||
emit(
|
||||
state.copyWith(
|
||||
known: {...state.known, for (final entry in fresh) entry.ref},
|
||||
progress: progress,
|
||||
progress: {
|
||||
...state.progress,
|
||||
for (final entry in fresh) entry.ref: _neverStudied,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/data/entry_progress
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/word_levels.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/word_links.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/add_destination_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/device_dictionary_widgets.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
||||
|
|
@ -71,8 +72,13 @@ class const _Watch({
|
|||
dependencies.jellyfin,
|
||||
dependencies.mediaPlayers,
|
||||
dependencies.dictionaries,
|
||||
DictionaryAdder(person.cards, person.tags),
|
||||
EntryProgress(person.cards, person.reviews),
|
||||
DictionaryAdder(
|
||||
person.cards,
|
||||
person.tags,
|
||||
person.studysets,
|
||||
person.settings,
|
||||
),
|
||||
EntryProgress(person.cards, person.reviews, person.studysets),
|
||||
studysetId: studysetId,
|
||||
itemId: itemId,
|
||||
languages: languages,
|
||||
|
|
@ -780,9 +786,15 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await cubit().add(entry);
|
||||
final added = await cubit().add(entry);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.watchAdded(entry.headword))),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
added
|
||||
? l10n.watchAdded(entry.headword)
|
||||
: l10n.watchAddFailed(entry.headword),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.watchAdd),
|
||||
|
|
@ -819,6 +831,15 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
const SizedBox(height: 8),
|
||||
...lineAndTranslation,
|
||||
const SizedBox(height: 8),
|
||||
// Where Add puts a word, just above it.
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: AddDestinationButton(
|
||||
studysetId: context.select<WatchCubit, String>(
|
||||
(cubit) => cubit.studysetId,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final (index, entry) in part.entries.indexed) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
row(entry),
|
||||
|
|
@ -1120,6 +1141,12 @@ class const _VideoWords({required final Dictionary dictionary})
|
|||
),
|
||||
],
|
||||
),
|
||||
// A line of its own: the panel is narrow beside the video.
|
||||
AddDestinationButton(
|
||||
studysetId: context.select<WatchCubit, String>(
|
||||
(cubit) => cubit.studysetId,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.watchTapAWord,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
|
|
|
|||
|
|
@ -28,10 +28,13 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
|||
// Schema 9 added the Jellyfin items tied to each studyset (`jellyfinTies`:
|
||||
// by studyset id, each a server's and an item's id), left out while there
|
||||
// are none, and hiding the tags made for Jellyfin on cards being studied
|
||||
// (`hideJellyfinTags`), left out while off. Older schemas read with what they lack as it comes.
|
||||
// (`hideJellyfinTags`), left out while off. Schema 10 added where words
|
||||
// added from a studyset go (`addDestinations`: by studyset id, a topic's
|
||||
// id), left out while none is chosen. Older schemas read with what they
|
||||
// lack as it comes.
|
||||
|
||||
/// The schema this app writes.
|
||||
const settingsSchemaVersion = 9;
|
||||
const settingsSchemaVersion = 10;
|
||||
|
||||
/// Encodes [settings] into their payload.
|
||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||
|
|
@ -93,6 +96,8 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
|||
{'server': tie.serverId, 'item': tie.itemId},
|
||||
],
|
||||
},
|
||||
if (settings.addDestinations.isNotEmpty)
|
||||
'addDestinations': settings.addDestinations,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -127,6 +132,11 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
|
|||
countLearning: json['countLearning'] == true,
|
||||
jellyfinTies: _jellyfinTies(json['jellyfinTies']),
|
||||
hideJellyfinTags: json['hideJellyfinTags'] == true,
|
||||
addDestinations: {
|
||||
if (json['addDestinations'] case final Map<String, Object?> chosen)
|
||||
for (final MapEntry(:key, :value) in chosen.entries)
|
||||
if (value is String) key: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a settings payload.');
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ final class const PersonSettings({
|
|||
/// words were met on Jellyfin ([Tag.fromJellyfin]; decided 2026-10-13,
|
||||
/// switched in Manage tags).
|
||||
final bool hideJellyfinTags = false,
|
||||
|
||||
/// Where words added from a studyset's dictionary or Watch screen go, by
|
||||
/// the studyset's id: one of its topics (decided 2026-10-16). A studyset
|
||||
/// not listed takes them itself.
|
||||
final Map<String, String> addDestinations = const {},
|
||||
}) with Equatable {
|
||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||
/// cards.
|
||||
|
|
@ -88,6 +93,21 @@ final class const PersonSettings({
|
|||
},
|
||||
);
|
||||
|
||||
/// The studyset chosen for words added from the studyset [studysetId];
|
||||
/// null while none is, when they go to the studyset itself.
|
||||
String? addDestinationOf(String studysetId) => addDestinations[studysetId];
|
||||
|
||||
/// These settings with words added from the studyset [studysetId] going
|
||||
/// to [destinationId]; to the studyset itself, the choice is forgotten.
|
||||
PersonSettings withAddDestination(String studysetId, String destinationId) =>
|
||||
copyWith(
|
||||
addDestinations: {
|
||||
for (final MapEntry(:key, :value) in addDestinations.entries)
|
||||
if (key != studysetId) key: value,
|
||||
if (destinationId != studysetId) studysetId: destinationId,
|
||||
},
|
||||
);
|
||||
|
||||
/// These settings with [study] as how the person studies.
|
||||
PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
|
||||
|
||||
|
|
@ -102,6 +122,7 @@ final class const PersonSettings({
|
|||
countLearning: countLearning,
|
||||
jellyfinTies: jellyfinTies,
|
||||
hideJellyfinTags: hideJellyfinTags,
|
||||
addDestinations: addDestinations,
|
||||
);
|
||||
|
||||
/// These settings with [pomodoro] as the Pomodoro timer's profiles.
|
||||
|
|
@ -119,6 +140,7 @@ final class const PersonSettings({
|
|||
bool? countLearning,
|
||||
Map<String, List<JellyfinTie>>? jellyfinTies,
|
||||
bool? hideJellyfinTags,
|
||||
Map<String, String>? addDestinations,
|
||||
}) => PersonSettings(
|
||||
studyTags: studyTags ?? this.studyTags,
|
||||
study: study ?? this.study,
|
||||
|
|
@ -129,6 +151,7 @@ final class const PersonSettings({
|
|||
countLearning: countLearning ?? this.countLearning,
|
||||
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
||||
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
||||
addDestinations: addDestinations ?? this.addDestinations,
|
||||
);
|
||||
|
||||
/// These settings with what only [other] has added: where both have a
|
||||
|
|
@ -146,6 +169,7 @@ final class const PersonSettings({
|
|||
countLearning: countLearning || other.countLearning,
|
||||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||
addDestinations: {...other.addDestinations, ...addDestinations},
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -159,5 +183,6 @@ final class const PersonSettings({
|
|||
countLearning,
|
||||
jellyfinTies,
|
||||
hideJellyfinTags,
|
||||
addDestinations,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
/// and what is inside them; [watchAll] follows every change.
|
||||
Future<List<Studyset>> all() async => notDeleted(await withDeleted());
|
||||
|
||||
/// The ids of the studyset [id] and everything inside it on this device
|
||||
/// right now, archived studysets too, those deleted not: the studyset
|
||||
/// first, then the tree's order.
|
||||
Future<List<String>> subtree(String id) async => subtreeOf(id, await all());
|
||||
|
||||
/// Every studyset on this device right now, those deleted and inside
|
||||
/// them too.
|
||||
Future<List<Studyset>> withDeleted() async => [
|
||||
|
|
|
|||
|
|
@ -181,6 +181,47 @@ class TagRepository(
|
|||
await _refresh();
|
||||
}
|
||||
|
||||
/// Gives each card the tags [tagNamesByCard] names, making those missing
|
||||
/// (as made for Jellyfin when [fromJellyfin] names them), with one read of
|
||||
/// the tags rather than one per name: a word added from the dictionary
|
||||
/// gets four at once, and every read takes in each card's tags. A name in
|
||||
/// any case is the tag it names. Returns each tag's id by its name as
|
||||
/// given. Throws an [ArgumentError] for a blank name, writing nothing.
|
||||
Future<Map<String, String>> addTagsNamed(
|
||||
Map<String, Set<String>> tagNamesByCard, {
|
||||
Set<String> fromJellyfin = const {},
|
||||
}) async {
|
||||
final names = {for (final names in tagNamesByCard.values) ...names};
|
||||
if (names.any((name) => tidyTagName(name).isEmpty)) {
|
||||
throw ArgumentError.value(names, 'tagNamesByCard', 'names a blank tag');
|
||||
}
|
||||
final ids = await _serially(() async {
|
||||
final tagging = await _read();
|
||||
final made = <String, String>{};
|
||||
final ids = <String, String>{};
|
||||
for (final name in names) {
|
||||
ids[name] =
|
||||
tagging.named(name)?.id ??
|
||||
(made[canonicalTagName(name)] ??= await _gateway.insertTag(
|
||||
encodeTagPayload((
|
||||
name: name,
|
||||
colorName: defaultTagColorName,
|
||||
), fromJellyfin: fromJellyfin.contains(name)),
|
||||
));
|
||||
}
|
||||
await _gateway.insertCardTags([
|
||||
for (final MapEntry(key: cardId, value: names)
|
||||
in tagNamesByCard.entries)
|
||||
for (final tagId in {for (final name in names) ids[name]!})
|
||||
if (!tagging.of(cardId).any((tag) => tag.id == tagId))
|
||||
(cardId: cardId, tagId: tagId),
|
||||
]);
|
||||
return ids;
|
||||
});
|
||||
await _refresh();
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Gives cards more tags at once: each card in [tagIdsByCard] the tags
|
||||
/// listed for it that it hasn't got yet. Tags gone in the meantime are left
|
||||
/// out.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/entry_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_device_dictionaries.dart';
|
||||
import '../../support/fake_dictionary_gateway.dart';
|
||||
import '../../support/fake_review_gateway.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/fake_tag_gateway.dart';
|
||||
|
||||
final taberu = word(
|
||||
|
|
@ -381,16 +393,156 @@ void main() {
|
|||
group('adding words', () {
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
late StudysetRepository studysets;
|
||||
late SettingsRepository settings;
|
||||
late DictionaryAdder adder;
|
||||
|
||||
final omae = word(
|
||||
'1002830',
|
||||
'お前',
|
||||
reading: 'おまえ',
|
||||
glosses: ['you'],
|
||||
labels: ['jlpt:n3'],
|
||||
pos: ['pn'],
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
tags = TagRepository(FakeTagGateway());
|
||||
adder = DictionaryAdder(cards, tags);
|
||||
// Japanese › Vocabulary › N5, N3 and an archived Old; Other beside.
|
||||
ByteData titled(String title) =>
|
||||
encodeStudysetPayload(StudysetContent.titled(title));
|
||||
studysets = StudysetRepository(
|
||||
FakeStudysetGateway()
|
||||
..receiveRemoteChange('japanese', titled('Japanese'))
|
||||
..receiveRemoteChange(
|
||||
'vocabulary',
|
||||
titled('Vocabulary'),
|
||||
parentId: 'japanese',
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'n5',
|
||||
titled('N5'),
|
||||
parentId: 'vocabulary',
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'n3',
|
||||
titled('N3'),
|
||||
parentId: 'vocabulary',
|
||||
position: 'a1',
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'old',
|
||||
titled('Old'),
|
||||
parentId: 'vocabulary',
|
||||
position: 'a2',
|
||||
archivedAt: DateTime.utc(2026, 10),
|
||||
)
|
||||
..receiveRemoteChange('other', titled('Other'), position: 'a1'),
|
||||
);
|
||||
settings = SettingsRepository(FakeSettingsGateway());
|
||||
adder = DictionaryAdder(cards, tags, studysets, settings);
|
||||
});
|
||||
tearDown(() async {
|
||||
await cards.dispose();
|
||||
await tags.dispose();
|
||||
await studysets.dispose();
|
||||
await settings.dispose();
|
||||
});
|
||||
|
||||
List<String?> entryIds(List<Card> cards) => [
|
||||
for (final card in cards) card.content.entry?.id,
|
||||
];
|
||||
|
||||
test('a word held anywhere in the studyset or its topics, archived ones '
|
||||
'too, is never added again; one held outside it is', () async {
|
||||
await adder.add('n5', [taberu]);
|
||||
await adder.add('old', [terebi]);
|
||||
await adder.add('other', [omae]);
|
||||
|
||||
expect(
|
||||
await adder.add('vocabulary', [taberu, terebi, omae]),
|
||||
(added: 1, skipped: 2),
|
||||
);
|
||||
expect(entryIds(await cards.cardsOf('vocabulary')), ['1002830']);
|
||||
expect(await adder.entriesIn('vocabulary'), {
|
||||
taberu.ref,
|
||||
terebi.ref,
|
||||
omae.ref,
|
||||
});
|
||||
});
|
||||
|
||||
test('words go to the studyset or any topic inside it but archived '
|
||||
'ones, in the order of the tree', () async {
|
||||
expect(
|
||||
[
|
||||
for (final studyset in await adder.destinationsOf('vocabulary'))
|
||||
studyset.id,
|
||||
],
|
||||
['vocabulary', 'n5', 'n3'],
|
||||
);
|
||||
expect(
|
||||
[for (final studyset in await adder.destinationsOf('n5')) studyset.id],
|
||||
['n5'],
|
||||
);
|
||||
});
|
||||
|
||||
test('new words go where chosen for the studyset, a choice that follows '
|
||||
'the person; while none is, or once it is no longer inside the '
|
||||
'studyset, to the studyset itself', () async {
|
||||
expect(await adder.destinationOf('vocabulary'), 'vocabulary');
|
||||
|
||||
await adder.chooseDestination('vocabulary', 'n3');
|
||||
expect(
|
||||
(await settings.read()).addDestinationOf('vocabulary'),
|
||||
'n3',
|
||||
);
|
||||
await adder.add('vocabulary', [taberu]);
|
||||
expect(entryIds(await cards.cardsOf('n3')), ['1358280']);
|
||||
expect(await cards.cardsOf('vocabulary'), isEmpty);
|
||||
|
||||
await studysets.move('n3', parentId: null);
|
||||
expect(await adder.destinationOf('vocabulary'), 'vocabulary');
|
||||
await adder.add('vocabulary', [terebi]);
|
||||
expect(entryIds(await cards.cardsOf('vocabulary')), ['1080510']);
|
||||
});
|
||||
|
||||
test(
|
||||
"the cards of a video's words held anywhere inside get its tag",
|
||||
() async {
|
||||
await adder.add('n3', [taberu]);
|
||||
await adder.add('n5', [terebi]);
|
||||
|
||||
final tag = await adder.tagHeld('vocabulary', {
|
||||
taberu.ref,
|
||||
}, 'あかね噺 S1 E1');
|
||||
|
||||
final tagging = await tags.read();
|
||||
final [eat] = await cards.cardsOf('n3');
|
||||
final [tv] = await cards.cardsOf('n5');
|
||||
expect([
|
||||
for (final each in tagging.of(eat.id)) each.id,
|
||||
], contains(tag.id));
|
||||
expect([
|
||||
for (final each in tagging.of(tv.id)) each.id,
|
||||
], isNot(contains(tag.id)));
|
||||
},
|
||||
);
|
||||
|
||||
test("a word's progress comes from its card anywhere in the studyset "
|
||||
'and its topics', () async {
|
||||
await adder.add('n3', [taberu]);
|
||||
await adder.add('other', [terebi]);
|
||||
final reviews = ReviewRepository(FakeReviewGateway());
|
||||
|
||||
final progress = await EntryProgress(
|
||||
cards,
|
||||
reviews,
|
||||
studysets,
|
||||
).of('vocabulary');
|
||||
|
||||
expect(progress.keys, [taberu.ref]);
|
||||
expect(progress[taberu.ref]?.isNew, isTrue);
|
||||
});
|
||||
|
||||
test('makes each word a card once, tagged with its level and word '
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
|
|
@ -82,6 +84,9 @@ void main() {
|
|||
FakeDictionaryGateway? dictionaries,
|
||||
StrokeRepository? strokes,
|
||||
String? location,
|
||||
|
||||
/// Gives Japanese the topics Vocabulary, and N5 and N3 inside it.
|
||||
bool topics = false,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1400, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -131,6 +136,23 @@ void main() {
|
|||
),
|
||||
),
|
||||
);
|
||||
if (topics) {
|
||||
ByteData titled(String title) =>
|
||||
encodeStudysetPayload(StudysetContent.titled(title));
|
||||
studysets
|
||||
..receiveRemoteChange(
|
||||
'vocabulary',
|
||||
titled('Vocabulary'),
|
||||
parentId: 'japanese',
|
||||
)
|
||||
..receiveRemoteChange('n5', titled('N5'), parentId: 'vocabulary')
|
||||
..receiveRemoteChange(
|
||||
'n3',
|
||||
titled('N3'),
|
||||
parentId: 'vocabulary',
|
||||
position: 'a1',
|
||||
);
|
||||
}
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
|
|
@ -193,6 +215,60 @@ void main() {
|
|||
expect(card.content.reading, 'たべる');
|
||||
});
|
||||
|
||||
testWidgets('from a studyset with topics, a word a topic holds shows as '
|
||||
'held, and new words go where Adds to says: the studyset itself, '
|
||||
'until a topic is chosen in its menu', (tester) async {
|
||||
await cards.createAll('n5', [toukyou.card], origin: 'jmdict');
|
||||
await show(
|
||||
tester,
|
||||
topics: true,
|
||||
location: RoutePaths.dictionary('vocabulary'),
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), 'たべ');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Adds to Vocabulary'), findsOneWidget);
|
||||
expect(
|
||||
[
|
||||
for (final card in await cards.cardsOf('vocabulary'))
|
||||
card.content.entry?.id,
|
||||
],
|
||||
['1358280'],
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Adds to Vocabulary'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('N3').last);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Adds to N3'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'テレビ');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
[for (final card in await cards.cardsOf('n3')) card.content.entry?.id],
|
||||
['1080510'],
|
||||
);
|
||||
|
||||
// N5's word, seen from Vocabulary: held, nothing to add.
|
||||
await tester.enterText(find.byType(TextField), '東京');
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('In this studyset'), findsOneWidget);
|
||||
expect(find.text('Add'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a studyset without topics shows no Adds to', (tester) async {
|
||||
await show(tester, location: RoutePaths.dictionary('japanese'));
|
||||
expect(find.textContaining('Adds to'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping Add twice makes one card', (tester) async {
|
||||
await show(tester);
|
||||
await addMaterial(tester, 'From the dictionary');
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue