feat(pdf): cards beside a PDF are written in the composer's own editor

The sidebar wrote a front and a back only. It now holds the composer's
editor with a bloc of its own: every kind of card (multiple choice,
vocabulary with Look up, matching, a map, a character), tags, hints and
the explanation, pinning, duplicating and deleting. A new card is tied
to the page shown, which it can be moved to the current one, and waits
for its chapter's tag by name until it is created; text sent from the
pages goes into its front or its answer, as its kind keeps one. Create
saves it once it is complete, as the composer does; going back asks
before discarding what was written.

The screenshot of the pages has a button at the end of the format bar,
outside its scrolling, where an editor's place is for it
(InsertableBlock.inFormatBar); the Insert menu still lists it.

A new card no longer shows twice in the composer when its stored copy
arrives while it is saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 23:08:33 +02:00
commit fcee3c2670
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 714 additions and 372 deletions

View file

@ -135,6 +135,41 @@ final class RichContent(
yield* visit(editorJson['document']);
}
/// This content with [text] after it, a paragraph per line: as text
/// taken from a PDF joins what is written. An empty last line takes the
/// text rather than staying above it.
RichContent withParagraphs(String text) {
final document =
editorJson['document'] as Map<String, Object?>? ??
const {'type': 'page'};
final children = [...?document['children'] as List<Object?>?];
if (children.lastOrNull
case {
'type': 'paragraph',
'data': {'delta': final List<Object?> delta},
}
when delta.isEmpty) {
children.removeLast();
}
return RichContent({
'document': {
...document,
'children': [
...children,
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
}, formatVersion: formatVersion);
}
/// The JSON stored in the content payload.
Map<String, Object?> toJson() => {
'formatVersion': formatVersion,

View file

@ -43,6 +43,10 @@ final class const InsertableBlock({
/// Whether it may go inside a callout; else it goes after the callout.
final bool insideCallouts = true,
/// Whether the format bar has a button of its own for it too, at its
/// end: for what the editor's place is for (a screenshot beside a PDF).
final bool inFormatBar = false,
});
/// The blocks an editor knows: how each type is built, which of them

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
@ -12,7 +13,9 @@ import 'package:material_ui/material_ui.dart';
/// Formats the selection of [editorState]: alignment, bold, italic,
/// underline, a link, superscript and subscript, furigana, and with [onFormula] formulas
/// within the line. Each button shows whether the selection has its format
/// already. With [onInsert], an Insert button comes first, for blocks.
/// already. With [onInsert], an Insert button comes first, for blocks; the
/// [pinned] blocks have buttons at the end, outside the scrolling, so they
/// are always at hand.
///
/// The bar takes no focus, so tapping a button leaves the editor focused and
/// its selection where it was; on phones, where there are no shortcut keys,
@ -25,6 +28,12 @@ class const FormatBar({
/// Called by the formula button (Σ), which shows only with it.
final VoidCallback? onFormula,
/// Blocks with a button of their own at the end.
final List<InsertableBlock> pinned = const [],
/// Called with the pinned block whose button was pressed.
final ValueChanged<InsertableBlock>? onPinned,
super.key,
}) extends StatefulWidget {
/// How tall the bar is, so what docks it can make room for it.
@ -120,102 +129,118 @@ class _FormatBarState() extends State<FormatBar> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final editorState = widget.editorState;
final formats = SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
if (widget.onInsert case final onInsert?) ...[
_FormatButton(
icon: LucideIcons.plus,
tooltip: l10n.editorInsert,
active: false,
onPressed: onInsert,
),
const _Divider(),
],
_FormatButton(
icon: LucideIcons.textAlignStart,
tooltip: l10n.formatAlignLeft,
active: _isAligned('left'),
onPressed: () => _align('left'),
),
_FormatButton(
icon: LucideIcons.textAlignCenter,
tooltip: l10n.formatAlignCenter,
active: _isAligned('center'),
onPressed: () => _align('center'),
),
_FormatButton(
icon: LucideIcons.textAlignEnd,
tooltip: l10n.formatAlignRight,
active: _isAligned('right'),
onPressed: () => _align('right'),
),
const _Divider(),
_FormatButton(
icon: LucideIcons.bold,
tooltip: l10n.formatBold,
active: _has(AppFlowyRichTextKeys.bold),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: l10n.formatItalic,
active: _has(AppFlowyRichTextKeys.italic),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
),
_FormatButton(
icon: LucideIcons.underline,
tooltip: l10n.formatUnderline,
active: _has(AppFlowyRichTextKeys.underline),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
),
_FormatButton(
icon: LucideIcons.link,
tooltip: l10n.formatLink,
active: hasLink(editorState),
onPressed: () => editLink(context, editorState),
),
const _Divider(),
_FormatButton(
icon: LucideIcons.superscript,
tooltip: l10n.formatSuperscript,
active: _has(superscriptKey),
onPressed: () => toggleScript(editorState, superscriptKey),
),
_FormatButton(
icon: LucideIcons.subscript,
tooltip: l10n.formatSubscript,
active: _has(subscriptKey),
onPressed: () => toggleScript(editorState, subscriptKey),
),
_FormatButton(
icon: LucideIcons.languages,
tooltip: l10n.formatRuby,
active:
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(
rubyKey,
) !=
null,
onPressed: () => editRuby(context, editorState),
),
if (widget.onFormula case final onFormula?)
_FormatButton(
icon: LucideIcons.sigma,
tooltip: l10n.formatInlineMath,
active: _has(inlineMathKey),
onPressed: onFormula,
),
],
),
);
return ExcludeFocus(
child: SizedBox(
height: FormatBar.height,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
if (widget.onInsert case final onInsert?) ...[
child: Row(
children: [
Expanded(child: formats),
if (widget.pinned.isNotEmpty) ...[
const _Divider(),
for (final block in widget.pinned)
_FormatButton(
icon: LucideIcons.plus,
tooltip: l10n.editorInsert,
icon: block.icon,
tooltip: block.name(l10n),
active: false,
onPressed: onInsert,
),
const _Divider(),
],
_FormatButton(
icon: LucideIcons.textAlignStart,
tooltip: l10n.formatAlignLeft,
active: _isAligned('left'),
onPressed: () => _align('left'),
),
_FormatButton(
icon: LucideIcons.textAlignCenter,
tooltip: l10n.formatAlignCenter,
active: _isAligned('center'),
onPressed: () => _align('center'),
),
_FormatButton(
icon: LucideIcons.textAlignEnd,
tooltip: l10n.formatAlignRight,
active: _isAligned('right'),
onPressed: () => _align('right'),
),
const _Divider(),
_FormatButton(
icon: LucideIcons.bold,
tooltip: l10n.formatBold,
active: _has(AppFlowyRichTextKeys.bold),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.bold),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: l10n.formatItalic,
active: _has(AppFlowyRichTextKeys.italic),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.italic),
),
_FormatButton(
icon: LucideIcons.underline,
tooltip: l10n.formatUnderline,
active: _has(AppFlowyRichTextKeys.underline),
onPressed: () =>
editorState.toggleAttribute(AppFlowyRichTextKeys.underline),
),
_FormatButton(
icon: LucideIcons.link,
tooltip: l10n.formatLink,
active: hasLink(editorState),
onPressed: () => editLink(context, editorState),
),
const _Divider(),
_FormatButton(
icon: LucideIcons.superscript,
tooltip: l10n.formatSuperscript,
active: _has(superscriptKey),
onPressed: () => toggleScript(editorState, superscriptKey),
),
_FormatButton(
icon: LucideIcons.subscript,
tooltip: l10n.formatSubscript,
active: _has(subscriptKey),
onPressed: () => toggleScript(editorState, subscriptKey),
),
_FormatButton(
icon: LucideIcons.languages,
tooltip: l10n.formatRuby,
active:
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(
rubyKey,
) !=
null,
onPressed: () => editRuby(context, editorState),
),
if (widget.onFormula case final onFormula?)
_FormatButton(
icon: LucideIcons.sigma,
tooltip: l10n.formatInlineMath,
active: _has(inlineMathKey),
onPressed: onFormula,
onPressed: () => widget.onPinned?.call(block),
),
const SizedBox(width: 4),
],
),
],
),
),
);

