diff --git a/packages/mix_generator/CHANGELOG.md b/packages/mix_generator/CHANGELOG.md index 068f6a031..c72e4af23 100644 --- a/packages/mix_generator/CHANGELOG.md +++ b/packages/mix_generator/CHANGELOG.md @@ -1,3 +1,14 @@ +## 2.2.0-beta.3 + + - **BREAKING** **FEAT**: Generate named-constructors-only `@MixWidget` widgets + from an enum `variant` factory parameter. The widget no longer emits an + unnamed constructor, a public `variant` parameter, or a public `variant` + field: each named constructor pins its enum value into a private `_variant` + field, and `build()` forwards that field back into the recipe's own `variant` + parameter. Recipes are unchanged — they keep selecting the style themselves. + Select a variant dynamically by calling the recipe directly and passing the + result through the target's `style` parameter. + ## 2.2.0-beta.2 - **FEAT**: Generate `@MixWidget(target:)` wrappers for plain Widget diff --git a/packages/mix_generator/README.md b/packages/mix_generator/README.md index acb259ccf..bfc8617f3 100644 --- a/packages/mix_generator/README.md +++ b/packages/mix_generator/README.md @@ -228,24 +228,43 @@ final cardStyle = BoxStyler() The widget name is derived from a lower-camel-case `*Style` identifier (`cardStyle` → `Card`); pass `@MixWidget(name: 'X')` to override. Function-backed factories thread their args before the styler `call()`: `@MixWidget Style badge({Color? color}) => ...` generates a `Badge({this.color, this.child})` constructor. When a function-backed factory declares a named, non-nullable enum parameter -named `variant`, the generator also emits one named constructor per accessible -enum value: +named `variant`, the generated widget is **named-constructors-only**: one +constructor per accessible enum value, and no unnamed constructor. ```dart @MixWidget() -ButtonStyler buttonStyle({ButtonVariant variant = .solid}) => ...; +ButtonStyler buttonStyle({ButtonVariant variant = .solid, int size = 2}) => ...; -// Generates both the backwards-compatible `Button(variant: ...)` and: -// const Button.solid({super.key, ...}) : variant = ButtonVariant.solid; -// const Button.ghost({super.key, ...}) : variant = ButtonVariant.ghost; +// Generates: +// const Button.solid({super.key, this.size = 2, ...}) : _variant = ButtonVariant.solid; +// const Button.ghost({super.key, this.size = 2, ...}) : _variant = ButtonVariant.ghost; +// final ButtonVariant _variant; +// @override Widget build(BuildContext context) => +// buttonStyle(variant: this._variant, size: this.size).call(...); ``` -Each named constructor omits the `variant` argument and preserves every other -factory and styler `call()` parameter. Enum-value documentation and deprecation -metadata are carried onto the matching constructor. Nullable, positional, and -non-enum `variant` parameters retain the single-constructor behavior. Variant -constructors are also skipped when an enum value conflicts with a generated -widget type parameter. +A variant is a recipe discriminator, not widget state, so it is curated off the +widget's public surface: there is no public `variant` parameter and no public +`variant` field. The selected value lives in a private `_variant` field that each +named constructor pins, and `build()` forwards it back into the recipe's own +`variant` parameter — so the recipe keeps whatever `switch (variant)` body it +already had. + +Every other factory and styler `call()` parameter is preserved on each named +constructor, and enum-value documentation and deprecation metadata are carried +onto the matching constructor. Nullable, positional, and non-enum `variant` +parameters are ordinary parameters and retain the single-unnamed-constructor +behavior; the same applies when an enum value collides with a generated widget +type parameter, since named constructors and type parameters share a namespace. + +`variant` must still be *selected* by `factoryParameters` for the constructors to +be generated — selection is what opts the parameter into the widget at all. It is +then curated back off the public surface. Excluding it from `.only(...)` keeps the +pre-existing behavior of a single unnamed constructor that uses the recipe's own +default variant. + +To select a variant dynamically, call the recipe directly and pass the resulting +style to the target widget: `RemixButton(style: buttonStyle(variant: selected))`. See [`mix_annotations`](https://pub.dev/packages/mix_annotations) for the full annotation contract (parameter rules, `Key? key` forwarding, naming/visibility constraints). diff --git a/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart b/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart index db66fd401..643562f45 100644 --- a/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart +++ b/packages/mix_generator/lib/src/core/builders/mix_widget_builder.dart @@ -15,18 +15,25 @@ class MixWidgetBuilder { /// Emits the constructor: positional factory params first, then named /// (`{super.key, ...named factory, ...named call}`). + /// + /// A widget with variant constructors is named-constructors-only: the variant + /// has no public parameter to select it, so an unnamed constructor could only + /// fall back to the recipe's default and would duplicate `.solid()` and + /// friends. This emits nothing for that case. void _writeConstructor(StringBuffer buffer) { + if (model.hasVariantConstructors) return; + _writeConstructorDeclaration(buffer, model.widgetName, model.allParams); + buffer.writeln(); } /// Emits the constructors derived from the convention-matched enum values. void _writeVariantConstructors(StringBuffer buffer) { - final variantParamName = model.variantParamName; - if (variantParamName == null || model.variantConstructors.isEmpty) return; + if (!model.hasVariantConstructors) return; - final constructorParams = model.allParams - .where((param) => param.name != variantParamName) - .toList(); + // The variant is already absent from `allParams` — the generator curates it + // out — so every remaining parameter belongs on each named constructor. + final constructorParams = model.allParams; for (final variant in model.variantConstructors) { if (variant.doc != null) { @@ -40,7 +47,8 @@ class MixWidgetBuilder { buffer, '${model.widgetName}.${variant.name}', constructorParams, - initializer: '$variantParamName = ${variant.valueCode}', + initializer: + '${MixWidgetModel.variantFieldName} = ${variant.valueCode}', ); buffer.writeln(); } @@ -101,8 +109,14 @@ class MixWidgetBuilder { return '${required}this.${param.name}$defaultClause'; } - /// Emits `final ;` for each parameter. + /// Emits `final ;` for each parameter, preceded by the private + /// variant field when the widget has variant constructors. void _writeFields(StringBuffer buffer) { + if (model.variantFieldTypeCode case final typeCode?) { + buffer.writeln(' final $typeCode ${MixWidgetModel.variantFieldName};'); + buffer.writeln(); + } + for (final param in model.allParams) { buffer.writeln(' final ${param.typeCode} ${param.name};'); buffer.writeln(); @@ -173,8 +187,13 @@ class MixWidgetBuilder { final named = model.factoryParams .where((p) => !p.isPositional) .map((p) => '${p.name}: this.${p.name}'); + // The variant is curated off the widget but still selects the recipe's + // style, so it is re-added here from the field the constructor pinned. + final variant = model.variantParamName == null + ? const [] + : ['${model.variantParamName}: this.${MixWidgetModel.variantFieldName}']; - return [...positional, ...named].join(', '); + return [...positional, ...variant, ...named].join(', '); } /// Renders the lines passed to `.call(...)`. @@ -201,7 +220,6 @@ class MixWidgetBuilder { ); _writeConstructor(buffer); - buffer.writeln(); _writeVariantConstructors(buffer); diff --git a/packages/mix_generator/lib/src/core/models/mix_widget_model.dart b/packages/mix_generator/lib/src/core/models/mix_widget_model.dart index eab9df721..8fb9de487 100644 --- a/packages/mix_generator/lib/src/core/models/mix_widget_model.dart +++ b/packages/mix_generator/lib/src/core/models/mix_widget_model.dart @@ -121,13 +121,27 @@ class MixWidgetModel { /// `///` markers intact), or `null` when the element has no doc. final String? doc; - /// Factory parameter initialized by [variantConstructors], or `null` when - /// this widget has no convention-derived variant constructors. + /// Recipe parameter the named constructors select, or `null` when this + /// widget has no convention-derived variant constructors. + /// + /// The parameter is curated off the widget's public surface: the value lives + /// in the private [variantFieldName] field and is forwarded back into this + /// recipe parameter by `build()`. final String? variantParamName; + /// Dart source code for [variantFieldName]'s type, or `null` when this widget + /// has no variant constructors. + /// + /// Carried separately because the variant is absent from [factoryParams], + /// which is where every other field's type comes from. + final String? variantFieldTypeCode; + /// Named constructors derived from the enum values of [variantParamName]. final List variantConstructors; + /// Private field the named constructors pin the selected enum value into. + static const variantFieldName = '_variant'; + const MixWidgetModel({ required this.widgetName, required this.factoryReference, @@ -140,9 +154,13 @@ class MixWidgetModel { this.targetConstructorName, this.doc, this.variantParamName, + this.variantFieldTypeCode, this.variantConstructors = const [], }); + /// Whether the widget is named-constructors-only. + bool get hasVariantConstructors => variantConstructors.isNotEmpty; + String _typeParameterSuffix(String Function(WidgetCallTypeParam) render) { if (callTypeParams.isEmpty) return ''; diff --git a/packages/mix_generator/lib/src/mix_widget_generator.dart b/packages/mix_generator/lib/src/mix_widget_generator.dart index 8cd6b4fac..d1cbcec07 100644 --- a/packages/mix_generator/lib/src/mix_widget_generator.dart +++ b/packages/mix_generator/lib/src/mix_widget_generator.dart @@ -248,16 +248,29 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ? const [] : _extractCallTypeParams(callSource.call, library: library)) : _extractTargetTypeParams(targetConstructor, library: library); - final variantConstructors = - factoryParams.any((parameter) => parameter.name == _variantParamName) - ? _extractVariantConstructors( + final variantParam = factoryParams + .where((parameter) => parameter.name == _variantParamName) + .firstOrNull; + final variantConstructors = variantParam == null + ? null + : _extractVariantConstructors( function, library: library, widgetTypeParameterNames: { for (final typeParameter in callTypeParams) typeParameter.name, }, - ) - : null; + ); + // The named constructors are the only way to select a variant, so the + // parameter itself is curated off the widget: it declares no public field + // and no constructor parameter, and `build()` forwards the private field + // back into the recipe. Curated here rather than in `_extractFactoryParams` + // so `factoryParameters: .only(...)` keeps meaning exactly what it says for + // every other parameter. + final publicFactoryParams = variantConstructors == null + ? factoryParams + : factoryParams + .where((parameter) => parameter.name != _variantParamName) + .toList(); return MixWidgetModel( widgetName: _resolveWidgetName( @@ -268,7 +281,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ), factoryReference: functionName, isFunctionFactory: true, - factoryParams: factoryParams, + factoryParams: publicFactoryParams, callParams: callParams, callTypeParams: callTypeParams, stylerCallForwardsKey: call.forwardsKey, @@ -276,6 +289,9 @@ class MixWidgetGenerator extends GeneratorForAnnotation { targetConstructorName: callSource.targetConstructorName, doc: function.documentationComment, variantParamName: variantConstructors == null ? null : _variantParamName, + variantFieldTypeCode: variantConstructors == null + ? null + : variantParam?.typeCode, variantConstructors: variantConstructors ?? const [], ); } diff --git a/packages/mix_generator/pubspec.yaml b/packages/mix_generator/pubspec.yaml index ea780067b..bd2a20e25 100644 --- a/packages/mix_generator/pubspec.yaml +++ b/packages/mix_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: mix_generator description: A code generator for Mix, an expressive way to effortlessly build design systems in Flutter. -version: 2.2.0-beta.2 +version: 2.2.0-beta.3 homepage: https://github.com/btwld/mix repository: https://github.com/btwld/mix/tree/main/packages/mix_generator diff --git a/packages/mix_generator/test/core/builders/mix_widget_builder_test.dart b/packages/mix_generator/test/core/builders/mix_widget_builder_test.dart index 5f5bf1c2f..6891a3e25 100644 --- a/packages/mix_generator/test/core/builders/mix_widget_builder_test.dart +++ b/packages/mix_generator/test/core/builders/mix_widget_builder_test.dart @@ -257,13 +257,6 @@ void main() { factoryReference: 'buttonStyle', isFunctionFactory: true, factoryParams: [ - WidgetCallParam( - name: 'variant', - typeCode: 'ButtonVariant', - isPositional: false, - isRequired: false, - defaultValueCode: 'ButtonVariant.solid', - ), WidgetCallParam( name: 'size', typeCode: 'int', @@ -288,6 +281,7 @@ void main() { ], stylerCallForwardsKey: true, variantParamName: 'variant', + variantFieldTypeCode: 'ButtonVariant', variantConstructors: [ WidgetVariantConstructor( name: 'solid', @@ -301,7 +295,7 @@ void main() { final code = builder.build(); final constructor = RegExp( r'const Button\.solid\(([\s\S]*?)\)\s*:\s*' - r'variant\s*=\s*ButtonVariant\.solid;', + r'_variant\s*=\s*ButtonVariant\.solid;', ).firstMatch(code); expect(constructor, isNotNull); @@ -317,7 +311,11 @@ void main() { ' const Button.solid(', ), ); - expect(code, contains('final ButtonVariant variant;')); + // No unnamed constructor is emitted for a variant-backed widget. + expect(code, isNot(contains('const Button({'))); + // The variant field is private and has no public counterpart. + expect(code, contains('final ButtonVariant _variant;')); + expect(code, isNot(contains('final ButtonVariant variant;'))); }); test('deprecated variant annotates its generated constructor', () { @@ -326,17 +324,11 @@ void main() { widgetName: 'Button', factoryReference: 'buttonStyle', isFunctionFactory: true, - factoryParams: [ - WidgetCallParam( - name: 'variant', - typeCode: 'ButtonVariant', - isPositional: false, - isRequired: true, - ), - ], + factoryParams: [], callParams: [], stylerCallForwardsKey: false, variantParamName: 'variant', + variantFieldTypeCode: 'ButtonVariant', variantConstructors: [ WidgetVariantConstructor( name: 'legacy', @@ -352,7 +344,7 @@ void main() { contains( " @Deprecated('Use solid instead.')\n" ' const Button.legacy({super.key}) ' - ': variant = ButtonVariant.legacy;', + ': _variant = ButtonVariant.legacy;', ), ); }); diff --git a/packages/mix_generator/test/integration/generator_smoke_test.dart b/packages/mix_generator/test/integration/generator_smoke_test.dart index 091048636..e53455944 100644 --- a/packages/mix_generator/test/integration/generator_smoke_test.dart +++ b/packages/mix_generator/test/integration/generator_smoke_test.dart @@ -1291,14 +1291,16 @@ RadioStyler fortalRadioStyle({Variant variant = Variant.surface}) => contains( 'class FortalRadio extends StatelessWidget', ), + isNot(contains('const FortalRadio({')), contains('const FortalRadio.surface('), - contains('this.variant = Variant.surface'), + contains(': _variant = Variant.surface;'), + contains('final Variant _variant;'), contains('required this.value'), contains('required this.values'), contains('final T value;'), contains('final List values;'), contains('return fortalRadioStyle('), - contains('variant: this.variant'), + contains('variant: this._variant'), contains('.call('), contains('value: this.value'), contains('values: this.values'), diff --git a/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart b/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart index aa89864ce..625f51d8e 100644 --- a/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart +++ b/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart @@ -286,9 +286,11 @@ ButtonStyler buttonStyle({ outputAsset: 'mix|lib/button.g.dart', outputMatcher: allOf([ contains('class Button extends StatelessWidget {'), + isNot(contains('const Button({')), contains('const Button.solid('), contains('const Button.ghost('), - contains(': variant = ButtonVariant.solid;'), + contains(': _variant = ButtonVariant.solid;'), + contains('final ButtonVariant _variant;'), contains('required this.label'), ]), ); @@ -460,13 +462,14 @@ ButtonStyler fortalButtonStyler({ outputAsset: 'mix|lib/button.g.dart', outputMatcher: allOf([ contains('class FortalButton extends StatelessWidget {'), - contains('const FortalButton({'), - contains('this.variant = ButtonVariant.solid,'), + isNot(contains('const FortalButton({')), contains('this.size = ButtonSize.small,'), contains('required this.label,'), contains('const FortalButton.solid('), contains('const FortalButton.ghost('), - contains('final ButtonVariant variant;'), + contains(': _variant = ButtonVariant.solid;'), + contains(': _variant = ButtonVariant.ghost;'), + contains('final ButtonVariant _variant;'), contains('final ButtonSize size;'), contains('final String label;'), contains('final Widget? child;'), @@ -479,7 +482,7 @@ ButtonStyler fortalButtonStyler({ contains('return PlainButton('), contains('key: this.key,'), contains( - 'style: fortalButtonStyler(variant: this.variant, ' + 'style: fortalButtonStyler(variant: this._variant, ' 'size: this.size),', ), contains('label: this.label,'), diff --git a/packages/mix_generator/test/integration/mix_widget_variant_constructor_test.dart b/packages/mix_generator/test/integration/mix_widget_variant_constructor_test.dart index c7fdc5b4b..70fecb4af 100644 --- a/packages/mix_generator/test/integration/mix_widget_variant_constructor_test.dart +++ b/packages/mix_generator/test/integration/mix_widget_variant_constructor_test.dart @@ -87,7 +87,7 @@ ButtonStyler buttonStyle({ final namedConstructorWithoutVariant = predicate((source) { final match = RegExp( - r'const Button\.solid\(([\s\S]*?)\)\s*:\s*variant\s*=\s*' + r'const Button\.solid\(([\s\S]*?)\)\s*:\s*_variant\s*=\s*' r'variants\.ButtonVariant\.solid;', ).firstMatch(source); @@ -123,9 +123,9 @@ ButtonStyler buttonStyle({ inputAsset: 'mix_generator|lib/widget_case.dart', outputAsset: 'mix_generator|lib/widget_case.g.dart', outputMatcher: allOf([ - // The existing unnamed constructor remains available. - contains('const Button({'), - contains('this.variant = variants.ButtonVariant.solid'), + // Variant constructors exist, so no unnamed constructor is emitted. + isNot(contains('const Button({')), + contains('final variants.ButtonVariant _variant;'), contains('this.size = 2'), contains('/// High-emphasis filled button.'), namedConstructorWithoutVariant, @@ -136,7 +136,7 @@ ButtonStyler buttonStyle({ constructorsFollowEnumOrder, contains(r'@Deprecated("Use \$solid instead.")'), contains('const Button.legacy('), - contains(': variant = variants.ButtonVariant.legacy;'), + contains(': _variant = variants.ButtonVariant.legacy;'), ]), ); }, @@ -161,7 +161,10 @@ ButtonStyler buttonStyle({ inputAsset: 'mix_generator|lib/widget_case.dart', outputAsset: 'mix_generator|lib/widget_case.g.dart', outputMatcher: allOf([ - contains('this.variant = ButtonVariant.solid'), + isNot(contains('const Button({')), + contains('final ButtonVariant _variant;'), + contains(': _variant = ButtonVariant.solid;'), + contains(': _variant = ButtonVariant.ghost;'), contains('this.size = 2'), contains('required this.label'), contains('const Button.solid('), @@ -189,9 +192,10 @@ ButtonStyler buttonStyle({required ButtonVariant variant}) => inputAsset: 'mix_generator|lib/widget_case.dart', outputAsset: 'mix_generator|lib/widget_case.g.dart', outputMatcher: allOf([ - contains('required this.variant'), + isNot(contains('this.variant')), + contains('final ButtonVariant _variant;'), contains('const Button.solid('), - contains(': variant = ButtonVariant.solid;'), + contains(': _variant = ButtonVariant.solid;'), ]), ); }); @@ -277,7 +281,7 @@ ButtonStyler buttonStyle({ outputMatcher: allOf([ contains('const Button.solid('), contains('const Button._internal('), - contains(': variant = ButtonVariant._internal;'), + contains(': _variant = ButtonVariant._internal;'), ]), ); });