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:
parent
72cbec52c2
commit
fcee3c2670
11 changed files with 714 additions and 372 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue