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:
Tizian 「ティツィアーン」 2026-10-07 21:24:41 +02:00
commit 9edb23a379
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 991 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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