feat(app): give cards hints and an explanation
A card of any type can carry up to six hints and an explanation, both rich text. They are written in the composer, added from its menu (or Ctrl+Alt+H / Ctrl+Alt+E), each removed on its own; blank ones aren't kept. While studying, "Hints" under the question opens them one at a time, and "Explanation" comes with the answer. Card schema 5 holds them (`hints`, `explanation`), and with them what a card needs next: a report that something on it is wrong (`report`: category, note, when) and a word's senses with their grammar codes (`senses`; a word's meanings then come from them, and its first sense says whether it is usually written in kana). Each merges as a field of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
bd00fc0d9a
commit
9edb23a379
14 changed files with 991 additions and 7 deletions
|
|
@ -344,6 +344,18 @@
|
|||
"cardTagsAdd": "Tags hinzufügen",
|
||||
"cardTagsEdit": "Tags bearbeiten",
|
||||
"tagChipsOverflow": "+{count, plural, =1{1 Tag} other{{count} Tags}}",
|
||||
"cardHints": "Hinweise",
|
||||
"cardHint": "Hinweis {number}",
|
||||
"cardAddHint": "Hinweis hinzufügen",
|
||||
"cardRemoveHint": "Hinweis entfernen",
|
||||
"cardExplanation": "Erklärung",
|
||||
"cardAddExplanation": "Erklärung hinzufügen",
|
||||
"cardRemoveExplanation": "Erklärung entfernen",
|
||||
"cardHintsSheetTitle": "{count, plural, =1{Hinweis} other{Hinweise {count}}}",
|
||||
"cardPreviousHint": "Vorheriger Hinweis",
|
||||
"cardNextHint": "Nächster Hinweis",
|
||||
"cardReport": "Meldung",
|
||||
"cardSenses": "Wortbedeutungen",
|
||||
"cardFilterTooltip": "Filter",
|
||||
"cardFilterTitle": "Filter",
|
||||
"cardFilterTag": "Tag",
|
||||
|
|
|
|||
|
|
@ -383,6 +383,20 @@
|
|||
"cardTagsEdit": "Edit tags",
|
||||
"tagChipsOverflow": "+{count, plural, =1{1 tag} other{{count} tags}}",
|
||||
"@tagChipsOverflow": {"placeholders": {"count": {"type": "int"}}},
|
||||
"cardHints": "Hints",
|
||||
"cardHint": "Hint {number}",
|
||||
"@cardHint": {"placeholders": {"number": {"type": "int"}}},
|
||||
"cardAddHint": "Add hint",
|
||||
"cardRemoveHint": "Remove hint",
|
||||
"cardExplanation": "Explanation",
|
||||
"cardAddExplanation": "Add explanation",
|
||||
"cardRemoveExplanation": "Remove explanation",
|
||||
"cardHintsSheetTitle": "{count, plural, =1{Hint} other{Hints {count}}}",
|
||||
"@cardHintsSheetTitle": {"placeholders": {"count": {"type": "int"}}},
|
||||
"cardPreviousHint": "Previous hint",
|
||||
"cardNextHint": "Next hint",
|
||||
"cardReport": "Report",
|
||||
"cardSenses": "Senses",
|
||||
"cardFilterTooltip": "Filter",
|
||||
"cardFilterTitle": "Filter",
|
||||
"cardFilterTag": "Tag",
|
||||
|
|
|
|||
|
|
@ -2098,6 +2098,78 @@ abstract class AppLocalizations {
|
|||
/// **'+{count, plural, =1{1 tag} other{{count} tags}}'**
|
||||
String tagChipsOverflow(int count);
|
||||
|
||||
/// No description provided for @cardHints.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hints'**
|
||||
String get cardHints;
|
||||
|
||||
/// No description provided for @cardHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hint {number}'**
|
||||
String cardHint(int number);
|
||||
|
||||
/// No description provided for @cardAddHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add hint'**
|
||||
String get cardAddHint;
|
||||
|
||||
/// No description provided for @cardRemoveHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove hint'**
|
||||
String get cardRemoveHint;
|
||||
|
||||
/// No description provided for @cardExplanation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Explanation'**
|
||||
String get cardExplanation;
|
||||
|
||||
/// No description provided for @cardAddExplanation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add explanation'**
|
||||
String get cardAddExplanation;
|
||||
|
||||
/// No description provided for @cardRemoveExplanation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove explanation'**
|
||||
String get cardRemoveExplanation;
|
||||
|
||||
/// No description provided for @cardHintsSheetTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Hint} other{Hints {count}}}'**
|
||||
String cardHintsSheetTitle(int count);
|
||||
|
||||
/// No description provided for @cardPreviousHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Previous hint'**
|
||||
String get cardPreviousHint;
|
||||
|
||||
/// No description provided for @cardNextHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next hint'**
|
||||
String get cardNextHint;
|
||||
|
||||
/// No description provided for @cardReport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Report'**
|
||||
String get cardReport;
|
||||
|
||||
/// No description provided for @cardSenses.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Senses'**
|
||||
String get cardSenses;
|
||||
|
||||
/// No description provided for @cardFilterTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1225,6 +1225,52 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '+$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardHints => 'Hinweise';
|
||||
|
||||
@override
|
||||
String cardHint(int number) {
|
||||
return 'Hinweis $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardAddHint => 'Hinweis hinzufügen';
|
||||
|
||||
@override
|
||||
String get cardRemoveHint => 'Hinweis entfernen';
|
||||
|
||||
@override
|
||||
String get cardExplanation => 'Erklärung';
|
||||
|
||||
@override
|
||||
String get cardAddExplanation => 'Erklärung hinzufügen';
|
||||
|
||||
@override
|
||||
String get cardRemoveExplanation => 'Erklärung entfernen';
|
||||
|
||||
@override
|
||||
String cardHintsSheetTitle(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Hinweise $count',
|
||||
one: 'Hinweis',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardPreviousHint => 'Vorheriger Hinweis';
|
||||
|
||||
@override
|
||||
String get cardNextHint => 'Nächster Hinweis';
|
||||
|
||||
@override
|
||||
String get cardReport => 'Meldung';
|
||||
|
||||
@override
|
||||
String get cardSenses => 'Wortbedeutungen';
|
||||
|
||||
@override
|
||||
String get cardFilterTooltip => 'Filter';
|
||||
|
||||
|
|
|
|||
|
|
@ -1216,6 +1216,52 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '+$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardHints => 'Hints';
|
||||
|
||||
@override
|
||||
String cardHint(int number) {
|
||||
return 'Hint $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardAddHint => 'Add hint';
|
||||
|
||||
@override
|
||||
String get cardRemoveHint => 'Remove hint';
|
||||
|
||||
@override
|
||||
String get cardExplanation => 'Explanation';
|
||||
|
||||
@override
|
||||
String get cardAddExplanation => 'Add explanation';
|
||||
|
||||
@override
|
||||
String get cardRemoveExplanation => 'Remove explanation';
|
||||
|
||||
@override
|
||||
String cardHintsSheetTitle(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Hints $count',
|
||||
one: 'Hint',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardPreviousHint => 'Previous hint';
|
||||
|
||||
@override
|
||||
String get cardNextHint => 'Next hint';
|
||||
|
||||
@override
|
||||
String get cardReport => 'Report';
|
||||
|
||||
@override
|
||||
String get cardSenses => 'Senses';
|
||||
|
||||
@override
|
||||
String get cardFilterTooltip => 'Filter';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,15 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
// Schema 3 added where a card came from (`origin`, e.g. "anki"), in its first
|
||||
// revision only and beside its content; left out for a card written here.
|
||||
// Schema 4 added the page of a PDF the card was made from (`source`, see
|
||||
// pdf_page_ref.dart), left out for a card of its own.
|
||||
// pdf_page_ref.dart), left out for a card of its own. Schema 5 added, each
|
||||
// left out while empty: `hints` (rich text, in order), `explanation` (rich
|
||||
// text), `report` ({category, note, at}: what is wrong, a note, when in
|
||||
// UTC; a category this app doesn't know reads as other) and a word's
|
||||
// `senses` ([{glosses, label, pos, misc, field, dialect}], the codes as
|
||||
// dictionaries write them).
|
||||
|
||||
/// The schema this app writes.
|
||||
const cardSchemaVersion = 4;
|
||||
const cardSchemaVersion = 5;
|
||||
|
||||
/// Encodes a card's content into a revision's payload; [origin] names where
|
||||
/// an imported card came from, in its first revision.
|
||||
|
|
@ -49,6 +54,28 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
|
|||
},
|
||||
'map': ?content.map,
|
||||
if (content.source case final source?) 'source': pdfPageRefToJson(source),
|
||||
if (content.hints.isNotEmpty)
|
||||
'hints': [for (final hint in content.hints) hint.toJson()],
|
||||
if (content.explanation case final explanation?)
|
||||
'explanation': explanation.toJson(),
|
||||
if (content.report case final report?)
|
||||
'report': {
|
||||
'category': report.category.name,
|
||||
if (report.note.isNotEmpty) 'note': report.note,
|
||||
'at': report.at.toUtc().toIso8601String(),
|
||||
},
|
||||
if (content.senses.isNotEmpty)
|
||||
'senses': [
|
||||
for (final sense in content.senses)
|
||||
{
|
||||
'glosses': sense.glosses,
|
||||
'label': ?sense.label,
|
||||
if (sense.pos.isNotEmpty) 'pos': sense.pos,
|
||||
if (sense.misc.isNotEmpty) 'misc': sense.misc,
|
||||
if (sense.field.isNotEmpty) 'field': sense.field,
|
||||
if (sense.dialect.isNotEmpty) 'dialect': sense.dialect,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/// Where the card of a revision's [payload] came from: null for one written
|
||||
|
|
@ -85,6 +112,24 @@ CardContent decodeCardPayload(ByteData payload) {
|
|||
matching: _matching(json['matching']),
|
||||
map: _optional<String>(json, 'map'),
|
||||
source: pdfPageRefFromJson(json['source']),
|
||||
hints: [
|
||||
for (final hint in _list(json['hints'], 'hints'))
|
||||
switch (hint) {
|
||||
final Map<String, Object?> hint => RichContent.fromJson(hint),
|
||||
_ => throw const FormatException('A card has a bad hint.'),
|
||||
},
|
||||
],
|
||||
explanation: switch (_optional<Map<String, Object?>>(
|
||||
json,
|
||||
'explanation',
|
||||
)) {
|
||||
final explanation? => RichContent.fromJson(explanation),
|
||||
null => null,
|
||||
},
|
||||
report: _report(json['report']),
|
||||
senses: [
|
||||
for (final sense in _list(json['senses'], 'senses')) _sense(sense),
|
||||
],
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a card payload.');
|
||||
|
|
@ -147,6 +192,43 @@ Matching _matching(Object? json) => switch (json) {
|
|||
_ => throw const FormatException('A card has a bad matching.'),
|
||||
};
|
||||
|
||||
CardReport? _report(Object? json) => switch (json) {
|
||||
null => null,
|
||||
{'category': final String category, 'at': final String at} => CardReport(
|
||||
category:
|
||||
ReportCategory.values.asNameMap()[category] ?? ReportCategory.other,
|
||||
note: switch (json['note']) {
|
||||
null => '',
|
||||
final String note => note,
|
||||
_ => throw const FormatException('A card has a bad report note.'),
|
||||
},
|
||||
at:
|
||||
DateTime.tryParse(at)?.toUtc() ??
|
||||
(throw const FormatException('A card has a bad report time.')),
|
||||
),
|
||||
_ => throw const FormatException('A card has a bad report.'),
|
||||
};
|
||||
|
||||
VocabularySense _sense(Object? json) => switch (json) {
|
||||
{'glosses': final List<Object?> glosses} &&
|
||||
final Map<String, Object?> sense =>
|
||||
VocabularySense(
|
||||
glosses: _strings(glosses, 'glosses'),
|
||||
label: _optional<String>(sense, 'label'),
|
||||
pos: _strings(sense['pos'], 'pos'),
|
||||
misc: _strings(sense['misc'], 'misc'),
|
||||
field: _strings(sense['field'], 'field'),
|
||||
dialect: _strings(sense['dialect'], 'dialect'),
|
||||
),
|
||||
_ => throw const FormatException('A card has a bad sense.'),
|
||||
};
|
||||
|
||||
List<Object?> _list(Object? json, String key) => switch (json) {
|
||||
null => const [],
|
||||
final List<Object?> list => list,
|
||||
_ => throw FormatException('A card has bad $key.'),
|
||||
};
|
||||
|
||||
List<String> _strings(Object? json, String key) => switch (json) {
|
||||
null => const [],
|
||||
final List<Object?> list when list.every((each) => each is String) =>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,84 @@ enum CardField() {
|
|||
|
||||
/// The page of a PDF the card was made from.
|
||||
source,
|
||||
|
||||
/// Hints, shown on asking while studying.
|
||||
hints,
|
||||
|
||||
/// The explanation, shown with the answer.
|
||||
explanation,
|
||||
|
||||
/// A report that something on the card is wrong.
|
||||
report,
|
||||
|
||||
/// A word's senses.
|
||||
senses,
|
||||
}
|
||||
|
||||
/// What a report says is wrong with a card.
|
||||
enum ReportCategory() {
|
||||
typo,
|
||||
grammar,
|
||||
content,
|
||||
other,
|
||||
}
|
||||
|
||||
/// A card marked to be fixed: what is wrong, a note on it, and when it was
|
||||
/// marked. It is studied as before.
|
||||
final class const CardReport({
|
||||
/// What is wrong.
|
||||
required final ReportCategory category,
|
||||
|
||||
/// When it was marked, in UTC.
|
||||
required final DateTime at,
|
||||
|
||||
/// What needs fixing, in the person's words; empty without.
|
||||
final String note = '',
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [category, at, note];
|
||||
}
|
||||
|
||||
/// One sense of a word: its glosses, a label setting it apart, and its
|
||||
/// grammar as dictionaries code it (JMdict's, e.g. `v5r` for a godan verb,
|
||||
/// `uk` for a word usually written in kana).
|
||||
final class const VocabularySense({
|
||||
/// What the word means in this sense, each gloss a translation.
|
||||
required final List<String> glosses,
|
||||
|
||||
/// What sets the sense apart, e.g. "(meat)"; null without.
|
||||
final String? label,
|
||||
|
||||
/// Its parts of speech.
|
||||
final List<String> pos = const [],
|
||||
|
||||
/// Other remarks, e.g. `uk`.
|
||||
final List<String> misc = const [],
|
||||
|
||||
/// The fields it belongs to, e.g. `med`.
|
||||
final List<String> field = const [],
|
||||
|
||||
/// The dialects it is from, e.g. `ksb`.
|
||||
final List<String> dialect = const [],
|
||||
}) with Equatable {
|
||||
/// This sense without blank glosses, its glosses trimmed.
|
||||
VocabularySense get trimmed => VocabularySense(
|
||||
glosses: [
|
||||
for (final gloss in glosses)
|
||||
if (gloss.trim().isNotEmpty) gloss.trim(),
|
||||
],
|
||||
label: label,
|
||||
pos: pos,
|
||||
misc: misc,
|
||||
field: field,
|
||||
dialect: dialect,
|
||||
);
|
||||
|
||||
/// Its grammar codes, in the order shown.
|
||||
List<String> get tags => [...pos, ...misc, ...field, ...dialect];
|
||||
|
||||
@override
|
||||
List<Object?> get props => [glosses, label, pos, misc, field, dialect];
|
||||
}
|
||||
|
||||
/// One answer of a multiple-choice card.
|
||||
|
|
@ -127,11 +205,27 @@ final class const CardContent({
|
|||
|
||||
/// The page of a PDF the card was made from; kept whatever its type.
|
||||
final PdfPageRef? source,
|
||||
|
||||
/// Hints toward the answer, at most [maxHints]; any type of card.
|
||||
final List<RichContent> hints = const [],
|
||||
|
||||
/// What explains the answer, shown with it; null without.
|
||||
final RichContent? explanation,
|
||||
|
||||
/// A report that something on the card is wrong; null without.
|
||||
final CardReport? report,
|
||||
|
||||
/// A word's senses, from a dictionary; empty for a word of meanings
|
||||
/// alone. With senses, its [meanings] are their glosses.
|
||||
final List<VocabularySense> senses = const [],
|
||||
}) with Equatable implements MergeableContent<CardField, CardContent> {
|
||||
/// A front/back card with nothing on either side.
|
||||
factory empty() =>
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty());
|
||||
|
||||
/// The most hints a card has.
|
||||
static const maxHints = 6;
|
||||
|
||||
@override
|
||||
Object? operator [](CardField field) => switch (field) {
|
||||
CardField.type => type,
|
||||
|
|
@ -143,6 +237,10 @@ final class const CardContent({
|
|||
CardField.matching => matching,
|
||||
CardField.map => map,
|
||||
CardField.source => source,
|
||||
CardField.hints => hints,
|
||||
CardField.explanation => explanation,
|
||||
CardField.report => report,
|
||||
CardField.senses => senses,
|
||||
};
|
||||
|
||||
@override
|
||||
|
|
@ -156,9 +254,18 @@ final class const CardContent({
|
|||
matching: field == CardField.matching ? value! as Matching : matching,
|
||||
map: field == CardField.map ? value as String? : map,
|
||||
source: field == CardField.source ? value as PdfPageRef? : source,
|
||||
hints: field == CardField.hints ? value! as List<RichContent> : hints,
|
||||
explanation: field == CardField.explanation
|
||||
? value as RichContent?
|
||||
: explanation,
|
||||
report: field == CardField.report ? value as CardReport? : report,
|
||||
senses: field == CardField.senses
|
||||
? value! as List<VocabularySense>
|
||||
: senses,
|
||||
);
|
||||
|
||||
/// This content with some fields replaced; [withField] clears the map.
|
||||
/// This content with some fields replaced; [withField] clears the map, the
|
||||
/// explanation and the report.
|
||||
CardContent copyWith({
|
||||
CardType? type,
|
||||
RichContent? front,
|
||||
|
|
@ -169,6 +276,8 @@ final class const CardContent({
|
|||
Matching? matching,
|
||||
String? map,
|
||||
PdfPageRef? source,
|
||||
List<RichContent>? hints,
|
||||
List<VocabularySense>? senses,
|
||||
}) => CardContent(
|
||||
type: type ?? this.type,
|
||||
front: front ?? this.front,
|
||||
|
|
@ -179,10 +288,15 @@ final class const CardContent({
|
|||
matching: matching ?? this.matching,
|
||||
map: map ?? this.map,
|
||||
source: source ?? this.source,
|
||||
hints: hints ?? this.hints,
|
||||
explanation: explanation,
|
||||
report: report,
|
||||
senses: senses ?? this.senses,
|
||||
);
|
||||
|
||||
/// The fields [type] uses; the others stay empty. The page it was made
|
||||
/// from isn't one of them: every type keeps it.
|
||||
/// from, the hints, the explanation and a report aren't among them: every
|
||||
/// type keeps them.
|
||||
Set<CardField> get fields => switch (type) {
|
||||
CardType.frontBack => const {CardField.front, CardField.back},
|
||||
CardType.multipleChoice when map != null => const {
|
||||
|
|
@ -195,6 +309,7 @@ final class const CardContent({
|
|||
CardField.front,
|
||||
CardField.reading,
|
||||
CardField.meanings,
|
||||
CardField.senses,
|
||||
},
|
||||
CardType.matching => const {CardField.front, CardField.matching},
|
||||
};
|
||||
|
|
@ -202,6 +317,9 @@ final class const CardContent({
|
|||
/// This content with only the fields of its [type]; the others empty, as
|
||||
/// they are stored: a card switched to another type while being written
|
||||
/// keeps the other type's fields only until it is saved.
|
||||
///
|
||||
/// Blank hints and a blank explanation go, and a word with senses takes
|
||||
/// its meanings from their glosses.
|
||||
CardContent get normalized {
|
||||
final kept = fields;
|
||||
var content = CardContent(
|
||||
|
|
@ -209,13 +327,39 @@ final class const CardContent({
|
|||
front: RichContent.empty(),
|
||||
back: RichContent.empty(),
|
||||
source: source,
|
||||
hints: [
|
||||
for (final hint in hints)
|
||||
if (!hint.isBlank) hint,
|
||||
],
|
||||
explanation: switch (explanation) {
|
||||
final explanation? when !explanation.isBlank => explanation,
|
||||
_ => null,
|
||||
},
|
||||
report: report,
|
||||
);
|
||||
for (final field in kept) {
|
||||
content = content.withField(field, this[field]);
|
||||
}
|
||||
if (content.type == CardType.vocabulary && content.senses.isNotEmpty) {
|
||||
final senses = [
|
||||
for (final sense in content.senses)
|
||||
if (sense.trimmed case final sense when sense.glosses.isNotEmpty)
|
||||
sense,
|
||||
];
|
||||
content = content.withField(CardField.senses, senses).withField(
|
||||
CardField.meanings,
|
||||
[
|
||||
for (final sense in senses) ...sense.glosses,
|
||||
],
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/// Whether a word is usually written in kana alone, as its first sense
|
||||
/// says: its reading then shows from the start while studying.
|
||||
bool get usuallyKana => senses.firstOrNull?.misc.contains('uk') ?? false;
|
||||
|
||||
/// Whether none of its type's fields holds anything.
|
||||
bool get isBlank => switch (type) {
|
||||
CardType.frontBack => _blank(front) && _blank(back),
|
||||
|
|
@ -245,7 +389,12 @@ final class const CardContent({
|
|||
answers.every((answer) => !_blank(answer.content)) &&
|
||||
answers.any((answer) => answer.correct),
|
||||
CardType.vocabulary =>
|
||||
!_blank(front) && meanings.any((meaning) => meaning.trim().isNotEmpty),
|
||||
!_blank(front) &&
|
||||
(meanings.any((meaning) => meaning.trim().isNotEmpty) ||
|
||||
senses.any(
|
||||
(sense) =>
|
||||
sense.glosses.any((gloss) => gloss.trim().isNotEmpty),
|
||||
)),
|
||||
// Two of each at least, every item in a target, and no target left
|
||||
// empty: an item in none can't be placed right, an empty target is dead.
|
||||
CardType.matching =>
|
||||
|
|
@ -290,6 +439,10 @@ final class const CardContent({
|
|||
matching,
|
||||
map,
|
||||
source,
|
||||
hints,
|
||||
explanation,
|
||||
report,
|
||||
senses,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A card's [hints] as a pill to open them, one at a time; nothing without.
|
||||
class const HintsButton({required final List<RichContent> hints, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => hints.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: _Pill(
|
||||
label: AppLocalizations.of(context).cardHints,
|
||||
onPressed: () => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _HintsSheet(hints: hints),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A card's [explanation] as a pill to open it; nothing without.
|
||||
class const ExplanationButton({
|
||||
required final RichContent? explanation,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return switch (explanation) {
|
||||
final explanation? => _Pill(
|
||||
label: l10n.cardExplanation,
|
||||
onPressed: () => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: l10n.cardExplanation,
|
||||
body: RichContentView(content: explanation),
|
||||
),
|
||||
),
|
||||
),
|
||||
null => const SizedBox.shrink(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A light bulb and [label] on an outlined pill, in muted colours.
|
||||
class const _Pill({
|
||||
required final String label,
|
||||
required final VoidCallback onPressed,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(LucideIcons.lightbulb, size: 16),
|
||||
label: Text(label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colors.onSurfaceVariant,
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hints one at a time, the first first; with several, a way back and
|
||||
/// on below, between them which of how many shows.
|
||||
class const _HintsSheet({required final List<RichContent> hints})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_HintsSheet> createState() => _HintsSheetState();
|
||||
}
|
||||
|
||||
class _HintsSheetState() extends State<_HintsSheet> {
|
||||
var _index = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final count = widget.hints.length;
|
||||
return SheetScaffold(
|
||||
title: l10n.cardHintsSheetTitle(count),
|
||||
footer: count > 1
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.cardPreviousHint,
|
||||
onPressed: _index == 0
|
||||
? null
|
||||
: () => setState(() => _index--),
|
||||
icon: const Icon(LucideIcons.chevronLeft),
|
||||
),
|
||||
Text(
|
||||
'${_index + 1} / $count',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.cardNextHint,
|
||||
onPressed: _index == count - 1
|
||||
? null
|
||||
: () => setState(() => _index++),
|
||||
icon: const Icon(LucideIcons.chevronRight),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: RichContentView(
|
||||
key: ValueKey(_index),
|
||||
content: widget.hints[_index],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
|
|
@ -139,6 +140,64 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
void _send(CardComposerEvent event) =>
|
||||
context.read<CardComposerBloc>().add(event);
|
||||
|
||||
/// The hint added last, to open as an editor.
|
||||
int? _newHint;
|
||||
|
||||
/// Whether the explanation was just added, to open as an editor.
|
||||
var _newExplanation = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Ahead of the editors, which would take the keys for themselves.
|
||||
HardwareKeyboard.instance.addHandler(_onKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_onKey);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+H adds a hint, Ctrl+Alt+E the explanation, while this card is
|
||||
/// the one written and nothing covers it.
|
||||
bool _onKey(KeyEvent event) {
|
||||
final keyboard = HardwareKeyboard.instance;
|
||||
if (event is! KeyDownEvent ||
|
||||
!keyboard.isControlPressed ||
|
||||
!keyboard.isAltPressed ||
|
||||
!mounted ||
|
||||
!(ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
return false;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.keyH) {
|
||||
_addHint();
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.keyE) {
|
||||
_addExplanation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _addHint() {
|
||||
final hints = _sent.hints;
|
||||
if (hints.length >= CardContent.maxHints) {
|
||||
return;
|
||||
}
|
||||
setState(() => _newHint = hints.length);
|
||||
_change(_sent.copyWith(hints: [...hints, RichContent.empty()]));
|
||||
}
|
||||
|
||||
void _addExplanation() {
|
||||
if (_sent.explanation != null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _newExplanation = true);
|
||||
_change(_sent.withField(CardField.explanation, RichContent.empty()));
|
||||
}
|
||||
|
||||
Key _key(String field) => ValueKey('${widget.draft.id}-$field-$_revision');
|
||||
|
||||
@override
|
||||
|
|
@ -191,6 +250,12 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
_Actions(
|
||||
onAddHint: content.hints.length < CardContent.maxHints
|
||||
? _addHint
|
||||
: null,
|
||||
onAddExplanation: content.explanation == null
|
||||
? _addExplanation
|
||||
: null,
|
||||
onDuplicate: widget.onDuplicate,
|
||||
onClear: () => _send(CardComposerEventCleared(draft.id)),
|
||||
onDelete: widget.onDelete,
|
||||
|
|
@ -252,6 +317,31 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
startExpanded: widget.focusFront,
|
||||
onChanged: (front) => _change(_sent.copyWith(front: front)),
|
||||
),
|
||||
if (content.hints.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
_Hints(
|
||||
hints: content.hints,
|
||||
fieldKey: (index) => _key('hint-$index'),
|
||||
opened: _newHint,
|
||||
onChanged: (hints) =>
|
||||
_change(_sent.copyWith(hints: hints)),
|
||||
onRemove: (index) {
|
||||
// The fields are by place: they start afresh.
|
||||
setState(() {
|
||||
_revision++;
|
||||
_newHint = null;
|
||||
});
|
||||
_change(
|
||||
_sent.copyWith(
|
||||
hints: [..._sent.hints]..removeAt(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAdd: content.hints.length < CardContent.maxHints
|
||||
? _addHint
|
||||
: null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 24),
|
||||
|
|
@ -303,6 +393,38 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
),
|
||||
),
|
||||
},
|
||||
if (content.explanation case final explanation?) ...[
|
||||
const SizedBox(height: 24),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.cardExplanation,
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.cardRemoveExplanation,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
onPressed: () => _change(
|
||||
_sent.withField(CardField.explanation, null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CardFace(
|
||||
key: _key('explanation'),
|
||||
content: explanation,
|
||||
placeholder: l10n.cardAddExplanation,
|
||||
startExpanded: _newExplanation,
|
||||
onChanged: (explanation) => _change(
|
||||
_sent.withField(CardField.explanation, explanation),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -384,8 +506,11 @@ class const _MapAnswer({
|
|||
}
|
||||
}
|
||||
|
||||
/// The card's actions: duplicate, clear its fields, delete.
|
||||
/// The card's actions: add a hint or the explanation (null while it can't),
|
||||
/// duplicate, clear its fields, delete.
|
||||
class const _Actions({
|
||||
required final VoidCallback? onAddHint,
|
||||
required final VoidCallback? onAddExplanation,
|
||||
required final VoidCallback onDuplicate,
|
||||
required final VoidCallback onClear,
|
||||
required final VoidCallback onDelete,
|
||||
|
|
@ -395,6 +520,17 @@ class const _Actions({
|
|||
final l10n = AppLocalizations.of(context);
|
||||
return MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.lightbulb, size: 18),
|
||||
onPressed: onAddHint,
|
||||
child: Text(l10n.cardAddHint),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.bookOpen, size: 18),
|
||||
onPressed: onAddExplanation,
|
||||
child: Text(l10n.cardAddExplanation),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.copy, size: 18),
|
||||
onPressed: onDuplicate,
|
||||
|
|
@ -495,6 +631,10 @@ class const _Conflicts({required final CardDraft draft})
|
|||
CardField.matching => l10n.composerKindMatching,
|
||||
CardField.map => l10n.composerKindMap,
|
||||
CardField.source => l10n.pdfPageField,
|
||||
CardField.hints => l10n.cardHints,
|
||||
CardField.explanation => l10n.cardExplanation,
|
||||
CardField.report => l10n.cardReport,
|
||||
CardField.senses => l10n.cardSenses,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -510,6 +650,22 @@ class const _Version({required final Object? version}) extends StatelessWidget {
|
|||
].join('\n'),
|
||||
),
|
||||
final List<String> meanings => Text(meanings.join('; ')),
|
||||
final List<RichContent> hints => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [for (final hint in hints) RichContentView(content: hint)],
|
||||
),
|
||||
final List<VocabularySense> senses => Text(
|
||||
[
|
||||
for (final (i, sense) in senses.indexed)
|
||||
'${i + 1}. ${sense.glosses.join('; ')}',
|
||||
].join('\n'),
|
||||
),
|
||||
final CardReport report => Text(
|
||||
[
|
||||
report.category.name,
|
||||
report.note,
|
||||
].where((part) => part.isNotEmpty).join(': '),
|
||||
),
|
||||
final Matching matching => Text(
|
||||
[
|
||||
for (final (t, target) in matching.targets.indexed)
|
||||
|
|
@ -526,3 +682,65 @@ class const _Version({required final Object? version}) extends StatelessWidget {
|
|||
_ => Text('${version ?? ''}'),
|
||||
};
|
||||
}
|
||||
|
||||
/// A card's hints, numbered, each to write and to remove, and a way to add
|
||||
/// one more while there may be.
|
||||
class const _Hints({
|
||||
required final List<RichContent> hints,
|
||||
required final Key Function(int index) fieldKey,
|
||||
required final int? opened,
|
||||
required final ValueChanged<List<RichContent>> onChanged,
|
||||
required final ValueChanged<int> onRemove,
|
||||
required final VoidCallback? onAdd,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.cardHints, style: theme.textTheme.labelLarge),
|
||||
for (final (index, hint) in hints.indexed) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.cardHint(index + 1),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.cardRemoveHint,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
onPressed: () => onRemove(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
CardFace(
|
||||
key: fieldKey(index),
|
||||
content: hint,
|
||||
placeholder: l10n.cardAddHint,
|
||||
startExpanded: index == opened,
|
||||
onChanged: (changed) => onChanged([...hints]..[index] = changed),
|
||||
),
|
||||
],
|
||||
if (onAdd case final onAdd?)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: Text(l10n.cardAddHint),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.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/widget/card_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||
|
|
@ -160,10 +161,20 @@ class const StudyCardHeader({
|
|||
}
|
||||
}
|
||||
|
||||
/// The hints of a card, under its question: 20 dp below it, centred.
|
||||
List<Widget> _hintsPart(Card card) => [
|
||||
if (card.content.hints.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Center(child: HintsButton(hints: card.content.hints)),
|
||||
],
|
||||
];
|
||||
|
||||
/// The answer of a card, once shown: a line with 24 dp on either side, then
|
||||
/// [answer]; until then, "Show answer" 40 dp below the question.
|
||||
/// [answer], and 20 dp below it the card's explanation, if any; until then,
|
||||
/// "Show answer" 40 dp below the question.
|
||||
List<Widget> _answerPart(
|
||||
BuildContext context, {
|
||||
required Card card,
|
||||
required bool shown,
|
||||
required Widget answer,
|
||||
required VoidCallback onShow,
|
||||
|
|
@ -173,6 +184,10 @@ List<Widget> _answerPart(
|
|||
const Divider(),
|
||||
const SizedBox(height: 24),
|
||||
answer,
|
||||
if (card.content.explanation case final explanation?) ...[
|
||||
const SizedBox(height: 20),
|
||||
Center(child: ExplanationButton(explanation: explanation)),
|
||||
],
|
||||
]
|
||||
: [
|
||||
const SizedBox(height: 40),
|
||||
|
|
@ -218,8 +233,10 @@ class const FrontBackBody({
|
|||
textStyle: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
..._hintsPart(card),
|
||||
..._answerPart(
|
||||
context,
|
||||
card: card,
|
||||
shown: answerShown,
|
||||
onShow: onShow,
|
||||
answer: RichContentView(
|
||||
|
|
@ -267,8 +284,10 @@ class const VocabularyBody({
|
|||
else
|
||||
// The reading is part of what is recalled.
|
||||
expression(theme.textTheme.headlineMedium, reading: answerShown),
|
||||
..._hintsPart(card),
|
||||
..._answerPart(
|
||||
context,
|
||||
card: card,
|
||||
shown: answerShown,
|
||||
onShow: onShow,
|
||||
answer: asksBack
|
||||
|
|
@ -451,6 +470,10 @@ class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
|
|||
textStyle: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.card.content.hints.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Center(child: HintsButton(hints: widget.card.content.hints)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_several ? l10n.studySelectAll : l10n.studySelectOne,
|
||||
|
|
@ -479,6 +502,12 @@ class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (widget.checked)
|
||||
if (widget.card.content.explanation
|
||||
case final explanation?) ...[
|
||||
const SizedBox(height: 12),
|
||||
Center(child: ExplanationButton(explanation: explanation)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -669,6 +698,12 @@ class _MatchingBodyState() extends State<MatchingBody> {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (widget.card.content.hints.isNotEmpty) ...[
|
||||
Center(
|
||||
child: HintsButton(hints: widget.card.content.hints),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
for (final (t, target) in matching.targets.indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
|
|
@ -770,6 +805,14 @@ class _MatchingBodyState() extends State<MatchingBody> {
|
|||
runSpacing: 8,
|
||||
children: [for (final i in loose) item(i)],
|
||||
),
|
||||
if (widget.checked)
|
||||
if (widget.card.content.explanation
|
||||
case final explanation?) ...[
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: ExplanationButton(explanation: explanation),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -895,6 +938,10 @@ class _MapBodyState() extends State<MapBody> {
|
|||
textStyle: theme.textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (content.hints.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Center(child: HintsButton(hints: content.hints)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: MapOf(
|
||||
|
|
@ -924,6 +971,15 @@ class _MapBodyState() extends State<MapBody> {
|
|||
right: right,
|
||||
checked: widget.checked,
|
||||
),
|
||||
if (widget.checked)
|
||||
if (content.explanation case final explanation?) ...[
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: ExplanationButton(
|
||||
explanation: explanation,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -139,6 +139,52 @@ void main() {
|
|||
expect(content.normalized.source, (pdfId: 'grammar-pdf', page: 12));
|
||||
});
|
||||
|
||||
test('hints, an explanation and a report go with any type of card, and '
|
||||
'a word keeps its senses', () {
|
||||
final content = CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: text('食べる'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'たべる',
|
||||
meanings: const ['to eat', 'to live on'],
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['to eat'], pos: ['v1', 'vt']),
|
||||
VocabularySense(
|
||||
glosses: ['to live on'],
|
||||
label: 'figurative',
|
||||
misc: ['uk'],
|
||||
field: ['food'],
|
||||
dialect: ['ksb'],
|
||||
),
|
||||
],
|
||||
hints: [text('a verb'), text('ichidan')],
|
||||
explanation: text('食 means food.'),
|
||||
report: CardReport(
|
||||
category: ReportCategory.typo,
|
||||
note: 'Reading is off',
|
||||
at: DateTime.utc(2026, 10, 7, 12),
|
||||
),
|
||||
);
|
||||
|
||||
expect(cardSchemaVersion, 5);
|
||||
expect(decodeCardPayload(encodeCardPayload(content)), content);
|
||||
});
|
||||
|
||||
test('reads a report of a category it does not know as "other"', () {
|
||||
final content = decodeCardPayload(
|
||||
bytes({
|
||||
'schemaVersion': 5,
|
||||
'type': 'frontBack',
|
||||
'front': text('猫').toJson(),
|
||||
'back': text('cat').toJson(),
|
||||
'report': {'category': 'offensive', 'at': '2026-10-07T12:00:00Z'},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(content.report?.category, ReportCategory.other);
|
||||
expect(content.report?.note, '');
|
||||
});
|
||||
|
||||
test('refuses a type this app does not know', () {
|
||||
expect(
|
||||
() => decodeCardPayload(
|
||||
|
|
|
|||
|
|
@ -138,6 +138,50 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('normalized drops blank hints and a blank explanation, and takes a '
|
||||
"word's meanings from its senses", () {
|
||||
final content = CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: text('食べる'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['stale'],
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['to eat', ' ']),
|
||||
VocabularySense(glosses: ['to live on']),
|
||||
],
|
||||
hints: [text('a verb'), RichContent.empty()],
|
||||
explanation: RichContent.empty(),
|
||||
).normalized;
|
||||
|
||||
expect(content.hints, [text('a verb')]);
|
||||
expect(content.explanation, isNull);
|
||||
expect(content.meanings, ['to eat', 'to live on']);
|
||||
expect(content.senses.first.glosses, ['to eat']);
|
||||
});
|
||||
|
||||
test('a word is usually written in kana when its first sense says so', () {
|
||||
CardContent word(List<VocabularySense> senses) => CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: text('蛙'),
|
||||
back: RichContent.empty(),
|
||||
senses: senses,
|
||||
);
|
||||
|
||||
expect(
|
||||
word(const [
|
||||
VocabularySense(glosses: ['frog'], misc: ['uk']),
|
||||
]).usuallyKana,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
word(const [
|
||||
VocabularySense(glosses: ['frog']),
|
||||
VocabularySense(glosses: ['x'], misc: ['uk']),
|
||||
]).usuallyKana,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalized keeps only the fields of its type', () {
|
||||
final switched = CardContent(
|
||||
type: CardType.vocabulary,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,46 @@ void main() {
|
|||
expect(find.text('All changes saved'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hints and an explanation are written beside the card: '
|
||||
'removed one by one, added, kept unless blank', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: paragraph('食べる'),
|
||||
back: paragraph('to eat'),
|
||||
hints: [paragraph('a verb'), paragraph('ichidan')],
|
||||
explanation: paragraph('食 is food'),
|
||||
),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '食べる');
|
||||
expect(find.text('Hint 2'), findsOneWidget);
|
||||
expect(find.text('Explanation'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Remove hint').first);
|
||||
await settle(tester);
|
||||
expect(find.text('Hint 2'), findsNothing);
|
||||
expect(
|
||||
find.textContaining('ichidan', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byTooltip('Remove explanation'));
|
||||
await settle(tester);
|
||||
expect(find.text('Explanation'), findsNothing);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Add hint'));
|
||||
await settle(tester);
|
||||
expect(find.text('Hint 2'), findsOneWidget);
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).first.content;
|
||||
expect(saved.hints, [paragraph('ichidan')]);
|
||||
expect(saved.explanation, isNull);
|
||||
});
|
||||
|
||||
testWidgets('New flashcard opens the new card, also while the changed one '
|
||||
'it leaves is being saved', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
|
|
|
|||
|
|
@ -257,6 +257,41 @@ void main() {
|
|||
expect(await cards.cardsOf('japanese'), hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('hints open from under the question, one at a time; the '
|
||||
'explanation comes with the answer', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
front: paragraph('食べる'),
|
||||
back: paragraph('to eat'),
|
||||
hints: [paragraph('a verb'), paragraph('ichidan')],
|
||||
explanation: paragraph('食 is food'),
|
||||
),
|
||||
);
|
||||
await study(tester);
|
||||
expect(find.text('Explanation'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Hints'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Hints 2'), findsOneWidget);
|
||||
expect(find.text('1 / 2'), findsOneWidget);
|
||||
expect(find.textContaining('a verb', findRichText: true), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Next hint'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('ichidan', findRichText: true), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Show answer'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Explanation'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining('食 is food', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('says when there is nothing to study', (tester) async {
|
||||
await study(tester);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue