fix(composer): a field closes at a click elsewhere, and an answer of several is one card
A card's field being written stayed open until another took the focus: a click on the page beside it did nothing. Now a click anywhere else closes it, as it does a text field on the desktop; a finger closes it by a tap, not by scrolling the page. The `/` menu, though outside, keeps it. An open field is framed by one line with its format bar inside, its text inset 16 by 8, and opens as tall as its content needs, 220 at the least (200 for a hint or the explanation) and 400 at the most; it grows and shrinks into place. A closed field's preview is 120 high. A multiple-choice answer is one card, green right, red wrong: closed, the whole card is what one taps, filled lightly and more under the pointer; open, its editor sits in the card without a frame of its own. An answer added opens to be written. "Add answer" shows below a single answer while it is being written. A field closed after writing in it no longer stays lit as if the pointer were over it: its preview had kept the hover from before it opened. 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
f99caebcd9
commit
c002a07011
5 changed files with 441 additions and 185 deletions
|
|
@ -40,6 +40,9 @@ class const RichContentEditor({
|
|||
|
||||
/// Whether a [FormatBar] shows below the editor while it has the focus.
|
||||
final bool formatBar = false,
|
||||
|
||||
/// The room around the text, within the editor's scrolling.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// Whether an editor's popup (its `/` menu) has the focus for now: it
|
||||
|
|
@ -151,7 +154,7 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
|||
builder: (context, constraints) => AppFlowyEditor(
|
||||
editorState: _editorState,
|
||||
editorScrollController: _scroll,
|
||||
editorStyle: editorStyleOf(context),
|
||||
editorStyle: editorStyleOf(context).copyWith(padding: widget.padding),
|
||||
commandShortcutEvents: clipboardShortcuts(link: linkShortcut(context)),
|
||||
blockComponentBuilders: buildersWithUnknown(
|
||||
blocks,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// The most answers a multiple-choice card has.
|
||||
const maxAnswers = 6;
|
||||
|
||||
/// A card's answer side: one answer makes a front/back card; "Add answer"
|
||||
/// turns it into a multiple-choice card of up to [maxAnswers], each marked
|
||||
/// right or wrong, at least one right. The first answer stays; the others
|
||||
/// can go.
|
||||
/// A card's answer side: one answer makes a front/back card; "Add answer",
|
||||
/// below it while it is being written, turns it into a multiple-choice card
|
||||
/// of up to [maxAnswers], each marked right or wrong, at least one right.
|
||||
/// The first answer stays; the others can go. An answer added opens to be
|
||||
/// written.
|
||||
class const AnswersEditor({
|
||||
/// The answers, at least one.
|
||||
required final List<CardAnswer> answers,
|
||||
|
|
@ -20,65 +21,90 @@ class const AnswersEditor({
|
|||
/// Called with the answers after every change.
|
||||
required final ValueChanged<List<CardAnswer>> onChanged,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<AnswersEditor> createState() => _AnswersEditorState();
|
||||
}
|
||||
|
||||
class _AnswersEditorState() extends State<AnswersEditor> {
|
||||
/// Where the answer last added is, which opens to be written.
|
||||
int? _added;
|
||||
|
||||
void _add() {
|
||||
final answers = widget.answers;
|
||||
setState(() => _added = answers.length);
|
||||
widget.onChanged([
|
||||
// A front/back card's answer is the right one.
|
||||
if (answers.length == 1)
|
||||
answers.single.copyWith(correct: true)
|
||||
else
|
||||
...answers,
|
||||
CardAnswer(content: RichContent.empty()),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final answers = widget.answers;
|
||||
final onChanged = widget.onChanged;
|
||||
final rightOnes = answers.where((answer) => answer.correct).length;
|
||||
final add = answers.length < maxAnswers
|
||||
? Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: TextButton.icon(
|
||||
onPressed: _add,
|
||||
icon: const Icon(LucideIcons.plus, size: 16),
|
||||
label: Text(l10n.composerAddAnswer),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (answers.length == 1) {
|
||||
return CardFace(
|
||||
key: const ValueKey('answer-0'),
|
||||
content: answers.single.content,
|
||||
placeholder: l10n.composerAddAnswer,
|
||||
onChanged: (content) =>
|
||||
onChanged([CardAnswer(content: content, correct: true)]),
|
||||
// Part of the open field, so pressing it doesn't close it first.
|
||||
footer: add,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (answers.length == 1)
|
||||
CardFace(
|
||||
key: const ValueKey('answer-0'),
|
||||
content: answers.single.content,
|
||||
placeholder: l10n.composerAddAnswer,
|
||||
onChanged: (content) => onChanged([
|
||||
CardAnswer(content: content, correct: true),
|
||||
]),
|
||||
)
|
||||
else
|
||||
for (final (i, answer) in answers.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 12),
|
||||
_AnswerCard(
|
||||
key: ValueKey('answer-$i'),
|
||||
answer: answer,
|
||||
// The last right answer can't turn wrong: one stays right.
|
||||
canTurnWrong: !(answer.correct && rightOnes <= 1),
|
||||
canRemove: i > 0,
|
||||
onChanged: (changed) => onChanged([...answers]..[i] = changed),
|
||||
onRemove: () => onChanged([...answers]..removeAt(i)),
|
||||
),
|
||||
],
|
||||
if (answers.length < maxAnswers) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onChanged([
|
||||
// A front/back card's answer is the right one.
|
||||
if (answers.length == 1)
|
||||
answers.single.copyWith(correct: true)
|
||||
else
|
||||
...answers,
|
||||
CardAnswer(content: RichContent.empty()),
|
||||
]),
|
||||
icon: const Icon(LucideIcons.plus, size: 16),
|
||||
label: Text(l10n.composerAddAnswer),
|
||||
),
|
||||
for (final (i, answer) in answers.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 12),
|
||||
_AnswerCard(
|
||||
key: ValueKey('answer-$i'),
|
||||
answer: answer,
|
||||
// The last right answer can't turn wrong: one stays right.
|
||||
canTurnWrong: !(answer.correct && rightOnes <= 1),
|
||||
canRemove: i > 0,
|
||||
// Read as it is first built: the answer added, once it has come
|
||||
// back with the others.
|
||||
startExpanded: i == _added,
|
||||
onChanged: (changed) => onChanged([...answers]..[i] = changed),
|
||||
onRemove: () => onChanged([...answers]..removeAt(i)),
|
||||
),
|
||||
],
|
||||
?add,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One answer of a multiple-choice card: marked right (green) or wrong
|
||||
/// (red) by its label and switch, the field itself left plain.
|
||||
/// One answer of a multiple-choice card, framed in green while right and
|
||||
/// red while wrong: above its content, its label and switch say which, and
|
||||
/// all but the first can go.
|
||||
class const _AnswerCard({
|
||||
required final CardAnswer answer,
|
||||
required final bool canTurnWrong,
|
||||
required final bool canRemove,
|
||||
required final bool startExpanded,
|
||||
required final ValueChanged<CardAnswer> onChanged,
|
||||
required final VoidCallback onRemove,
|
||||
super.key,
|
||||
|
|
@ -90,64 +116,49 @@ class const _AnswerCard({
|
|||
final right = theme.extras.success;
|
||||
final wrong = theme.colorScheme.error;
|
||||
final accent = answer.correct ? right : wrong;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.06),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 4, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
// The switches line up whatever the label's length.
|
||||
width: 140,
|
||||
child: Text(
|
||||
answer.correct
|
||||
? l10n.composerCorrectAnswer
|
||||
: l10n.composerWrongAnswer,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: answer.correct,
|
||||
// Kept enabled, so it keeps its colours; turning the last
|
||||
// right answer wrong does nothing.
|
||||
onChanged: (correct) {
|
||||
if (correct || canTurnWrong) {
|
||||
onChanged(answer.copyWith(correct: correct));
|
||||
}
|
||||
},
|
||||
activeThumbColor: right,
|
||||
inactiveThumbColor: wrong,
|
||||
),
|
||||
const Spacer(),
|
||||
if (canRemove)
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
tooltip: l10n.composerRemoveAnswer,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
),
|
||||
],
|
||||
return CardFace(
|
||||
content: answer.content,
|
||||
placeholder: l10n.composerAddAnswer,
|
||||
startExpanded: startExpanded,
|
||||
accent: accent,
|
||||
onChanged: (content) => onChanged(answer.copyWith(content: content)),
|
||||
header: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
// The switches line up whatever the label's length.
|
||||
width: 140,
|
||||
child: Text(
|
||||
answer.correct
|
||||
? l10n.composerCorrectAnswer
|
||||
: l10n.composerWrongAnswer,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CardFace(
|
||||
content: answer.content,
|
||||
placeholder: l10n.composerAddAnswer,
|
||||
onChanged: (content) =>
|
||||
onChanged(answer.copyWith(content: content)),
|
||||
),
|
||||
Switch(
|
||||
value: answer.correct,
|
||||
// Kept enabled, so it keeps its colours; turning the last right
|
||||
// answer wrong does nothing.
|
||||
onChanged: (correct) {
|
||||
if (correct || canTurnWrong) {
|
||||
onChanged(answer.copyWith(correct: correct));
|
||||
}
|
||||
},
|
||||
activeThumbColor: right,
|
||||
inactiveThumbColor: wrong,
|
||||
),
|
||||
const Spacer(),
|
||||
if (canRemove)
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
tooltip: l10n.composerRemoveAnswer,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ CardContent withAnswers(CardContent content, List<CardAnswer> answers) =>
|
|||
.withField(CardField.answers, const <CardAnswer>[])
|
||||
: content.copyWith(type: CardType.multipleChoice, answers: answers);
|
||||
|
||||
/// How tall a hint's or the explanation's editor opens at least: lower than
|
||||
/// the question's and the answer's, as they are mostly a line or two.
|
||||
const extraEditorHeight = 200.0;
|
||||
|
||||
/// The composer's editor pane for [draft]: its tags and actions on top, its
|
||||
/// type, the front, and below a line its answer side as its type has it.
|
||||
/// Fields devices changed differently come first, each with its versions.
|
||||
|
|
@ -538,6 +542,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
content: explanation,
|
||||
placeholder: l10n.cardAddExplanation,
|
||||
startExpanded: _newExplanation,
|
||||
minEditorHeight: extraEditorHeight,
|
||||
onChanged: (explanation) => _change(
|
||||
_sent.withField(CardField.explanation, explanation),
|
||||
),
|
||||
|
|
@ -871,6 +876,7 @@ class const _Hints({
|
|||
content: hint,
|
||||
placeholder: l10n.cardAddHint,
|
||||
startExpanded: index == opened,
|
||||
minEditorHeight: extraEditorHeight,
|
||||
onChanged: (changed) => onChanged([...hints]..[index] = changed),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
|
|
@ -6,10 +7,21 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A rich field of a card: read-only and compact until tapped, then an
|
||||
/// editor of a fixed height with the cursor in it and the format bar below
|
||||
/// (its Insert too, as on phones there is no other way to blocks), compact
|
||||
/// again once the focus leaves. Only the field being written holds an
|
||||
/// editor, so a card with many fields stays light.
|
||||
/// editor with the cursor in it and the format bar below (its Insert too,
|
||||
/// as on phones there is no other way to blocks), compact again once the
|
||||
/// focus leaves, by a tap anywhere else among them. Only the field being
|
||||
/// written holds an editor, so a card with many fields stays light.
|
||||
///
|
||||
/// The editor opens as tall as the content needs, between
|
||||
/// [minEditorHeight] and [maxEditorHeight] with its format bar, so a short
|
||||
/// field doesn't push the rest of the card far down and a long one doesn't
|
||||
/// take the whole screen; it scrolls within. It grows and shrinks into
|
||||
/// place ([resizeDuration]).
|
||||
///
|
||||
/// With an [accent], it is one answer of several, framed in that colour
|
||||
/// (green right, red wrong) with [header] above its content: the whole
|
||||
/// frame is what one taps, filled lightly, more under the pointer; open,
|
||||
/// the editor sits in it without a frame of its own.
|
||||
class const CardFace({
|
||||
/// The field's content.
|
||||
required final RichContent content,
|
||||
|
|
@ -22,16 +34,40 @@ class const CardFace({
|
|||
|
||||
/// Whether it opens as an editor, e.g. for a card just added.
|
||||
final bool startExpanded = false,
|
||||
|
||||
/// How tall the open editor is at least, its format bar included.
|
||||
final double minEditorHeight = defaultMinEditorHeight,
|
||||
|
||||
/// The colour of the answer it is; a plain field without.
|
||||
final Color? accent,
|
||||
|
||||
/// Above the content within an [accent]'s frame: what the answer is
|
||||
/// and what can be done with it.
|
||||
final Widget? header,
|
||||
|
||||
/// Below the editor while it is open, as part of it: a tap there doesn't
|
||||
/// close it.
|
||||
final Widget? footer,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// How tall the editor is, its format bar below not counted.
|
||||
static const editorHeight = 200.0;
|
||||
/// How tall an open editor is at least, its format bar included.
|
||||
static const defaultMinEditorHeight = 220.0;
|
||||
|
||||
/// How tall an open editor grows at most, its format bar included.
|
||||
static const maxEditorHeight = 400.0;
|
||||
|
||||
/// The room an open editor keeps beyond its content: the format bar with
|
||||
/// its line, the editor's padding, and a line to write on.
|
||||
static const editorHeadroom = FormatBar.height + 1 + 2 * 8 + 24;
|
||||
|
||||
/// How tall a filled field's preview grows at most.
|
||||
static const previewHeight = 160.0;
|
||||
static const previewHeight = 120.0;
|
||||
|
||||
/// Over how much of its bottom a preview cut short fades out.
|
||||
static const fadeHeight = 32.0;
|
||||
static const fadeHeight = 30.0;
|
||||
|
||||
/// How long a field takes to open and close.
|
||||
static const resizeDuration = Duration(milliseconds: 150);
|
||||
|
||||
@override
|
||||
State<CardFace> createState() => _CardFaceState();
|
||||
|
|
@ -46,6 +82,13 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
|
|||
/// Whether the preview cuts the content short.
|
||||
var _clipped = false;
|
||||
|
||||
/// How tall the content is, as its preview measured it.
|
||||
double? _contentHeight;
|
||||
|
||||
/// Where a finger touched down outside the open editor: lifted near
|
||||
/// there, it was a tap, which closes it; moved on, it scrolled the page.
|
||||
Offset? _outsideDown;
|
||||
|
||||
/// Keeps the preview, the editor showing it included, when it moves in
|
||||
/// and out of the fade.
|
||||
final _preview = GlobalKey();
|
||||
|
|
@ -84,12 +127,22 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
|
|||
if (mounted && _expanded) {
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
duration: CardFace.resizeDuration,
|
||||
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _open() {
|
||||
setState(() {
|
||||
_expanded = true;
|
||||
// The pointer leaves with the preview, which says nothing of it then.
|
||||
_hovered = false;
|
||||
});
|
||||
_keepInView();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
// A dialog opening over the editor takes the focus too, and so does
|
||||
// the editor's `/` menu; collapsing then would drop the editor they
|
||||
|
|
@ -102,28 +155,112 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
|
|||
}
|
||||
}
|
||||
|
||||
/// A click elsewhere closes the editor at once, as it does a text field;
|
||||
/// a finger only once lifted without moving, so scrolling the page keeps
|
||||
/// it open.
|
||||
void _downOutside(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.touch) {
|
||||
_outsideDown = event.position;
|
||||
} else {
|
||||
_leave();
|
||||
}
|
||||
}
|
||||
|
||||
void _upOutside(PointerUpEvent event) {
|
||||
final down = _outsideDown;
|
||||
_outsideDown = null;
|
||||
if (down != null && (event.position - down).distance <= kTouchSlop) {
|
||||
_leave();
|
||||
}
|
||||
}
|
||||
|
||||
void _leave() {
|
||||
// The `/` menu lies outside, over the page.
|
||||
if (!RichContentEditor.popupHasFocus) {
|
||||
_focus.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// How tall the open editor is: its content, measured in the preview,
|
||||
/// with room to write on, within the bounds.
|
||||
double get _editorHeight => switch (_contentHeight) {
|
||||
final measured? when !widget.content.isBlank =>
|
||||
(measured + CardFace.editorHeadroom).clamp(
|
||||
widget.minEditorHeight,
|
||||
CardFace.maxEditorHeight,
|
||||
),
|
||||
_ => widget.minEditorHeight,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context) => AnimatedSize(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: CardFace.resizeDuration,
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _expanded ? _editor(context) : _face(context),
|
||||
);
|
||||
|
||||
Widget _editor(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final accent = widget.accent;
|
||||
final editor = SizedBox(
|
||||
height: _editorHeight,
|
||||
child: RichContentEditor(
|
||||
initialContent: widget.content,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
formatBar: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
);
|
||||
return TapRegion(
|
||||
onTapOutside: _downOutside,
|
||||
onTapUpOutside: _upOutside,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (accent == null)
|
||||
DecoratedBox(
|
||||
// On the frame's inside, so the format bar's line meets it.
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.outline),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: editor,
|
||||
)
|
||||
else
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: accent.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
?widget.header,
|
||||
const SizedBox(height: 4),
|
||||
editor,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
?widget.footer,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _face(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
if (_expanded) {
|
||||
return Container(
|
||||
// The bar and the line above it come on top of the editor's room.
|
||||
height: CardFace.editorHeight + FormatBar.height + 1,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.primary, width: 2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: RichContentEditor(
|
||||
initialContent: widget.content,
|
||||
onChanged: widget.onChanged,
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
formatBar: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
final accent = widget.accent;
|
||||
final empty = widget.content.isBlank;
|
||||
final preview = ClipRect(
|
||||
key: _preview,
|
||||
|
|
@ -132,64 +269,84 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
|
|||
child: RichContentView(content: widget.content),
|
||||
),
|
||||
);
|
||||
final content = empty
|
||||
? Row(
|
||||
children: [
|
||||
Icon(LucideIcons.plus, size: 18, color: colors.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.placeholder,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
// Tall content shows its start, fading out so it reads as cut
|
||||
// short (decided 2026-10-02); the editor shows it all.
|
||||
: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: CardFace.previewHeight,
|
||||
),
|
||||
child: NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) {
|
||||
// The preview's own scroll view, not one in the content.
|
||||
if (notification.depth > 0) {
|
||||
return false;
|
||||
}
|
||||
final metrics = notification.metrics;
|
||||
// All of it, also what the preview cuts off, for the
|
||||
// editor's height.
|
||||
_contentHeight =
|
||||
metrics.viewportDimension + metrics.maxScrollExtent;
|
||||
final clipped = metrics.maxScrollExtent > 0;
|
||||
if (clipped != _clipped) {
|
||||
setState(() => _clipped = clipped);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
child: _clipped ? _Fade(child: preview) : preview,
|
||||
),
|
||||
);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _expanded = true);
|
||||
_keepInView();
|
||||
},
|
||||
onTap: _open,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? colors.surfaceContainerHighest : null,
|
||||
border: Border.all(
|
||||
color: _hovered ? colors.outline : colors.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: empty
|
||||
? Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.plus,
|
||||
size: 18,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.placeholder,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: accent == null
|
||||
? const EdgeInsets.all(12)
|
||||
: const EdgeInsets.fromLTRB(12, 8, 8, 12),
|
||||
decoration: accent == null
|
||||
? BoxDecoration(
|
||||
color: _hovered
|
||||
? colors.surfaceContainerHighest
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: _hovered ? colors.outline : colors.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
)
|
||||
// Tall content shows its start, fading out so it reads as cut
|
||||
// short (decided 2026-10-02); the editor shows it all.
|
||||
: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: CardFace.previewHeight,
|
||||
),
|
||||
child: NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (notification) {
|
||||
// The preview's own scroll view, not one in the content.
|
||||
if (notification.depth > 0) {
|
||||
return false;
|
||||
}
|
||||
final clipped = notification.metrics.maxScrollExtent > 0;
|
||||
if (clipped != _clipped) {
|
||||
setState(() => _clipped = clipped);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
child: _clipped ? _Fade(child: preview) : preview,
|
||||
),
|
||||
: BoxDecoration(
|
||||
color: accent.withValues(alpha: _hovered ? 0.16 : 0.10),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: accent == null
|
||||
? content
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
?widget.header,
|
||||
const SizedBox(height: 4),
|
||||
content,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
|
|
@ -75,6 +76,12 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// [text] within a field of the card, not the list of cards beside it.
|
||||
Finder inField(String text) => find.descendant(
|
||||
of: find.byType(CardFace),
|
||||
matching: find.text(text, findRichText: true),
|
||||
);
|
||||
|
||||
/// Opens the studyset `japanese`, with the cards the test gave [cards], in
|
||||
/// a window of [size].
|
||||
Future<void> showStudyset(
|
||||
|
|
@ -325,14 +332,11 @@ void main() {
|
|||
await tester.tap(find.text('cat', findRichText: true));
|
||||
await settle(tester);
|
||||
expect(find.byTooltip('Bold'), findsOneWidget);
|
||||
// The bar takes nothing from the room to write in.
|
||||
final editor = tester.getSize(
|
||||
find.descendant(
|
||||
of: find.byType(RichContentEditor),
|
||||
matching: find.byType(AppFlowyEditor),
|
||||
),
|
||||
// A short field opens as low as a field gets, the bar within.
|
||||
expect(
|
||||
tester.getSize(find.byType(RichContentEditor)).height,
|
||||
CardFace.defaultMinEditorHeight,
|
||||
);
|
||||
expect(editor.height, greaterThanOrEqualTo(CardFace.editorHeight - 4));
|
||||
|
||||
await tester.tap(find.byTooltip('Insert'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -443,11 +447,17 @@ void main() {
|
|||
expect(fadeIn(find.byType(CardFace).last), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a second answer makes a multiple-choice card, one answer '
|
||||
'right', (tester) async {
|
||||
testWidgets('a second answer, added from the open answer, makes a '
|
||||
'multiple-choice card, one answer right, the new one open to write', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('Capital of Japan?', 'Tokyo'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, 'Capital of Japan?');
|
||||
// Offered while the answer is written.
|
||||
expect(find.text('Add answer'), findsNothing);
|
||||
await tester.tap(inField('Tokyo'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Add answer'));
|
||||
await settle(tester);
|
||||
|
|
@ -455,12 +465,81 @@ void main() {
|
|||
expect(find.text('Answers'), findsOneWidget);
|
||||
expect(find.text('Correct answer'), findsOneWidget);
|
||||
expect(find.text('Wrong answer'), findsOneWidget);
|
||||
// The new answer is the one open, framed by its card alone.
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byType(RichContentEditor),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) => widget is CardFace && widget.accent != null,
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(inField('Tokyo'), findsOneWidget);
|
||||
// The only right answer can't turn wrong.
|
||||
await tester.tap(find.byType(Switch).first);
|
||||
await settle(tester);
|
||||
expect(find.text('Correct answer'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a field being written closes at a click elsewhere, and at a '
|
||||
"finger's tap, but not as a finger scrolls the page", (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
final outside = tester.getCenter(find.text('Back'));
|
||||
|
||||
await tester.tap(inField('cat'));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
await tester.tapAt(outside, kind: PointerDeviceKind.mouse);
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsNothing);
|
||||
|
||||
await tester.tap(inField('cat'));
|
||||
await settle(tester);
|
||||
await tester.dragFrom(outside, const Offset(0, -40));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
await tester.tapAt(tester.getCenter(find.text('Back')));
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a field closed after writing in it is not left lit as if '
|
||||
'the pointer were over it', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(
|
||||
location: tester.getCenter(inField('cat')),
|
||||
);
|
||||
await tester.pump();
|
||||
await mouse.down(tester.getCenter(inField('cat')));
|
||||
await mouse.up();
|
||||
await settle(tester);
|
||||
expect(find.byType(RichContentEditor), findsOneWidget);
|
||||
|
||||
// Into the question, the pointer leaving the answer on its way.
|
||||
final front = tester.getCenter(inField('猫'));
|
||||
await mouse.moveTo(front);
|
||||
await mouse.down(front);
|
||||
await mouse.up();
|
||||
await settle(tester);
|
||||
|
||||
final answer = find.ancestor(
|
||||
of: inField('cat'),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final decoration =
|
||||
tester.widget<AnimatedContainer>(answer.first).decoration!
|
||||
as BoxDecoration;
|
||||
expect(decoration.color, Colors.transparent);
|
||||
});
|
||||
|
||||
testWidgets('leaving with a card that is incomplete asks first', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue