Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ class PortableTextBlock extends StatelessWidget {
/// The model representing the block of Portable Text.
final TextBlockItem model;

const PortableTextBlock({super.key, required this.model});
/// Optional configuration override for this block.
///
/// When null, falls back to [PortableTextConfig.shared].
/// Use [PortableTextConfig.copyWith] to create a scoped config with
/// overrides (e.g. custom [PortableTextConfig.itemPadding]).
final PortableTextConfig? config;

const PortableTextBlock({super.key, required this.model, this.config});

@override
Widget build(final BuildContext context) {
final config = PortableTextConfig.shared;
final config = this.config ?? PortableTextConfig.shared;

final spans = model.children
.map((final span) => _buildInlineSpan(span, Theme.of(context), context))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ final class PortableTextConfig {

PortableTextConfig._();

/// Creates a copy of this [PortableTextConfig] but with the given fields
/// replaced with new values.
///
/// This is useful when you need a scoped configuration for a specific
/// [PortableTextBlock] without mutating the global [shared] instance.
/// For example, rendering blocks inside a [Row] where the default
/// [itemPadding] would throw off cross-axis alignment.
PortableTextConfig copyWith({
EdgeInsets? itemPadding,
double? listIndent,
BulletRenderer? bulletRenderer,
TextStyle? Function(BuildContext)? baseStyle,
}) {
final copy = PortableTextConfig._();
copy.styles.addAll(styles);
copy.blocks.addAll(blocks);
copy.blockContainers.addAll(blockContainers);
copy.markDefs.addAll(markDefs);
copy.baseStyle = baseStyle ?? this.baseStyle;
copy.bulletRenderer = bulletRenderer ?? this.bulletRenderer;
copy.itemPadding = itemPadding ?? this.itemPadding;
copy.listIndent = listIndent ?? this.listIndent;
return copy;
}

/// Applies the custom configuration to the shared instance of the [PortableTextConfig].
void apply({
final double listIndent = defaultListIndent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_sanity_portable_text/flutter_sanity_portable_text.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
setUp(() {
PortableTextConfig.shared.reset();
});

group('PortableTextConfig.copyWith', () {
test('copies all values from source by default', () {
PortableTextConfig.shared.apply(
itemPadding: const EdgeInsets.all(16),
listIndent: 32,
);

final scoped = PortableTextConfig.shared.copyWith();

expect(scoped.itemPadding, const EdgeInsets.all(16));
expect(scoped.listIndent, 32);
expect(scoped.styles.keys, PortableTextConfig.shared.styles.keys);
expect(scoped.blocks.keys, PortableTextConfig.shared.blocks.keys);
expect(scoped.blockContainers.keys,
PortableTextConfig.shared.blockContainers.keys);
});

test('overrides itemPadding when specified', () {
PortableTextConfig.shared.apply(
itemPadding: const EdgeInsets.only(bottom: 8),
);

final scoped = PortableTextConfig.shared.copyWith(
itemPadding: EdgeInsets.zero,
);

expect(scoped.itemPadding, EdgeInsets.zero);
expect(PortableTextConfig.shared.itemPadding,
const EdgeInsets.only(bottom: 8));
});

test('overrides listIndent when specified', () {
final scoped = PortableTextConfig.shared.copyWith(
listIndent: 40,
);

expect(scoped.listIndent, 40);
expect(PortableTextConfig.shared.listIndent,
PortableTextConfig.defaultListIndent);
});

test('does not mutate the source config', () {
final original = PortableTextConfig.shared;
final originalPadding = original.itemPadding;
final originalIndent = original.listIndent;

original.copyWith(
itemPadding: const EdgeInsets.all(99),
listIndent: 99,
);

expect(original.itemPadding, originalPadding);
expect(original.listIndent, originalIndent);
});
});

group('PortableTextBlock with scoped config', () {
testWidgets('uses scoped config itemPadding instead of shared',
(WidgetTester tester) async {
PortableTextConfig.shared.itemPadding = const EdgeInsets.only(bottom: 10);

final scoped = PortableTextConfig.shared.copyWith(
itemPadding: EdgeInsets.zero,
);

final block = TextBlockItem(children: [
Span(text: 'Hello, World'),
]);

await tester.pumpWidget(MaterialApp(
home: PortableTextBlock(model: block, config: scoped),
));

final padding = tester.widget<Padding>(find.byType(Padding));
expect(padding.padding, EdgeInsets.zero);
});

testWidgets('falls back to shared config when no scoped config is provided',
(WidgetTester tester) async {
PortableTextConfig.shared.itemPadding = const EdgeInsets.only(bottom: 12);

final block = TextBlockItem(children: [
Span(text: 'Hello, World'),
]);

await tester.pumpWidget(MaterialApp(
home: PortableTextBlock(model: block),
));

final padding = tester.widget<Padding>(find.byType(Padding));
expect(padding.padding, const EdgeInsets.only(bottom: 12));
});

testWidgets(
'scoped config with zero padding in Row enables correct center alignment',
(WidgetTester tester) async {
final scoped = PortableTextConfig.shared.copyWith(
itemPadding: EdgeInsets.zero,
);

final block = TextBlockItem(children: [
Span(text: 'Hello, World'),
]);

await tester.pumpWidget(MaterialApp(
home: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.star, size: 32),
const SizedBox(width: 16),
Expanded(
child: PortableTextBlock(model: block, config: scoped),
),
],
),
));

// Verify the block's padding is zero — no trailing space to
// throw off the Row's cross-axis center alignment.
final padding = tester.widget<Padding>(find.byType(Padding));
expect(padding.padding, EdgeInsets.zero);

// Verify the icon and text are vertically centered in the Row.
final iconCenter = tester.getCenter(find.byIcon(Icons.star));
final textCenter =
tester.getCenter(find.text('Hello, World', findRichText: true));
expect(iconCenter.dy, textCenter.dy);
});
});
}
Loading