From 96083125f0e13e39f0ee2f3c6f6c42b0011d07f8 Mon Sep 17 00:00:00 2001 From: Joshua Strobl Date: Mon, 16 Mar 2026 11:47:38 +0200 Subject: [PATCH 1/2] feat(flutter_sanity_portable_text): support scoped PortableTextConfig per block PortableTextBlock always reads from the global PortableTextConfig.shared singleton, which means properties like itemPadding cannot be overridden per-instance. I ran into this when I was trying to render blocks inside a Row with CrossAxisAlignment.center. The default 8px bottom itemPadding inflates each block's layout height, causing the cross-axis center to be calculated against the padded height rather than the visible text. I checked using Flutter's debugDumpApp widget tree output, which showed the padding persisting regardless of attempts to temporarily mutate the shared config, since PortableTextBlock.build() reads the config at framework build time, not at widget creation time. Added PortableTextConfig.from() factory constructor that creates a new config instance by copying an existing one with optional overrides. Added an optional config parameter to PortableTextBlock so callers can pass a scoped config instead of relying on the shared singleton. This means the PortableTextBlock styling can more easily differ across multiple instances in an app. Not specify will use the default shared config, so there should be no functional regressions. Just in case I added a bunch of tests. --- .../lib/ui/portable_text_block.dart | 11 +- .../lib/ui/portable_text_config.dart | 26 ++++ .../test/scoped_config_test.dart | 144 ++++++++++++++++++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart 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..e62ec3cb 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.from] 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..15f712e3 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,32 @@ final class PortableTextConfig { PortableTextConfig._(); + /// Creates a new [PortableTextConfig] by copying values from [source] + /// with optional overrides. + /// + /// 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. + factory PortableTextConfig.from( + PortableTextConfig source, { + EdgeInsets? itemPadding, + double? listIndent, + BulletRenderer? bulletRenderer, + TextStyle? Function(BuildContext)? baseStyle, + }) { + final copy = PortableTextConfig._(); + copy.styles.addAll(source.styles); + copy.blocks.addAll(source.blocks); + copy.blockContainers.addAll(source.blockContainers); + copy.markDefs.addAll(source.markDefs); + copy.baseStyle = baseStyle ?? source.baseStyle; + copy.bulletRenderer = bulletRenderer ?? source.bulletRenderer; + copy.itemPadding = itemPadding ?? source.itemPadding; + copy.listIndent = listIndent ?? source.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..2cc5d114 --- /dev/null +++ b/packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart @@ -0,0 +1,144 @@ +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.from', () { + test('copies all values from source by default', () { + PortableTextConfig.shared.apply( + itemPadding: const EdgeInsets.all(16), + listIndent: 32, + ); + + final scoped = PortableTextConfig.from(PortableTextConfig.shared); + + 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.from( + PortableTextConfig.shared, + 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.from( + PortableTextConfig.shared, + 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; + + PortableTextConfig.from( + original, + 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.from( + PortableTextConfig.shared, + 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.from( + PortableTextConfig.shared, + 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); + }); + }); +} From f186e0f390a84e5ccb38733979821a4e6ae1fd00 Mon Sep 17 00:00:00 2001 From: Joshua Strobl Date: Tue, 17 Mar 2026 10:57:45 +0200 Subject: [PATCH 2/2] fix(flutter_sanity_portable_text): implement copyWith instead of a new factory --- .../lib/ui/portable_text_block.dart | 2 +- .../lib/ui/portable_text_config.dart | 23 +++++++++---------- .../test/scoped_config_test.dart | 19 ++++++--------- 3 files changed, 19 insertions(+), 25 deletions(-) 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 e62ec3cb..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 @@ -17,7 +17,7 @@ class PortableTextBlock extends StatelessWidget { /// Optional configuration override for this block. /// /// When null, falls back to [PortableTextConfig.shared]. - /// Use [PortableTextConfig.from] to create a scoped config with + /// Use [PortableTextConfig.copyWith] to create a scoped config with /// overrides (e.g. custom [PortableTextConfig.itemPadding]). final PortableTextConfig? config; 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 15f712e3..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,29 +73,28 @@ final class PortableTextConfig { PortableTextConfig._(); - /// Creates a new [PortableTextConfig] by copying values from [source] - /// with optional overrides. + /// 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. - factory PortableTextConfig.from( - PortableTextConfig source, { + PortableTextConfig copyWith({ EdgeInsets? itemPadding, double? listIndent, BulletRenderer? bulletRenderer, TextStyle? Function(BuildContext)? baseStyle, }) { final copy = PortableTextConfig._(); - copy.styles.addAll(source.styles); - copy.blocks.addAll(source.blocks); - copy.blockContainers.addAll(source.blockContainers); - copy.markDefs.addAll(source.markDefs); - copy.baseStyle = baseStyle ?? source.baseStyle; - copy.bulletRenderer = bulletRenderer ?? source.bulletRenderer; - copy.itemPadding = itemPadding ?? source.itemPadding; - copy.listIndent = listIndent ?? source.listIndent; + 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; } 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 index 2cc5d114..69b5c087 100644 --- a/packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart +++ b/packages/sanity/flutter_sanity_portable_text/test/scoped_config_test.dart @@ -7,14 +7,14 @@ void main() { PortableTextConfig.shared.reset(); }); - group('PortableTextConfig.from', () { + group('PortableTextConfig.copyWith', () { test('copies all values from source by default', () { PortableTextConfig.shared.apply( itemPadding: const EdgeInsets.all(16), listIndent: 32, ); - final scoped = PortableTextConfig.from(PortableTextConfig.shared); + final scoped = PortableTextConfig.shared.copyWith(); expect(scoped.itemPadding, const EdgeInsets.all(16)); expect(scoped.listIndent, 32); @@ -29,8 +29,7 @@ void main() { itemPadding: const EdgeInsets.only(bottom: 8), ); - final scoped = PortableTextConfig.from( - PortableTextConfig.shared, + final scoped = PortableTextConfig.shared.copyWith( itemPadding: EdgeInsets.zero, ); @@ -40,8 +39,7 @@ void main() { }); test('overrides listIndent when specified', () { - final scoped = PortableTextConfig.from( - PortableTextConfig.shared, + final scoped = PortableTextConfig.shared.copyWith( listIndent: 40, ); @@ -55,8 +53,7 @@ void main() { final originalPadding = original.itemPadding; final originalIndent = original.listIndent; - PortableTextConfig.from( - original, + original.copyWith( itemPadding: const EdgeInsets.all(99), listIndent: 99, ); @@ -71,8 +68,7 @@ void main() { (WidgetTester tester) async { PortableTextConfig.shared.itemPadding = const EdgeInsets.only(bottom: 10); - final scoped = PortableTextConfig.from( - PortableTextConfig.shared, + final scoped = PortableTextConfig.shared.copyWith( itemPadding: EdgeInsets.zero, ); @@ -107,8 +103,7 @@ void main() { testWidgets( 'scoped config with zero padding in Row enables correct center alignment', (WidgetTester tester) async { - final scoped = PortableTextConfig.from( - PortableTextConfig.shared, + final scoped = PortableTextConfig.shared.copyWith( itemPadding: EdgeInsets.zero, );