fix(app): study faces centred in the card

The read-only view passed its alignment to the text alone, which the
editor lays out only as wide as itself, so a centred face stayed at the
start. It now aligns top-level paragraphs and headings that have no
alignment of their own; lists, quotes and the rest keep the start, and
an alignment chosen in the format bar wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 14:14:39 +02:00
commit 7c00abcc05
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 135 additions and 22 deletions

View file

@ -88,31 +88,21 @@ class const EditorBlocksScope({
/// English.
Map<String, BlockComponentBuilder> buildersWithUnknown(
EditorBlocks blocks,
AppLocalizations l10n, {
TextAlign? textAlign,
}) => {
AppLocalizations l10n,
) => {
for (final MapEntry(key: type, value: builder) in blocks.builders.entries)
type: _withPlaceholder(builder, l10n, textAlign),
type: _withPlaceholder(builder, l10n),
errorBlockComponentBuilderKey: _UnknownBlockBuilder(),
};
/// [builder] alike, but saying what its block is in [l10n]'s language while
/// empty, for the editor's own text blocks, and aligning their text as
/// [textAlign] says where the text doesn't say otherwise; others as they
/// are.
/// empty, for the editor's own text blocks; others as they are.
BlockComponentBuilder _withPlaceholder(
BlockComponentBuilder builder,
AppLocalizations l10n,
TextAlign? textAlign,
) {
BlockComponentConfiguration saying(String Function(Node node) text) =>
builder.configuration.copyWith(
placeholderText: text,
textAlign: switch (textAlign) {
final align? => (_) => align,
null => null,
},
);
builder.configuration.copyWith(placeholderText: text);
return switch (builder) {
final ParagraphBlockComponentBuilder paragraph =>
ParagraphBlockComponentBuilder(

View file

@ -24,7 +24,9 @@ class const RichContentView({
/// The text's style; the theme's `bodyLarge` without.
final TextStyle? textStyle,
/// How the text aligns where it doesn't say itself; at the start without.
/// How paragraphs and headings align where the editor gave them no
/// alignment of their own; at the start without. Lists, quotes and the
/// other blocks keep the start: centred, a list's markers drift apart.
final TextAlign? textAlign,
super.key,
}) extends StatefulWidget {
@ -33,16 +35,17 @@ class const RichContentView({
}
class _RichContentViewState() extends State<RichContentView> {
late EditorState _editorState = _readOnly(widget.content);
late EditorState _editorState = _readOnly(widget.content, widget.textAlign);
@override
void didUpdateWidget(RichContentView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.content != oldWidget.content) {
if (widget.content != oldWidget.content ||
widget.textAlign != oldWidget.textAlign) {
// The old editor is still attached until this frame is built.
final previous = _editorState;
WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
_editorState = _readOnly(widget.content);
_editorState = _readOnly(widget.content, widget.textAlign);
}
}
@ -68,7 +71,6 @@ class _RichContentViewState() extends State<RichContentView> {
blockComponentBuilders: buildersWithUnknown(
blocksOf(context),
AppLocalizations.of(context),
textAlign: widget.textAlign,
),
editable: false,
disableSelectionService: true,
@ -78,5 +80,25 @@ class _RichContentViewState() extends State<RichContentView> {
);
}
EditorState _readOnly(RichContent content) =>
EditorState(document: content.toDocument())..editable = false;
/// [content] to show, its top-level paragraphs and headings aligned as
/// [textAlign] says where they don't say themselves. The editor lays a text
/// block's text out only as wide as it is, so aligning the text alone moves
/// nothing: the block's own `align` places it. The document is never saved.
EditorState _readOnly(RichContent content, TextAlign? textAlign) {
final document = content.toDocument();
final align = switch (textAlign) {
TextAlign.center => 'center',
TextAlign.right => 'right',
_ => null,
};
if (align != null) {
for (final node in document.root.children) {
if ((node.type == ParagraphBlockKeys.type ||
node.type == HeadingBlockKeys.type) &&
node.attributes[blockComponentAlign] == null) {
node.updateAttributes({blockComponentAlign: align});
}
}
}
return EditorState(document: document)..editable = false;
}

View file

@ -74,4 +74,83 @@ void main() {
expect(tapped, 1);
expect(tester.testTextInput.hasAnyClients, isFalse);
}, variant: TargetPlatformVariant.all());
group('asked to centre', () {
Map<String, Object?> block(
String type,
String text, [
Map<String, Object?> data = const {},
]) => {
'type': type,
'data': {
...data,
'delta': [
{'insert': text},
],
},
};
Future<void> showCentred(
WidgetTester tester,
List<Map<String, Object?>> blocks,
) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: ListView(
children: [
RichContentView(
content: RichContent({
'document': {'type': 'page', 'children': blocks},
}),
textAlign: TextAlign.center,
),
],
),
),
),
);
await tester.pump(const Duration(seconds: 1));
}
double centreOf(WidgetTester tester, String text) =>
tester.getCenter(find.text(text, findRichText: true)).dx;
double leftOf(WidgetTester tester, String text) =>
tester.getTopLeft(find.text(text, findRichText: true)).dx;
testWidgets('centres paragraphs and headings', (tester) async {
await showCentred(tester, [
block('heading', 'Greetings', {'level': 2}),
block('paragraph', 'hola'),
]);
expect(centreOf(tester, 'Greetings'), moreOrLessEquals(200, epsilon: 1));
expect(centreOf(tester, 'hola'), moreOrLessEquals(200, epsilon: 1));
});
testWidgets('leaves a list at the start: centred, its markers would '
'drift apart', (tester) async {
await showCentred(tester, [
block('bulleted_list', 'hola'),
block('bulleted_list', 'buenos días'),
]);
expect(leftOf(tester, 'hola'), lessThan(60));
expect(leftOf(tester, 'buenos días'), leftOf(tester, 'hola'));
});
testWidgets('keeps the alignment a paragraph was given in the editor', (
tester,
) async {
await showCentred(tester, [
block('paragraph', 'hola', {'align': 'left'}),
]);
expect(leftOf(tester, 'hola'), lessThan(40));
});
});
}

View file

@ -138,6 +138,28 @@ void main() {
expect(find.textContaining('cat', findRichText: true), findsNothing);
});
testWidgets('the faces are centred in the card, and a tap on their text '
'shows and hides the answer too', (tester) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
);
await study(tester, width: 400);
final card = tester.getRect(find.byType(StudyCardSurface));
double centreOf(String text) =>
tester.getCenter(find.text(text, findRichText: true)).dx;
expect(centreOf('猫'), moreOrLessEquals(card.center.dx, epsilon: 1));
await tester.tap(find.text('猫', findRichText: true));
await tester.pumpAndSettle();
expect(centreOf('cat'), moreOrLessEquals(card.center.dx, epsilon: 1));
await tester.tap(find.text('cat', findRichText: true));
await tester.pumpAndSettle();
expect(find.textContaining('cat', findRichText: true), findsNothing);
});
testWidgets('on a phone, a card swiped right is rated Good, one let go '
'early springs back', (tester) async {
await cards.create(