View file

@ -126,6 +126,12 @@ class _RichContentEditorState() extends State<RichContentEditor> {
if (!mounted) {
return;
}
await _insertBlock(block, at);
}
/// Inserts [block], if any, at [at], and gives the editor its caret and
/// focus back.
Future<void> _insertBlock(InsertableBlock? block, Selection? at) async {
if (block != null) {
await block.insert(context, _editorState, at);
}
@ -228,6 +234,12 @@ class _RichContentEditorState() extends State<RichContentEditor> {
editorState: _editorState,
onInsert: _insert,
onFormula: _formula,
pinned: [
for (final block in blocksOf(context).insertable)
if (block.inFormatBar) block,
],
onPinned: (block) =>
unawaited(_insertBlock(block, _editorState.selection)),
),
],
],

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.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';
@ -38,6 +39,19 @@ class CardComposerBloc(
),
);
on<CardComposerEventLookedUp>(_onLookedUp);
on<CardComposerEventTextAdded>(
(event, emit) => _update(
emit,
event.id,
(draft) => draft.copyWith(
content: draft.content.withTextAdded(
event.text,
toAnswer: event.toAnswer,
),
),
),
);
on<CardComposerEventDiscarded>(_onDiscarded);
on<CardComposerEventVersionChosen>(
(event, emit) => _update(
emit,
@ -199,7 +213,7 @@ class CardComposerBloc(
final previous = state.byId(event.after);
var content = CardContent.empty();
var tagIds = <String>{};
var newTagNames = const <String>[];
var newTagNames = <String>[];
if (previous != null) {
final from = previous.content;
content = content.withField(CardField.type, from.type);
@ -220,20 +234,31 @@ class CardComposerBloc(
}
if (state.pins.contains(CardPin.tags)) {
tagIds = {...previous.tagIds};
newTagNames = previous.newTagNames;
newTagNames = [...previous.newTagNames];
}
}
if (event.source case final source?) {
content = content.copyWith(source: source);
}
for (final name in event.tagNames) {
if (!newTagNames.contains(name)) {
newTagNames.add(name);
}
}
emit(
state.copyWith(
drafts: [
_withStatus(
CardDraft(
id: event.id,
content: content,
saved: CardContent.empty(),
tagIds: tagIds,
savedTagIds: const {},
newTagNames: newTagNames,
_withExisting(
CardDraft(
id: event.id,
content: content,
saved: CardContent.empty(),
tagIds: tagIds,
savedTagIds: const {},
newTagNames: newTagNames,
),
state.tagging,
),
),
...state.drafts,
@ -291,6 +316,38 @@ class CardComposerBloc(
);
});
/// A new card goes; a stored one is as stored again, its tags too.
void _onDiscarded(
CardComposerEventDiscarded event,
Emitter<CardComposerState> emit,
) {
final draft = state.byId(event.id);
if (draft == null || draft.status == CardDraftStatus.saving) {
return;
}
if (draft.cardId == null) {
emit(
state.copyWith(
drafts: [
for (final each in state.drafts)
if (each.id != draft.id) each,
],
),
);
return;
}
_update(
emit,
draft.id,
(d) => d.copyWith(
content: d.saved,
tagIds: d.savedTagIds,
newTagNames: const [],
resolved: const {},
),
);
}
Future<void> _onLeft(
CardComposerEventLeft event,
Emitter<CardComposerState> emit,
@ -326,6 +383,16 @@ class CardComposerBloc(
if (!_sameSet(tagIds, draft.savedTagIds)) {
await _tags.setTagsOf(cardId, tagIds);
}
// The stored card may have come in while it was saved: the draft
// holds it now.
emit(
state.copyWith(
existing: [
for (final each in state.existing)
if (each.cardId != cardId || each.id == draft.id) each,
],
),
);
_update(emit, draft.id, (d) {
// Tags made for names it still waits for are its own now.
final waiting = [
@ -688,9 +755,42 @@ final class const CardComposerEventDraftAdded(
/// The draft the person was on, whose type and pinned fields it takes.
final String? after,
/// The page of a PDF it is written from.
final PdfPageRef? source,
/// Tags it is to have by name, made if need be once it is saved: the
/// chapter of a PDF it is written from.
final List<String> tagNames = const [],
}) extends CardComposerEvent {
@override
List<Object?> get props => [id, after];
List<Object?> get props => [id, after, source, tagNames];
}
/// Text taken from elsewhere (a PDF's selection) goes after the card
/// [id]'s front, or after its answer ([CardContent.withTextAdded]).
final class const CardComposerEventTextAdded(
/// The draft's id.
final String id, {
/// The text.
required final String text,
/// Whether it goes to the answer rather than the front.
required final bool toAnswer,
}) extends CardComposerEvent {
@override
List<Object?> get props => [id, text, toAnswer];
}
/// Leave the card [id] unsaved: a new one goes, a stored one is as stored
/// again.
final class const CardComposerEventDiscarded(
/// The draft's id.
final String id,
) extends CardComposerEvent {
@override
List<Object?> get props => [id];
}
/// The person changed the card [id] to [content].

View file

@ -445,6 +445,41 @@ final class const CardContent({
/// says: its reading then shows from the start while studying.
bool get usuallyKana => senses.firstOrNull?.misc.contains('uk') ?? false;
/// This content with [text] taken from elsewhere (a PDF's selection)
/// after its front, or after its answer as its type has one: a
/// front/back card's back, a multiple-choice card's first right answer, a
/// word's or a character's meanings as one more (a word with senses
/// takes its meanings from them). A map question's answer is a region,
/// and matching has no one answer: they take none.
CardContent withTextAdded(String text, {required bool toAnswer}) {
if (!toAnswer) {
return copyWith(front: front.withParagraphs(text));
}
final line = text.replaceAll(RegExp(r'\s*\n\s*'), ' ').trim();
return switch (type) {
CardType.frontBack => copyWith(back: back.withParagraphs(text)),
CardType.multipleChoice when map == null => switch (answers.indexWhere(
(answer) => answer.correct,
)) {
-1 => this,
final right => copyWith(
answers: [...answers]
..[right] = answers[right].copyWith(
content: answers[right].content.withParagraphs(text),
),
),
},
CardType.vocabulary || CardType.character when senses.isEmpty => copyWith(
meanings: [
for (final meaning in meanings)
if (meaning.trim().isNotEmpty) meaning,
line,
],
),
_ => this,
};
}
/// Whether none of its type's fields holds anything.
bool get isBlank => switch (type) {
CardType.frontBack => _blank(front) && _blank(back),

View file

@ -132,6 +132,9 @@ class const CardDraftEditor({
/// Suggests the card as it is to the author of the published studyset it
/// came from; null for a card of the person's own.
final VoidCallback? onSuggest,
/// Around it all: less in a narrow place, beside a PDF.
final EdgeInsetsGeometry padding = const EdgeInsets.all(24),
super.key,
}) extends StatefulWidget {
@override
@ -292,7 +295,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
CardKind.character => l10n.composerLabelCharacter,
};
return Padding(
padding: const EdgeInsets.all(24),
padding: widget.padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [

View file

@ -49,6 +49,7 @@ EditorBlocks screenshotBlocks(PdfCapture capture, String pdfId) => EditorBlocks(
icon: LucideIcons.crop,
group: InsertGroup.media,
keywords: const ['screenshot', 'capture', 'clip', 'pdf', 'image'],
inFormatBar: true,
insert: (context, editorState, at) async {
final shot = await capture.request();
if (shot == null) {

View file

@ -1,20 +1,24 @@
import 'dart:async';
import 'dart:developer';
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';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import 'package:material_ui/material_ui.dart';
/// Where selected text of a PDF goes.
enum PdfClipTarget() {
@ -52,42 +56,6 @@ String clippedText(String text, {required bool keepLineBreaks}) =>
.replaceAll(RegExp(r'\s*\r?\n\s*'), ' ')
.trim();
/// [content] with [text] added as a paragraph of its own at the end.
RichContent withParagraph(RichContent content, String text) {
final document =
content.editorJson['document'] as Map<String, Object?>? ??
const {'type': 'page'};
final children = [
...?document['children'] as List<Object?>?,
];
// An empty last line takes the text rather than staying above it.
if (children.lastOrNull
case {
'type': 'paragraph',
'data': {'delta': final List<Object?> delta},
}
when delta.isEmpty) {
children.removeLast();
}
return RichContent({
'document': {
...document,
'children': [
...children,
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
});
}
/// The panels beside a PDF's pages: its flashcards, and the note of the page
/// shown. Text sent through [clips] goes where it was sent, the panel
/// showing.
@ -161,18 +129,11 @@ class _PdfReaderPanelsState()
}
}
/// A card being written in the panel: a new one, or `card` changed.
typedef _Draft = ({
Card? card,
RichContent front,
RichContent back,
int page,
int version,
});
/// The PDF's flashcards, by page or by chapter; a tap on one shows its page.
/// "Create flashcard" writes one, tied to the page shown and tagged with its
/// chapter.
/// "Create flashcard" writes one in the composer's own editor, every kind
/// of card with its tags, hints and explanation, tied to the page shown and
/// tagged with its chapter; text sent from the pages goes into the card
/// being written, or a new one.
class const _CardsPanel({
required final PdfSource source,
required final PdfReaderController controller,
@ -186,11 +147,18 @@ class const _CardsPanel({
class _CardsPanelState()
extends State<_CardsPanel>
with AutomaticKeepAliveClientMixin {
_Draft? _draft;
late final CardComposerBloc _bloc;
/// The card being written, by its draft's id; null while the list shows.
String? _open;
/// The card whose saving is awaited, to go on once it is saved, and
/// whether it is a new one.
({String id, bool isNew})? _saving;
var _nextDraft = 0;
var _focusFront = false;
var _keepOpen = false;
var _byChapter = false;
StreamSubscription<List<Card>>? _cards;
var _all = <Card>[];
@override
bool get wantKeepAlive => true;
@ -198,128 +166,172 @@ class _CardsPanelState()
@override
void initState() {
super.initState();
final person = PersonScope.of(context);
_bloc = CardComposerBloc(
person.cards,
person.tags,
widget.source.studysetId,
)..add(const CardComposerEventStarted());
widget.clips.addListener(_onClip);
_cards =
PersonScope.of(
context,
).cards
.watch(widget.source.studysetId)
.listen(
(cards) => setState(
() => _all = [
for (final card in cards)
if (card.content.source?.pdfId == widget.source.id) card,
],
),
);
}
@override
void dispose() {
widget.clips.removeListener(_onClip);
unawaited(_cards?.cancel());
unawaited(_bloc.close());
super.dispose();
}
_Draft _newDraft() => (
card: null,
front: RichContent.empty(),
back: RichContent.empty(),
page: widget.controller.page,
version: 0,
);
/// Opens a new card, tied to the page shown and tagged with its chapter
/// (a chapter whose title is blank names no tag), after the one written
/// before, whose kind it takes.
String _add({required bool focus}) {
final page = widget.controller.page;
final id = 'pdf-${_nextDraft++}';
_bloc.add(
CardComposerEventDraftAdded(
id,
after: _open,
source: (pdfId: widget.source.id, page: page),
tagNames: [
if (chapterOf(widget.chapters, page)?.title.trim() case final title?
when title.isNotEmpty)
title,
],
),
);
setState(() {
_open = id;
_focusFront = focus;
});
return id;
}
void _onClip() {
final clip = widget.clips.last;
if (clip == null || clip.target == PdfClipTarget.note) {
return;
}
final draft = _draft ?? _newDraft();
setState(
() => _draft = (
card: draft.card,
front: clip.target == PdfClipTarget.question
? withParagraph(draft.front, clip.text)
: draft.front,
back: clip.target == PdfClipTarget.answer
? withParagraph(draft.back, clip.text)
: draft.back,
page: draft.page,
version: draft.version + 1,
final id = _open ?? _add(focus: false);
_bloc.add(
CardComposerEventTextAdded(
id,
text: clip.text,
toAnswer: clip.target == PdfClipTarget.answer,
),
);
}
Future<void> _save() async {
final draft = _draft;
if (draft == null) {
/// Back to the list, asking first when that discards something written.
Future<void> _back(CardDraft draft) async {
final l10n = AppLocalizations.of(context);
final nothingToLose = draft.cardId == null
? draft.content.isBlank
: !draft.isDirty;
if (!nothingToLose &&
!await confirmDestructiveAction(
context,
title: l10n.composerDiscardTitle(1),
message: l10n.composerDiscardMessage(1),
confirmLabel: l10n.composerDiscardConfirm,
)) {
return;
}
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final person = PersonScope.of(context);
final page = draft.card == null ? widget.controller.page : draft.page;
final content = (draft.card?.content ?? CardContent.empty()).copyWith(
front: draft.front,
back: draft.back,
source: (pdfId: widget.source.id, page: page),
);
if (draft.card case final card?) {
await person.cards.save(card, content);
} else {
final id = await person.cards.create(widget.source.studysetId, content);
// Tagged with its chapter, so the chapter can be studied on its own.
// A chapter whose title is blank names no tag.
if (chapterOf(widget.chapters, page) case final chapter?
when chapter.title.trim().isNotEmpty) {
await person.tags.setTagsOf(id, {
await person.tags.create(chapter.title),
});
}
messenger.showSnackBar(
SnackBar(content: Text(l10n.readerCardCreated)),
);
}
_bloc.add(CardComposerEventDiscarded(draft.id));
if (mounted) {
setState(
() => _draft = _keepOpen && draft.card == null ? _newDraft() : null,
);
setState(() => _open = null);
}
}
Future<void> _delete(Card card) async {
final l10n = AppLocalizations.of(context);
final cards = PersonScope.of(context).cards;
if (await confirmDestructiveAction(
context,
title: l10n.readerCardDeleteTitle,
message: l10n.readerCardDeleteMessage,
confirmLabel: l10n.readerCardDeleteConfirm,
)) {
await cards.delete(card);
void _save(CardDraft draft) {
setState(() => _saving = (id: draft.id, isNew: draft.cardId == null));
_bloc.add(CardComposerEventLeft(draft.id));
}
/// Once the card awaited is saved: a new one says so, and the next opens
/// while creation stays open; else the list shows again.
void _onState(BuildContext context, CardComposerState state) {
final saving = _saving;
final draft = state.byId(saving?.id);
if (saving == null ||
draft == null ||
draft.status != CardDraftStatus.saved) {
return;
}
final created = saving.isNew;
_saving = null;
if (created) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).readerCardCreated)),
);
}
if (_keepOpen && created) {
_add(focus: true);
} else {
setState(() => _open = null);
}
}
Future<void> _delete(CardDraft draft) async {
final l10n = AppLocalizations.of(context);
// A new card with nothing in it has nothing to lose.
if (!(draft.cardId == null && draft.content.isBlank) &&
!await confirmDestructiveAction(
context,
title: l10n.readerCardDeleteTitle,
message: l10n.readerCardDeleteMessage,
confirmLabel: l10n.readerCardDeleteConfirm,
)) {
return;
}
_bloc.add(CardComposerEventDeleted(draft.id));
if (mounted && _open == draft.id) {
setState(() => _open = null);
}
}
void _duplicate(CardDraft draft) {
final id = 'pdf-${_nextDraft++}';
_bloc.add(CardComposerEventDuplicated(draft.id, id));
setState(() {
_open = id;
_focusFront = false;
});
}
@override
Widget build(BuildContext context) {
super.build(context);
final draft = _draft;
return draft == null ? _list(context) : _editor(context, draft);
return BlocProvider.value(
value: _bloc,
child: BlocConsumer<CardComposerBloc, CardComposerState>(
listener: _onState,
builder: (context, state) => switch (state.byId(_open)) {
final draft? => _editor(context, state, draft),
null => _list(context, state),
},
),
);
}
Widget _list(BuildContext context) {
Widget _list(BuildContext context, CardComposerState state) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final sections = <String, List<Card>>{};
for (final card
in [..._all]..sort(
(a, b) => a.content.source!.page.compareTo(b.content.source!.page),
final sections = <String, List<CardDraft>>{};
for (final draft
in [
for (final draft in state.items)
if (draft.cardId != null &&
draft.saved.source?.pdfId == widget.source.id)
draft,
]..sort(
(a, b) => a.saved.source!.page.compareTo(b.saved.source!.page),
)) {
final page = card.content.source!.page;
final page = draft.saved.source!.page;
final heading = _byChapter
? chapterOf(widget.chapters, page)?.title ?? l10n.readerFrontMatter
: l10n.readerPage(page);
(sections[heading] ??= []).add(card);
(sections[heading] ??= []).add(draft);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -338,7 +350,7 @@ class _CardsPanelState()
),
),
Expanded(
child: _all.isEmpty
child: sections.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Column(
@ -361,33 +373,28 @@ class _CardsPanelState()
)
: ListView(
children: [
for (final MapEntry(key: heading, value: cards)
for (final MapEntry(key: heading, value: drafts)
in sections.entries) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'$heading · ${cards.length}',
'$heading · ${drafts.length}',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
),
for (final card in cards)
for (final draft in drafts)
_CardTile(
card: card,
content: draft.saved,
onTap: () => widget.controller.goToPage(
card.content.source!.page,
draft.saved.source!.page,
),
onEdit: () => setState(
() => _draft = (
card: card,
front: card.content.front,
back: card.content.back,
page: card.content.source!.page,
version: 0,
),
),
onDelete: () => unawaited(_delete(card)),
onEdit: () => setState(() {
_open = draft.id;
_focusFront = false;
}),
onDelete: () => unawaited(_delete(draft)),
),
],
],
@ -398,139 +405,132 @@ class _CardsPanelState()
child: FilledButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.readerCreateCard),
onPressed: () => setState(() => _draft = _newDraft()),
onPressed: () => _add(focus: true),
),
),
],
);
}
Widget _editor(BuildContext context, _Draft draft) {
Widget _editor(
BuildContext context,
CardComposerState state,
CardDraft draft,
) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
void change({RichContent? front, RichContent? back, int? page}) =>
_draft = (
card: draft.card,
front: front ?? _draft!.front,
back: back ?? _draft!.back,
page: page ?? _draft!.page,
version: _draft!.version,
);
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 12, 12),
children: [
Row(
children: [
IconButton(
icon: const Icon(LucideIcons.arrowLeft),
tooltip: l10n.readerBackToCards,
onPressed: () => setState(() => _draft = null),
),
Text(
draft.card == null ? l10n.readerCreateCard : l10n.readerEditCard,
style: theme.textTheme.titleMedium,
),
],
),
if (draft.card != null)
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Row(
children: [
Text(l10n.readerReferencedPage(draft.page)),
const Spacer(),
TextButton(
onPressed: () => setState(
() => change(page: widget.controller.page),
),
child: Text(
l10n.readerUseCurrentPage(widget.controller.page),
),
),
],
),
),
_Field(
key: ValueKey(('front', draft.version, draft.card?.id)),
label: l10n.readerQuestion,
content: draft.front,
onChanged: (front) => change(front: front),
),
const SizedBox(height: 12),
_Field(
key: ValueKey(('back', draft.version, draft.card?.id)),
label: l10n.readerAnswer,
content: draft.back,
onChanged: (back) => change(back: back),
),
if (draft.card == null)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: _keepOpen,
onChanged: (value) => setState(() => _keepOpen = value ?? false),
title: Text(l10n.readerKeepOpen),
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () => setState(
() => _draft = draft.card == null
? _newDraft()
: (
card: draft.card,
front: RichContent.empty(),
back: RichContent.empty(),
page: draft.page,
version: draft.version + 1,
),
),
child: Text(l10n.readerClear),
),
const Spacer(),
FilledButton(
onPressed: () => unawaited(_save()),
child: Text(
draft.card == null ? l10n.readerCreate : l10n.commonSave,
),
),
],
),
],
);
}
}
/// A field of a card in the panel: its label, and an editor with the
/// format bar while written.
class const _Field({
required final String label,
required final RichContent content,
required final ValueChanged<RichContent> onChanged,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isNew = draft.cardId == null;
final page = draft.content.source?.page;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text(label, style: theme.textTheme.labelLarge),
),
Container(
height: 180,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
padding: const EdgeInsets.fromLTRB(4, 4, 12, 0),
child: Row(
children: [
IconButton(
icon: const Icon(LucideIcons.arrowLeft),
tooltip: l10n.readerBackToCards,
onPressed: () => unawaited(_back(draft)),
),
Expanded(
child: Text(
isNew ? l10n.readerCreateCard : l10n.readerEditCard,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
child: RichContentEditor(
initialContent: content,
onChanged: onChanged,
formatBar: true,
),
if (page != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 12, 0),
child: ListenableBuilder(
listenable: widget.controller,
builder: (context, _) => Row(
children: [
Expanded(
child: Text(
l10n.readerReferencedPage(page),
style: theme.textTheme.bodySmall,
),
),
if (widget.controller.page != page)
TextButton(
onPressed: () => _bloc.add(
CardComposerEventChanged(
draft.id,
draft.content.copyWith(
source: (
pdfId: widget.source.id,
page: widget.controller.page,
),
),
),
),
child: Text(
l10n.readerUseCurrentPage(widget.controller.page),
),
),
],
),
),
),
Expanded(
child: CardDraftEditor(
key: ValueKey(draft.id),
draft: draft,
pins: state.pins,
tagging: state.tagging,
mapRegions: state.mapRegions,
focusFront: _focusFront,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
onDuplicate: () => _duplicate(draft),
onDelete: () => unawaited(_delete(draft)),
onSuggest: fromRelease(draft.content.published)
? () => suggestToAuthor(
context,
studysetId: widget.source.studysetId,
send: (feedback, body) => feedback.suggestCard(
draft.content.normalized,
body: body,
),
)
: null,
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 8),
child: Row(
children: [
if (isNew)
Expanded(
child: CheckboxListTile(
contentPadding: const EdgeInsets.only(left: 4),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepOpen,
onChanged: (value) =>
setState(() => _keepOpen = value ?? false),
title: Text(l10n.readerKeepOpen),
),
)
else
const Spacer(),
const SizedBox(width: 8),
FilledButton(
onPressed:
draft.canSave && draft.status != CardDraftStatus.saving
? () => _save(draft)
: null,
child: draft.status == CardDraftStatus.saving
? const ButtonLoadingIndicator()
: Text(isNew ? l10n.readerCreate : l10n.commonSave),
),
],
),
),
],
@ -539,7 +539,7 @@ class const _Field({
}
class const _CardTile({
required final Card card,
required final CardContent content,
required final VoidCallback onTap,
required final VoidCallback onEdit,
required final VoidCallback onDelete,
@ -548,8 +548,8 @@ class const _CardTile({
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error;
final front = previewOf(card.content.front, l10n);
final answer = card.content.answerPreview;
final front = previewOf(content.front, l10n);
final answer = content.answerPreview;
return MenuAnchor(
menuChildren: [
MenuItemButton(
@ -691,8 +691,7 @@ class _NotePanelState()
if (clip == null || clip.target != PdfClipTarget.note || !_loaded) {
return;
}
final body = withParagraph(
_unsaved ?? _body ?? RichContent.empty(),
final body = (_unsaved ?? _body ?? RichContent.empty()).withParagraphs(
clip.text,
);
setState(() {

View file

@ -595,4 +595,101 @@ void main() {
expect(await cards.cardsOf('japanese'), isEmpty);
expect(gateway.deletions, ['blank $neko', 'delete $neko']);
});
test('a card written from a PDF knows its page, waits for its chapter '
'tag by name, and takes text sent to its front and answer', () async {
start();
await loaded();
bloc
..add(
const CardComposerEventDraftAdded(
'new',
source: (pdfId: 'grammar', page: 5),
tagNames: ['Particles'],
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'は marks',
toAnswer: false,
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'the topic',
toAnswer: true,
),
);
final draft = (await until(
(s) => s.byId('new')?.content.back.plainText == 'the topic',
)).byId('new')!;
expect(draft.content.front.plainText, 'は marks');
expect(draft.content.source, (pdfId: 'grammar', page: 5));
expect(draft.newTagNames, ['Particles']);
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
(s) => s.byId('new')?.status == CardDraftStatus.saved,
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.content.source, (pdfId: 'grammar', page: 5));
expect((await tags.read()).of(stored.id).single.name, 'Particles');
// Held by its draft alone, never also among the stored cards.
expect(saved.items.where((d) => d.cardId == stored.id), hasLength(1));
});
test('text for the answer goes where the kind of card keeps it', () {
final base = card('猫', '');
expect(
base.withTextAdded('cat', toAnswer: true).back.plainText,
'cat',
);
final word = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.vocabulary,
meanings: const ['cat'],
);
expect(
word.withTextAdded('feline\nanimal', toAnswer: true).meanings,
['cat', 'feline animal'],
);
final choice = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.multipleChoice,
answers: [
CardAnswer(content: text('dog')),
CardAnswer(content: RichContent.empty(), correct: true),
],
);
expect(
choice.withTextAdded('cat', toAnswer: true).answers[1].content.plainText,
'cat',
);
});
test('discarding drops a new card, and takes a stored one back to how '
'it is stored', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged(neko, card('猫', 'kitten')))
..add(const CardComposerEventDiscarded('new'))
..add(CardComposerEventDiscarded(neko));
final state = await until(
(s) =>
s.byId('new') == null &&
s.byId(neko)?.content.back.plainText == 'cat',
);
expect(state.byId(neko)!.isDirty, isFalse);
expect(await cards.cardsOf('japanese'), hasLength(1));
});
}

View file

@ -223,6 +223,18 @@ void main() {
find.textContaining('は marks the topic', findRichText: true),
findsOneWidget,
);
// A card is created once it has its answer too, as in the composer.
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Create'))
.onPressed,
isNull,
);
engine.select('the topic particle');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Answer'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
@ -235,6 +247,7 @@ void main() {
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.source, (pdfId: id, page: 5));
expect(card.content.front.plainText, 'は marks the topic');
expect(card.content.back.plainText, 'the topic particle');
expect((await tags.read()).of(card.id).single.name, 'Particles');
expect(find.text('Page 5 · 1'), findsOneWidget);
});
@ -268,6 +281,24 @@ void main() {
expect(note.content.body.plainText, 'ね is a softener');
});
testWidgets('the format bar beside a PDF ends with a screenshot button, and '
'the card is written as in the composer, its kind chosen', (
tester,
) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Create flashcard'));
await tester.pumpAndSettle();
expect(find.widgetWithText(ChoiceChip, 'Vocabulary'), findsOneWidget);
await tester.tap(find.byTooltip('Screenshot of the page'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsOneWidget);
await tester.tap(find.text('Cancel screenshot'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsNothing);
});
testWidgets('a screenshot of a region of the page goes into the card as '
'an image that knows its page, which a click selects', (tester) async {
final id = await stored();