From 4bbd2becdeb2c280f20e315cf31e5b6dcd7dd180 Mon Sep 17 00:00:00 2001 From: Lucas Oliveira <62367544+tilucasoli@users.noreply.github.com> Date: Mon, 22 Jan 2024 18:20:49 -0300 Subject: [PATCH] Feature/styled icon supporting decorators (#159) * Create test cases to heritage * Add support for decorator attributes in StyledBox * create a test case to decorators * Add rule that decorator is only applied to the first widget * Update widget_decorator_widget_test.dart * Update the decorators doc. * add logic to remove decorators from inheritedMix in StyledWidget Create a where constructor in MixData * Remove unused functions * Add isInheritable property remove RenderWidgetDecorators when there isnt decorators attributes in style * review the code * Solve comments --- lib/src/core/attribute.dart | 3 + lib/src/core/decorator.dart | 3 + lib/src/core/styled_widget.dart | 2 +- lib/src/factory/mix_provider_data.dart | 25 ++- lib/src/specs/container/box_widget.dart | 30 +-- lib/src/specs/flex/flex_widget.dart | 13 +- lib/src/specs/icon/icon_widget.dart | 11 +- lib/src/specs/text/text_widget.dart | 11 +- lib/src/utils/helper_util.dart | 20 ++ test/helpers/testing_utils.dart | 14 ++ .../widget_decorator_widget_test.dart | 91 ++++++++ test/src/factory/mix_provider_data_test.dart | 38 +++- test/src/widgets/box_test.dart | 202 ++++++++++++++++++ test/src/widgets/styled_icon_test.dart | 95 ++++++++ website/pages/docs/guides/decorators.mdx | 4 + 15 files changed, 537 insertions(+), 25 deletions(-) create mode 100644 test/src/widgets/box_test.dart create mode 100644 test/src/widgets/styled_icon_test.dart diff --git a/lib/src/core/attribute.dart b/lib/src/core/attribute.dart index a40324a93..450683d09 100644 --- a/lib/src/core/attribute.dart +++ b/lib/src/core/attribute.dart @@ -9,6 +9,9 @@ abstract class Attribute with Comparable { // Used as a merge type Object get type; + + // Used to determine if the attribute is inheritable + bool get isInheritable => true; } @immutable diff --git a/lib/src/core/decorator.dart b/lib/src/core/decorator.dart index 2289c6fc6..ce9ebe831 100644 --- a/lib/src/core/decorator.dart +++ b/lib/src/core/decorator.dart @@ -13,6 +13,9 @@ abstract class Decorator> extends StyleAttribute { @override Object get type => Self; + @override + bool get isInheritable => false; + Widget build(MixData mix, Widget child); } diff --git a/lib/src/core/styled_widget.dart b/lib/src/core/styled_widget.dart index cfe4280d3..60bad9800 100644 --- a/lib/src/core/styled_widget.dart +++ b/lib/src/core/styled_widget.dart @@ -37,7 +37,7 @@ abstract class StyledWidget extends StatelessWidget { /// This method is typically used in the `build` method of widgets extending /// [StyledWidget] to provide the actual styled widget. Widget withMix(BuildContext context, MixBuilder builder) { - final inheritedMix = inherit ? MixProvider.maybeOf(context) : null; + final inheritedMix = inherit ? MixData.inherited(context) : null; final mixData = MixData.create(context, style); diff --git a/lib/src/factory/mix_provider_data.dart b/lib/src/factory/mix_provider_data.dart index 35ec15af8..22295191b 100644 --- a/lib/src/factory/mix_provider_data.dart +++ b/lib/src/factory/mix_provider_data.dart @@ -6,6 +6,7 @@ import '../core/attributes_map.dart'; import '../helpers/compare_mixin.dart'; import '../theme/mix_theme.dart'; import '../widgets/pressable/widget_state_util.dart'; +import 'mix_provider.dart'; import 'style_mix.dart'; /// This class is used for encapsulating all [MixData] related operations. @@ -33,7 +34,25 @@ class MixData with Comparable { final resolver = MixTokenResolver(context); return MixData._( - resolver: resolver, attributes: AttributeMap(attributeList)); + resolver: resolver, + attributes: AttributeMap(attributeList), + ); + } + + static MixData? inherited(BuildContext context) { + final inheritedMix = MixProvider.maybeOf(context); + + if (inheritedMix == null) return null; + + // Remove non-inheritable attributes + final inheritableAttributes = inheritedMix.attributes.values.where( + (attr) => attr.isInheritable, + ); + + return MixData._( + resolver: MixTokenResolver(context), + attributes: AttributeMap(inheritableAttributes), + ); } /// Getter for [MixTokenResolver]. @@ -59,6 +78,10 @@ class MixData with Comparable { return _attributes.whereType(); } + bool contains() { + return _attributes.values.any((attr) => attr is T); + } + Value resolvableOf>(A attribute) { final attributes = _attributes.whereType(); if (attributes.isEmpty) return attribute.resolve(this); diff --git a/lib/src/specs/container/box_widget.dart b/lib/src/specs/container/box_widget.dart index fa1bc99f3..759cbd6b7 100644 --- a/lib/src/specs/container/box_widget.dart +++ b/lib/src/specs/container/box_widget.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import '../../core/styled_widget.dart'; -import '../../decorators/widget_decorator_widget.dart'; import '../../deprecations.dart'; import '../../factory/mix_provider.dart'; import '../../factory/mix_provider_data.dart'; +import '../../utils/helper_util.dart'; import 'box_attribute.dart'; import 'box_spec.dart'; @@ -115,21 +115,23 @@ class MixedBox extends StatelessWidget { final spec = BoxSpec.of(mix); // Apply styles and decorators to the Container, which wraps the child widget. - return RenderWidgetDecorators( + final container = Container( + alignment: spec.alignment, + padding: spec.padding, + decoration: spec.decoration, + width: spec.width, + height: spec.height, + constraints: spec.constraints, + margin: spec.margin, + transform: spec.transform, + clipBehavior: spec.clipBehavior ?? Clip.none, + child: child, + ); + + return shouldApplyDecorators( mix: mix, orderOfDecorators: decoratorOrder, - child: Container( - alignment: spec.alignment, - padding: spec.padding, - decoration: spec.decoration, - width: spec.width, - height: spec.height, - constraints: spec.constraints, - margin: spec.margin, - transform: spec.transform, - clipBehavior: spec.clipBehavior ?? Clip.none, - child: child, - ), + child: container, ); } } diff --git a/lib/src/specs/flex/flex_widget.dart b/lib/src/specs/flex/flex_widget.dart index ffeb8fd76..874041963 100644 --- a/lib/src/specs/flex/flex_widget.dart +++ b/lib/src/specs/flex/flex_widget.dart @@ -5,6 +5,7 @@ import '../../deprecations.dart'; import '../../factory/mix_provider.dart'; import '../../factory/mix_provider_data.dart'; import '../../factory/style_mix.dart'; +import '../../utils/helper_util.dart'; import '../container/box_widget.dart'; import 'flex_spec.dart'; @@ -48,14 +49,16 @@ class StyledFlex extends StyledWidget { class MixedFlex extends StatelessWidget { const MixedFlex({ - this.mix, super.key, + this.mix, + this.decoratorOrder = const [], required this.children, required this.direction, }); final List children; final Axis direction; + final List decoratorOrder; final MixData? mix; @override @@ -64,7 +67,7 @@ class MixedFlex extends StatelessWidget { final spec = FlexSpec.of(mix); final gap = spec.gap; - return Flex( + var flexWidget = Flex( direction: direction, mainAxisAlignment: spec.mainAxisAlignment ?? _defaultFlex.mainAxisAlignment, @@ -75,6 +78,12 @@ class MixedFlex extends StatelessWidget { spec.verticalDirection ?? _defaultFlex.verticalDirection, children: buildChildren(gap), ); + + return shouldApplyDecorators( + mix: mix, + orderOfDecorators: decoratorOrder, + child: flexWidget, + ); } @visibleForTesting diff --git a/lib/src/specs/icon/icon_widget.dart b/lib/src/specs/icon/icon_widget.dart index efcf5ec92..b9bf6da75 100644 --- a/lib/src/specs/icon/icon_widget.dart +++ b/lib/src/specs/icon/icon_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/styled_widget.dart'; import '../../factory/mix_provider.dart'; import '../../factory/mix_provider_data.dart'; +import '../../utils/helper_util.dart'; import 'icon_attribute.dart'; import 'icon_spec.dart'; @@ -39,24 +40,32 @@ class MixedIcon extends StatelessWidget { this.semanticLabel, super.key, this.textDirection, + this.decoratorOrder = const [], }); final IconData? icon; final MixData? mix; final String? semanticLabel; final TextDirection? textDirection; + final List decoratorOrder; @override Widget build(BuildContext context) { final mix = this.mix ?? MixProvider.of(context); final spec = IconSpec.of(mix); - return IconSpecWidget( + final iconWidget = IconSpecWidget( spec: spec, semanticLabel: semanticLabel, textDirection: textDirection, icon: icon, ); + + return shouldApplyDecorators( + mix: mix, + orderOfDecorators: decoratorOrder, + child: iconWidget, + ); } } diff --git a/lib/src/specs/text/text_widget.dart b/lib/src/specs/text/text_widget.dart index 4acf3432e..3dbf30e93 100644 --- a/lib/src/specs/text/text_widget.dart +++ b/lib/src/specs/text/text_widget.dart @@ -4,6 +4,7 @@ import '../../core/directive.dart'; import '../../core/styled_widget.dart'; import '../../factory/mix_provider.dart'; import '../../factory/mix_provider_data.dart'; +import '../../utils/helper_util.dart'; import 'text_spec.dart'; /// [StyledText] - A styled widget for displaying text with a mix of styles. @@ -80,6 +81,7 @@ class MixedText extends StatelessWidget { this.mix, this.semanticsLabel, this.locale, + this.decoratorOrder = const [], super.key, }); @@ -87,6 +89,7 @@ class MixedText extends StatelessWidget { final String? semanticsLabel; final Locale? locale; final MixData? mix; + final List decoratorOrder; @override Widget build(BuildContext context) { @@ -98,7 +101,7 @@ class MixedText extends StatelessWidget { final modifyText = mix.attributeOf(); // The Text widget is used here, applying the resolved styles and properties from TextSpec. - return Text( + final textWidget = Text( modifyText?.apply(text) ?? text, style: spec.style, strutStyle: spec.strutStyle, @@ -113,5 +116,11 @@ class MixedText extends StatelessWidget { textWidthBasis: spec.textWidthBasis, textHeightBehavior: spec.textHeightBehavior, ); + + return shouldApplyDecorators( + mix: mix, + orderOfDecorators: decoratorOrder, + child: textWidget, + ); } } diff --git a/lib/src/utils/helper_util.dart b/lib/src/utils/helper_util.dart index 199279016..f762a9484 100644 --- a/lib/src/utils/helper_util.dart +++ b/lib/src/utils/helper_util.dart @@ -1,3 +1,9 @@ +import 'package:flutter/widgets.dart'; + +import '../core/decorator.dart'; +import '../decorators/widget_decorator_widget.dart'; +import '../factory/mix_provider_data.dart'; + typedef FunctionWithParams = ReturnT Function( List params, ); @@ -35,3 +41,17 @@ class SpreadFunctionParams { ].whereType().toList()); } } + +Widget shouldApplyDecorators({ + required MixData mix, + required Widget child, + List orderOfDecorators = const [], +}) { + return mix.contains() + ? RenderWidgetDecorators( + mix: mix, + orderOfDecorators: orderOfDecorators, + child: child, + ) + : child; +} diff --git a/test/helpers/testing_utils.dart b/test/helpers/testing_utils.dart index ab940be9d..a9917dcac 100644 --- a/test/helpers/testing_utils.dart +++ b/test/helpers/testing_utils.dart @@ -299,3 +299,17 @@ class CustomWidgetDecorator extends WidgetDecorator { @override get props => []; } + +class WidgetWithTestableBuild extends StyledWidget { + const WidgetWithTestableBuild(this.onBuild, {super.key}); + + final void Function(BuildContext context) onBuild; + + @override + Widget build(BuildContext context) { + return withMix(context, (_) { + onBuild(context); + return const SizedBox(); + }); + } +} diff --git a/test/src/decorators/widget_decorator_widget_test.dart b/test/src/decorators/widget_decorator_widget_test.dart index 8b10a67d7..652b84b63 100644 --- a/test/src/decorators/widget_decorator_widget_test.dart +++ b/test/src/decorators/widget_decorator_widget_test.dart @@ -15,6 +15,7 @@ void main() { ); final mixData = MixData.create(MockBuildContext(), style); + group('RenderWidgetDecorators', () { testWidgets('Renders decorators in the correct order', (tester) async { await tester.pumpMaterialApp( @@ -223,4 +224,94 @@ void main() { ); }); }); + + group('Decorators attributes', () { + testWidgets( + 'should be applied to the first one. The children wont inherit even though the second one is set to inherit', + (tester) async { + const key = Key('box'); + + await tester.pumpWidget( + Box( + style: Style( + scale(2.0), + opacity(0.5), + visibility.on(), + clip.oval(), + aspectRatio(2.0), + ), + child: Box( + key: key, + inherit: true, + child: Builder(builder: (context) { + final inheritedMix = MixProvider.maybeOf(context)!; + + expect(inheritedMix.attributes.length, 0); + return const SizedBox(); + }), + ), + ), + ); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Transform), + ), + findsNothing); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Opacity), + ), + findsNothing); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RotatedBox), + ), + findsNothing); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Visibility), + ), + findsNothing); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(AspectRatio), + ), + findsNothing); + }); + + testWidgets( + 'If there are no decorator attributes in Style, RenderWidgetDecorators shouldnt exist in the widget tree', + (tester) async { + const key = Key('box'); + + await tester.pumpWidget( + Box( + key: key, + style: Style( + backgroundColor.red(), + height(100), + width(100), + ), + ), + ); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RenderWidgetDecorators), + ), + findsNothing, + ); + }); + }); } diff --git a/test/src/factory/mix_provider_data_test.dart b/test/src/factory/mix_provider_data_test.dart index bf39c31a3..2fdd7b928 100644 --- a/test/src/factory/mix_provider_data_test.dart +++ b/test/src/factory/mix_provider_data_test.dart @@ -1,9 +1,6 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mix/src/core/attributes_map.dart'; -import 'package:mix/src/factory/mix_provider_data.dart'; -import 'package:mix/src/factory/style_mix.dart'; -import 'package:mix/src/theme/mix_theme.dart'; -import 'package:mix/src/variants/variant.dart'; +import 'package:mix/mix.dart'; import '../../helpers/testing_utils.dart'; @@ -86,4 +83,35 @@ void main() { expect(mergedMixData.attributeOf(), const MockDoubleScalarAttribute(4.0)); }); + + testWidgets('MixData.inherited shouldnt have attributes non inheritable', + (WidgetTester tester) async { + await tester.pumpWidget( + MixProvider( + data: MixData.create( + MockBuildContext(), + Style( + const _NonInheritableAttribute(), + icon.color.black(), + ), + ), + child: Builder(builder: (context) { + final inheritedMix = MixData.inherited(context); + final iconSpec = IconSpec.of(inheritedMix!); + + expect(inheritedMix.attributes.length, 1); + expect(iconSpec.color, Colors.black); + return const SizedBox(); + }), + ), + ); + }); +} + +class _NonInheritableAttribute + extends ScalarAttribute { + const _NonInheritableAttribute() : super(null); + + @override + bool get isInheritable => false; } diff --git a/test/src/widgets/box_test.dart b/test/src/widgets/box_test.dart new file mode 100644 index 000000000..195cebca9 --- /dev/null +++ b/test/src/widgets/box_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +import '../../helpers/testing_utils.dart'; + +void main() { + group('Box', () { + testWidgets('by default should not pass its style through the widget tree', + (tester) async { + await tester.pumpWidget( + Box( + style: Style( + icon.color.black(), + ), + child: Box( + child: WidgetWithTestableBuild((context) { + final inheritedStyle = MixProvider.maybeOf(context); + + expect(inheritedStyle?.attributes.length, 0); + }), + ), + ), + ); + }); + + testWidgets( + 'when the property `inherit` is true should pass its style through the widget tree', + (tester) async { + await tester.pumpWidget( + Box( + style: Style( + icon.color.black(), + ), + child: Box( + inherit: true, + child: Builder(builder: (context) { + final inheritedStyle = MixProvider.maybeOf(context)!; + final iconSpec = IconSpec.of(inheritedStyle); + + expect(inheritedStyle.attributes.length, 1); + expect(iconSpec.color, Colors.black); + return const SizedBox(); + }), + ), + ), + ); + }); + + testWidgets( + 'when the property `inherit` is true and it has its own style, should merge the styles', + (tester) async { + await tester.pumpWidget( + Box( + style: Style( + icon.color.black(), + ), + child: Box( + inherit: true, + style: Style( + box.height(100), + box.width(100), + ), + child: Builder(builder: (context) { + final inheritedStyle = MixProvider.maybeOf(context)!; + final iconSpec = IconSpec.of(inheritedStyle); + final boxSpec = BoxSpec.of(inheritedStyle); + + expect(inheritedStyle.attributes.length, 2); + expect(iconSpec.color, Colors.black); + expect(boxSpec.height, 100); + expect(boxSpec.width, 100); + return const SizedBox(); + }), + ), + ), + ); + }); + + testWidgets( + 'when the property `inherit` is true and it has its own style with different attributes, should merge them', + (tester) async { + await tester.pumpWidget( + Box( + style: Style( + icon.color.black(), + ), + child: Box( + inherit: true, + style: Style( + box.height(100), + box.width(100), + ), + child: Builder(builder: (context) { + final inheritedStyle = MixProvider.maybeOf(context)!; + final iconSpec = IconSpec.of(inheritedStyle); + final boxSpec = BoxSpec.of(inheritedStyle); + + expect(inheritedStyle.attributes.length, 2); + expect(iconSpec.color, Colors.black); + expect(boxSpec.height, 100); + expect(boxSpec.width, 100); + return const SizedBox(); + }), + ), + ), + ); + }); + + testWidgets( + 'when the property `inherit` is true and it has its own style with similar attributes, should merge them', + (tester) async { + await tester.pumpWidget( + Box( + style: Style( + box.height(100), + box.width(50), + ), + child: Box( + inherit: true, + style: Style( + box.width(100), + ), + child: Builder(builder: (context) { + final inheritedStyle = MixProvider.maybeOf(context)!; + final boxSpec = BoxSpec.of(inheritedStyle); + + expect(inheritedStyle.attributes.length, 1); + expect(boxSpec.height, 100); + expect(boxSpec.width, 100); + return const SizedBox(); + }), + ), + ), + ); + }, + ); + + testWidgets( + 'should create a RenderWidgetDecorators widget and all its decorators widgets in the decendant widget tree', + (tester) async { + const key = Key('box'); + + await tester.pumpWidget( + Box( + key: key, + style: Style( + box.height(100), + box.width(50), + scale(1), + opacity(0.5), + rotate(1), + visibility(true), + aspectRatio(1), + ), + ), + ); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RenderWidgetDecorators)), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Transform), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Opacity), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RotatedBox), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Visibility), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(AspectRatio), + ), + findsOneWidget); + }, + ); + }); +} diff --git a/test/src/widgets/styled_icon_test.dart b/test/src/widgets/styled_icon_test.dart new file mode 100644 index 000000000..8b4abcd35 --- /dev/null +++ b/test/src/widgets/styled_icon_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +import '../../helpers/testing_utils.dart'; + +void main() { + group('StyledIcon', () { + testWidgets('should receive a style from its ancestor widget', + (tester) async { + const color = Color(0xffff1744); + const size = 20.0; + + await tester.pumpMaterialApp( + Box( + style: Style( + icon.color(color), + icon.size(size), + ), + child: const StyledIcon( + Icons.access_time_filled_outlined, + ), + ), + ); + + final iconWidget = tester.widget(find.byType(Icon)); + expect(iconWidget.color, color); + expect(iconWidget.size, size); + }); + + testWidgets( + 'should apply decorators when they are defined in the style', + (tester) async { + const key = Key('box'); + + await tester.pumpMaterialApp( + StyledIcon( + Icons.access_time_filled_outlined, + key: key, + style: Style( + box.height(100), + box.width(50), + scale(1), + opacity(0.5), + rotate(1), + visibility(true), + aspectRatio(1), + ), + ), + ); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RenderWidgetDecorators)), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Transform), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Opacity), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RotatedBox), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Visibility), + ), + findsOneWidget); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(AspectRatio), + ), + findsOneWidget); + }, + ); + }); +} diff --git a/website/pages/docs/guides/decorators.mdx b/website/pages/docs/guides/decorators.mdx index 8ad60aa52..0664ca75c 100644 --- a/website/pages/docs/guides/decorators.mdx +++ b/website/pages/docs/guides/decorators.mdx @@ -15,6 +15,10 @@ Transform.scale( ) ``` + + Important Note: Decorators are a special kind of attribute that can't be inherited for any child widgets. + + ### Creating a decorator We can achieve the same effect by creating a custom `**WidgetDecorator**`.