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

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 {