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:
Tizian 「ティツィアーン」 2026-10-12 15:56:10 +02:00
commit c002a07011
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 441 additions and 185 deletions

View file

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

View file

@ -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,24 +21,61 @@ 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;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (answers.length == 1)
CardFace(
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),
]),
)
else
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: [
for (final (i, answer) in answers.indexed) ...[
if (i > 0) const SizedBox(height: 12),
_AnswerCard(
@ -46,39 +84,27 @@ class const AnswersEditor({
// 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)),
),
],
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),
),
),
],
?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,18 +116,13 @@ 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(
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.
@ -120,8 +141,8 @@ class const _AnswerCard({
),
Switch(
value: answer.correct,
// Kept enabled, so it keeps its colours; turning the last
// right answer wrong does nothing.
// 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));
@ -139,16 +160,6 @@ class const _AnswerCard({
),
],
),
const SizedBox(height: 4),
CardFace(
content: answer.content,
placeholder: l10n.composerAddAnswer,
onChanged: (content) =>
onChanged(answer.copyWith(content: content)),
),
],
),
),
);
}
}

View file

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

View file

@ -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 {
}
}
@override
Widget build(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),
/// 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) => 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;
final accent = widget.accent;
final empty = widget.content.isBlank;
final preview = ClipRect(
key: _preview,
@ -132,42 +269,19 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
child: RichContentView(content: widget.content),
),
);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: () {
setState(() => _expanded = true);
_keepInView();
},
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
final content = empty
? Row(
children: [
Icon(
LucideIcons.plus,
size: 18,
color: colors.onSurfaceVariant,
),
Icon(LucideIcons.plus, size: 18, color: colors.onSurfaceVariant),
const SizedBox(width: 8),
Text(
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
@ -182,7 +296,12 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
if (notification.depth > 0) {
return false;
}
final clipped = notification.metrics.maxScrollExtent > 0;
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);
}
@ -190,6 +309,44 @@ class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
},
child: _clipped ? _Fade(child: preview) : preview,
),
);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: _open,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: double.infinity,
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),
)
: 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,
],
),
),
),

View file

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