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.",
|
"dictionaryUnreachable": "Das Wörterbuch ist gerade nicht erreichbar.",
|
||||||
"dictionaryRetry": "Erneut versuchen",
|
"dictionaryRetry": "Erneut versuchen",
|
||||||
"dictionaryAddFailed": "Die Wörter konnten nicht hinzugefügt werden. Versuche es erneut.",
|
"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…",
|
"dictionaryMakingCards": "Die Karteikarten werden erstellt…",
|
||||||
"dictionaryNone": "Für die Sprache dieses Lernsets gibt es auf dieser Instanz kein Wörterbuch.",
|
"dictionaryNone": "Für die Sprache dieses Lernsets gibt es auf dieser Instanz kein Wörterbuch.",
|
||||||
"dictionaryAdd": "Hinzufügen",
|
"dictionaryAdd": "Hinzufügen",
|
||||||
|
|
@ -2060,6 +2062,7 @@
|
||||||
"watchWordCount": "{count}×",
|
"watchWordCount": "{count}×",
|
||||||
"watchAdd": "Hinzufügen",
|
"watchAdd": "Hinzufügen",
|
||||||
"watchAdded": "{word} hinzugefügt",
|
"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.",
|
"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.",
|
"watchStretchFailed": "Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.",
|
||||||
"watchStretchSearch": "Auf {site} suchen",
|
"watchStretchSearch": "Auf {site} suchen",
|
||||||
|
|
|
||||||
|
|
@ -795,6 +795,9 @@
|
||||||
"dictionaryUnreachable": "The dictionary can't be reached right now.",
|
"dictionaryUnreachable": "The dictionary can't be reached right now.",
|
||||||
"dictionaryRetry": "Retry",
|
"dictionaryRetry": "Retry",
|
||||||
"dictionaryAddFailed": "Couldn't add the words. Try again.",
|
"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…",
|
"dictionaryMakingCards": "Making the cards…",
|
||||||
"dictionaryNone": "This studyset's language has no dictionary on this instance.",
|
"dictionaryNone": "This studyset's language has no dictionary on this instance.",
|
||||||
"dictionaryAdd": "Add",
|
"dictionaryAdd": "Add",
|
||||||
|
|
@ -2408,6 +2411,8 @@
|
||||||
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"watchAdd": "Add",
|
"watchAdd": "Add",
|
||||||
"watchAdded": "{word} added",
|
"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": "{text} isn't in the dictionary as one word.",
|
||||||
"@watchStretchNone": {"placeholders": {"text": {"type": "String"}}},
|
"@watchStretchNone": {"placeholders": {"text": {"type": "String"}}},
|
||||||
"watchStretchFailed": "Couldn't look it up. Choose it again to try once more.",
|
"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.'**
|
/// **'Couldn\'t add the words. Try again.'**
|
||||||
String get dictionaryAddFailed;
|
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.
|
/// No description provided for @dictionaryMakingCards.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -12153,6 +12165,12 @@ abstract class AppLocalizations {
|
||||||
/// **'{word} added'**
|
/// **'{word} added'**
|
||||||
String watchAdded(String word);
|
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.
|
/// No description provided for @watchStretchNone.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2575,6 +2575,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get dictionaryAddFailed =>
|
String get dictionaryAddFailed =>
|
||||||
'Die Wörter konnten nicht hinzugefügt werden. Versuche es erneut.';
|
'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
|
@override
|
||||||
String get dictionaryMakingCards => 'Die Karteikarten werden erstellt…';
|
String get dictionaryMakingCards => 'Die Karteikarten werden erstellt…';
|
||||||
|
|
||||||
|
|
@ -7687,6 +7696,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$word hinzugefügt';
|
return '$word hinzugefügt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String watchAddFailed(String word) {
|
||||||
|
return '$word konnte nicht hinzugefügt werden. Versuch es noch einmal.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchStretchNone(String text) {
|
String watchStretchNone(String text) {
|
||||||
return '„$text“ steht nicht als ein Wort im Wörterbuch.';
|
return '„$text“ steht nicht als ein Wort im Wörterbuch.';
|
||||||
|
|
|
||||||
|
|
@ -2551,6 +2551,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get dictionaryAddFailed => 'Couldn\'t add the words. Try again.';
|
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
|
@override
|
||||||
String get dictionaryMakingCards => 'Making the cards…';
|
String get dictionaryMakingCards => 'Making the cards…';
|
||||||
|
|
||||||
|
|
@ -7613,6 +7622,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$word added';
|
return '$word added';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String watchAddFailed(String word) {
|
||||||
|
return 'Couldn\'t add $word. Try again.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchStretchNone(String text) {
|
String watchStretchNone(String text) {
|
||||||
return '$text isn\'t in the dictionary as one word.';
|
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
|
/// The cards of [studysetId] on this device right now, in order, read
|
||||||
/// once; [watch] follows every change.
|
/// once; [watch] follows every change.
|
||||||
Future<List<Card>> cardsOf(String studysetId) async {
|
Future<List<Card>> cardsOf(String studysetId) => cardsIn([studysetId]);
|
||||||
final rows = await _gateway.cardsOf(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>>{};
|
final revisionsByCard = <String, List<CardRevision>>{};
|
||||||
for (final row in await _gateway.revisionsOf({
|
for (final row in await _gateway.revisionsOf({
|
||||||
for (final card in rows) card.id,
|
for (final card in rows) card.id,
|
||||||
|
|
@ -114,13 +122,18 @@ class CardRepository(final CardGateway _gateway) {
|
||||||
heads: merged.heads,
|
heads: merged.heads,
|
||||||
conflicts: merged.conflicts,
|
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)) {
|
(a, b) => switch (a.position.compareTo(b.position)) {
|
||||||
0 => a.id.compareTo(b.id),
|
0 => a.id.compareTo(b.id),
|
||||||
final order => order,
|
final order => order,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/// Every card of the person on this device: its id, its studyset, when it
|
/// 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
|
/// 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/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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/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/data/tag_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.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});
|
typedef DictionaryAdded = ({int added, int skipped});
|
||||||
|
|
||||||
/// Adds dictionary entries to a studyset as cards (vocabulary for a word, a
|
/// 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
|
/// character card for a character), each once in the studyset and
|
||||||
/// studyset was made from already is left out. Each card is tagged with
|
/// everything inside it, archived topics too (decided 2026-10-16: a word a
|
||||||
/// the entry's [DictionaryItem.tagNames]: a word's JLPT level and word
|
/// topic holds is held, so the studyset of a language's levels never gets
|
||||||
/// class, a character's level, school grade or script.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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();
|
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>>{};
|
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) =>
|
Future<Set<DictionaryRef>> entriesIn(String studysetId) =>
|
||||||
_queue.run(() async => {...await _entriesIn(studysetId)});
|
_queue.run(() async => {...await _entriesIn(studysetId)});
|
||||||
|
|
||||||
Future<Set<DictionaryRef>> _entriesIn(String studysetId) async =>
|
Future<Set<DictionaryRef>> _entriesIn(String studysetId) async =>
|
||||||
_held[studysetId] ??= {
|
_held[studysetId] ??= {
|
||||||
for (final card in await _cards.cardsOf(studysetId))
|
for (final card in await _cards.cardsIn(
|
||||||
|
await _studysets.subtree(studysetId),
|
||||||
|
))
|
||||||
?card.content.entry,
|
?card.content.entry,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Gives every card of [studysetId] made from one of [entries] the tag
|
/// The studysets words added from [studysetId] may go to: it and every
|
||||||
/// [tagName] (made for Jellyfin if new): the words of an episode the
|
/// topic inside it but archived ones, in the order of the tree.
|
||||||
/// studyset held before it was watched too. The tag as something refers
|
Future<List<Studyset>> destinationsOf(String studysetId) async {
|
||||||
/// to it.
|
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(
|
Future<TagRef> tagHeld(
|
||||||
String studysetId,
|
String studysetId,
|
||||||
Set<DictionaryRef> entries,
|
Set<DictionaryRef> entries,
|
||||||
|
|
@ -46,16 +93,18 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
||||||
) => _queue.run(() async {
|
) => _queue.run(() async {
|
||||||
final tagId = await _tags.create(tagName, fromJellyfin: true);
|
final tagId = await _tags.create(tagName, fromJellyfin: true);
|
||||||
await _tags.addTags({
|
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},
|
if (entries.contains(card.content.entry)) card.id: {tagId},
|
||||||
});
|
});
|
||||||
return (id: tagId, name: tidyTagName(tagName));
|
return (id: tagId, name: tidyTagName(tagName));
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Adds [words] to [studysetId], at its end, in their order; each card
|
/// Adds [words] to [studysetId], at the end of where its words go, in
|
||||||
/// also tagged [jellyfinTagNames], tags made for Jellyfin if they are new
|
/// their order; each card also tagged [jellyfinTagNames], tags made for
|
||||||
/// (the show and the episode, or the artist and the song, a word was met
|
/// Jellyfin if they are new (the show and the episode, or the artist and
|
||||||
/// in).
|
/// the song, a word was met in).
|
||||||
Future<DictionaryAdded> add(
|
Future<DictionaryAdded> add(
|
||||||
String studysetId,
|
String studysetId,
|
||||||
List<DictionaryItem> words, {
|
List<DictionaryItem> words, {
|
||||||
|
|
@ -78,28 +127,20 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
||||||
if (fresh.isEmpty) {
|
if (fresh.isEmpty) {
|
||||||
return (added: 0, skipped: words.length);
|
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,
|
for (final word in fresh) word.card,
|
||||||
], origin: fresh.first.ref.source);
|
], origin: fresh.first.ref.source);
|
||||||
// Held from here on, even should tagging them fail.
|
// Held from here on, even should tagging them fail.
|
||||||
there.addAll(once);
|
there.addAll(once);
|
||||||
|
|
||||||
final tagIdOf = <String, String>{};
|
// Every name at once: each tag read takes in every card's tags.
|
||||||
final tagsByCard = <String, Set<String>>{};
|
await _tags.addTagsNamed(
|
||||||
for (final (index, word) in fresh.indexed) {
|
{
|
||||||
for (final name in word.tagNames) {
|
for (final (index, word) in fresh.indexed)
|
||||||
final tagId = tagIdOf[name] ??= await _tags.create(name);
|
ids[index]: {...word.tagNames, ...jellyfinTagNames},
|
||||||
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
},
|
||||||
}
|
fromJellyfin: {...jellyfinTagNames},
|
||||||
for (final name in jellyfinTagNames) {
|
);
|
||||||
final tagId = tagIdOf[name] ??= await _tags.create(
|
|
||||||
name,
|
|
||||||
fromJellyfin: true,
|
|
||||||
);
|
|
||||||
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _tags.addTags(tagsByCard);
|
|
||||||
return (added: fresh.length, skipped: words.length - fresh.length);
|
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/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/data/review_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.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
|
/// 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
|
/// 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(
|
class EntryProgress(
|
||||||
final CardRepository _cards,
|
final CardRepository _cards,
|
||||||
final ReviewRepository _reviews,
|
final ReviewRepository _reviews,
|
||||||
|
final StudysetRepository _studysets,
|
||||||
) {
|
) {
|
||||||
/// The schedules of [studysetId]'s cards made from dictionary entries,
|
/// The schedules of the cards of [studysetId] and inside it made from
|
||||||
/// by their entry; a card never studied has one of no reviews.
|
/// dictionary entries, by their entry; a card never studied has one of no
|
||||||
|
/// reviews.
|
||||||
Future<Map<DictionaryRef, CardSchedule>> of(String studysetId) async {
|
Future<Map<DictionaryRef, CardSchedule>> of(String studysetId) async {
|
||||||
final entryOf = <String, DictionaryRef>{
|
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,
|
card.id: ?card.content.entry,
|
||||||
};
|
};
|
||||||
final schedules = await _reviews.schedulesOf(entryOf.keys.toList());
|
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/bloc/dictionary_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.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/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_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/dictionary_word_tile.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.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,
|
backgroundColor: Colors.transparent,
|
||||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||||
title: Text(l10n.dictionaryTitle),
|
title: Text(l10n.dictionaryTitle),
|
||||||
|
actions: [
|
||||||
|
AddDestinationButton(studysetId: studysetId),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: _Dictionary(
|
body: _Dictionary(
|
||||||
dictionary: words,
|
dictionary: words,
|
||||||
|
|
@ -99,6 +104,10 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||||
title: Text(l10n.dictionaryTitle),
|
title: Text(l10n.dictionaryTitle),
|
||||||
|
actions: [
|
||||||
|
AddDestinationButton(studysetId: studysetId),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: l10n.dictionaryTabWords),
|
Tab(text: l10n.dictionaryTabWords),
|
||||||
|
|
@ -148,7 +157,12 @@ class const _Dictionary({
|
||||||
final person = PersonScope.of(context);
|
final person = PersonScope.of(context);
|
||||||
return DictionaryBloc(
|
return DictionaryBloc(
|
||||||
DependenciesScope.of(context).dictionaries,
|
DependenciesScope.of(context).dictionaries,
|
||||||
DictionaryAdder(person.cards, person.tags),
|
DictionaryAdder(
|
||||||
|
person.cards,
|
||||||
|
person.tags,
|
||||||
|
person.studysets,
|
||||||
|
person.settings,
|
||||||
|
),
|
||||||
dictionary,
|
dictionary,
|
||||||
studysetId,
|
studysetId,
|
||||||
)..add(const DictionaryEventStarted());
|
)..add(const DictionaryEventStarted());
|
||||||
|
|
|
||||||
|
|
@ -540,20 +540,44 @@ class WatchCubit(
|
||||||
/// episode. Its hints are the dictionary's examples alone, never the line
|
/// 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,
|
/// 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
|
/// a line may be wrong or mistyped, and every episode's line would pile
|
||||||
/// up on a common word.
|
/// up on a common word. It shows as held, and new, at once (decided
|
||||||
Future<void> add(DictionaryWord entry) async {
|
/// 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) {
|
if (state.selectedPart == null) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
emit(
|
||||||
final progress = await _progress.of(studysetId);
|
state.copyWith(
|
||||||
if (!isClosed) {
|
known: {...state.known, entry.ref},
|
||||||
emit(
|
progress: {...state.progress, entry.ref: _neverStudied},
|
||||||
state.copyWith(known: {...state.known, entry.ref}, progress: progress),
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
||||||
|
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}..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.
|
/// Plays from the start of [index]'s line.
|
||||||
Future<void> playLine(int index) async {
|
Future<void> playLine(int index) async {
|
||||||
final playback = _playback;
|
final playback = _playback;
|
||||||
|
|
@ -603,12 +627,15 @@ class WatchCubit(
|
||||||
fresh,
|
fresh,
|
||||||
jellyfinTagNames: _tagNames,
|
jellyfinTagNames: _tagNames,
|
||||||
);
|
);
|
||||||
final progress = await _progress.of(studysetId);
|
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
|
// New cards, never studied: nothing to read again.
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
known: {...state.known, for (final entry in fresh) entry.ref},
|
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/dictionary.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/word_levels.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/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/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/dictionary_word_tile.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
||||||
|
|
@ -71,8 +72,13 @@ class const _Watch({
|
||||||
dependencies.jellyfin,
|
dependencies.jellyfin,
|
||||||
dependencies.mediaPlayers,
|
dependencies.mediaPlayers,
|
||||||
dependencies.dictionaries,
|
dependencies.dictionaries,
|
||||||
DictionaryAdder(person.cards, person.tags),
|
DictionaryAdder(
|
||||||
EntryProgress(person.cards, person.reviews),
|
person.cards,
|
||||||
|
person.tags,
|
||||||
|
person.studysets,
|
||||||
|
person.settings,
|
||||||
|
),
|
||||||
|
EntryProgress(person.cards, person.reviews, person.studysets),
|
||||||
studysetId: studysetId,
|
studysetId: studysetId,
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
languages: languages,
|
languages: languages,
|
||||||
|
|
@ -780,9 +786,15 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
await cubit().add(entry);
|
final added = await cubit().add(entry);
|
||||||
messenger.showSnackBar(
|
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),
|
child: Text(l10n.watchAdd),
|
||||||
|
|
@ -819,6 +831,15 @@ class const _ChosenWord({required final Dictionary dictionary})
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...lineAndTranslation,
|
...lineAndTranslation,
|
||||||
const SizedBox(height: 8),
|
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) ...[
|
for (final (index, entry) in part.entries.indexed) ...[
|
||||||
if (index > 0) const Divider(height: 1),
|
if (index > 0) const Divider(height: 1),
|
||||||
row(entry),
|
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(
|
Text(
|
||||||
l10n.watchTapAWord,
|
l10n.watchTapAWord,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
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`:
|
// 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
|
// 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
|
// 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.
|
/// The schema this app writes.
|
||||||
const settingsSchemaVersion = 9;
|
const settingsSchemaVersion = 10;
|
||||||
|
|
||||||
/// Encodes [settings] into their payload.
|
/// Encodes [settings] into their payload.
|
||||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
|
|
@ -93,6 +96,8 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
{'server': tie.serverId, 'item': tie.itemId},
|
{'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,
|
countLearning: json['countLearning'] == true,
|
||||||
jellyfinTies: _jellyfinTies(json['jellyfinTies']),
|
jellyfinTies: _jellyfinTies(json['jellyfinTies']),
|
||||||
hideJellyfinTags: json['hideJellyfinTags'] == true,
|
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.');
|
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,
|
/// words were met on Jellyfin ([Tag.fromJellyfin]; decided 2026-10-13,
|
||||||
/// switched in Manage tags).
|
/// switched in Manage tags).
|
||||||
final bool hideJellyfinTags = false,
|
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 {
|
}) with Equatable {
|
||||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||||
/// cards.
|
/// 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.
|
/// These settings with [study] as how the person studies.
|
||||||
PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
|
PersonSettings withStudy(StudyOptions study) => copyWith(study: study);
|
||||||
|
|
||||||
|
|
@ -102,6 +122,7 @@ final class const PersonSettings({
|
||||||
countLearning: countLearning,
|
countLearning: countLearning,
|
||||||
jellyfinTies: jellyfinTies,
|
jellyfinTies: jellyfinTies,
|
||||||
hideJellyfinTags: hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags,
|
||||||
|
addDestinations: addDestinations,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// These settings with [pomodoro] as the Pomodoro timer's profiles.
|
/// These settings with [pomodoro] as the Pomodoro timer's profiles.
|
||||||
|
|
@ -119,6 +140,7 @@ final class const PersonSettings({
|
||||||
bool? countLearning,
|
bool? countLearning,
|
||||||
Map<String, List<JellyfinTie>>? jellyfinTies,
|
Map<String, List<JellyfinTie>>? jellyfinTies,
|
||||||
bool? hideJellyfinTags,
|
bool? hideJellyfinTags,
|
||||||
|
Map<String, String>? addDestinations,
|
||||||
}) => PersonSettings(
|
}) => PersonSettings(
|
||||||
studyTags: studyTags ?? this.studyTags,
|
studyTags: studyTags ?? this.studyTags,
|
||||||
study: study ?? this.study,
|
study: study ?? this.study,
|
||||||
|
|
@ -129,6 +151,7 @@ final class const PersonSettings({
|
||||||
countLearning: countLearning ?? this.countLearning,
|
countLearning: countLearning ?? this.countLearning,
|
||||||
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
jellyfinTies: jellyfinTies ?? this.jellyfinTies,
|
||||||
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags ?? this.hideJellyfinTags,
|
||||||
|
addDestinations: addDestinations ?? this.addDestinations,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// These settings with what only [other] has added: where both have a
|
/// These settings with what only [other] has added: where both have a
|
||||||
|
|
@ -146,6 +169,7 @@ final class const PersonSettings({
|
||||||
countLearning: countLearning || other.countLearning,
|
countLearning: countLearning || other.countLearning,
|
||||||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||||
|
addDestinations: {...other.addDestinations, ...addDestinations},
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -159,5 +183,6 @@ final class const PersonSettings({
|
||||||
countLearning,
|
countLearning,
|
||||||
jellyfinTies,
|
jellyfinTies,
|
||||||
hideJellyfinTags,
|
hideJellyfinTags,
|
||||||
|
addDestinations,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
/// and what is inside them; [watchAll] follows every change.
|
/// and what is inside them; [watchAll] follows every change.
|
||||||
Future<List<Studyset>> all() async => notDeleted(await withDeleted());
|
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
|
/// Every studyset on this device right now, those deleted and inside
|
||||||
/// them too.
|
/// them too.
|
||||||
Future<List<Studyset>> withDeleted() async => [
|
Future<List<Studyset>> withDeleted() async => [
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,47 @@ class TagRepository(
|
||||||
await _refresh();
|
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
|
/// 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
|
/// listed for it that it hasn't got yet. Tags gone in the meantime are left
|
||||||
/// out.
|
/// out.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,28 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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/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_adder.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.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/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/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 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
|
||||||
import '../../support/fake_card_gateway.dart';
|
import '../../support/fake_card_gateway.dart';
|
||||||
import '../../support/fake_device_dictionaries.dart';
|
import '../../support/fake_device_dictionaries.dart';
|
||||||
import '../../support/fake_dictionary_gateway.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';
|
import '../../support/fake_tag_gateway.dart';
|
||||||
|
|
||||||
final taberu = word(
|
final taberu = word(
|
||||||
|
|
@ -381,16 +393,156 @@ void main() {
|
||||||
group('adding words', () {
|
group('adding words', () {
|
||||||
late CardRepository cards;
|
late CardRepository cards;
|
||||||
late TagRepository tags;
|
late TagRepository tags;
|
||||||
|
late StudysetRepository studysets;
|
||||||
|
late SettingsRepository settings;
|
||||||
late DictionaryAdder adder;
|
late DictionaryAdder adder;
|
||||||
|
|
||||||
|
final omae = word(
|
||||||
|
'1002830',
|
||||||
|
'お前',
|
||||||
|
reading: 'おまえ',
|
||||||
|
glosses: ['you'],
|
||||||
|
labels: ['jlpt:n3'],
|
||||||
|
pos: ['pn'],
|
||||||
|
);
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
cards = CardRepository(FakeCardGateway());
|
cards = CardRepository(FakeCardGateway());
|
||||||
tags = TagRepository(FakeTagGateway());
|
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 {
|
tearDown(() async {
|
||||||
await cards.dispose();
|
await cards.dispose();
|
||||||
await tags.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 '
|
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:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
|
@ -82,6 +84,9 @@ void main() {
|
||||||
FakeDictionaryGateway? dictionaries,
|
FakeDictionaryGateway? dictionaries,
|
||||||
StrokeRepository? strokes,
|
StrokeRepository? strokes,
|
||||||
String? location,
|
String? location,
|
||||||
|
|
||||||
|
/// Gives Japanese the topics Vocabulary, and N5 and N3 inside it.
|
||||||
|
bool topics = false,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = const Size(1400, 1600);
|
tester.view.physicalSize = const Size(1400, 1600);
|
||||||
tester.view.devicePixelRatio = 1;
|
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(
|
await pumpApp(
|
||||||
tester,
|
tester,
|
||||||
testDependencies(
|
testDependencies(
|
||||||
|
|
@ -193,6 +215,60 @@ void main() {
|
||||||
expect(card.content.reading, 'たべる');
|
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 {
|
testWidgets('tapping Add twice makes one card', (tester) async {
|
||||||
await show(tester);
|
await show(tester);
|
||||||
await addMaterial(tester, 'From the dictionary');
|
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