diff --git a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart index 5ba410bf..0fce385b 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_block.dart @@ -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)) diff --git a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart index 44bd00e7..b8b45455 100644 --- a/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart +++ b/packages/sanity/flutter_sanity_portable_text/lib/ui/portable_text_config.dart @@ -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, diff --git a/packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart b/packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart new file mode 100644 index 00000000..69b5c087 --- /dev/null +++ b/packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart @@ -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(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(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(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); + }); + }); +}