Skip to content
Closed
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
11 changes: 11 additions & 0 deletions packages/mix_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
43 changes: 31 additions & 12 deletions packages/mix_generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S> 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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -40,7 +47,8 @@ class MixWidgetBuilder {
buffer,
'${model.widgetName}.${variant.name}',
constructorParams,
initializer: '$variantParamName = ${variant.valueCode}',
initializer:
'${MixWidgetModel.variantFieldName} = ${variant.valueCode}',
);
buffer.writeln();
}
Expand Down Expand Up @@ -101,8 +109,14 @@ class MixWidgetBuilder {
return '${required}this.${param.name}$defaultClause';
}

/// Emits `final <type> <name>;` for each parameter.
/// Emits `final <type> <name>;` 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();
Expand Down Expand Up @@ -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 <String>[]
: ['${model.variantParamName}: this.${MixWidgetModel.variantFieldName}'];

return [...positional, ...named].join(', ');
return [...positional, ...variant, ...named].join(', ');
}

/// Renders the lines passed to `.call(...)`.
Expand All @@ -201,7 +220,6 @@ class MixWidgetBuilder {
);

_writeConstructor(buffer);
buffer.writeln();

_writeVariantConstructors(buffer);

Expand Down
22 changes: 20 additions & 2 deletions packages/mix_generator/lib/src/core/models/mix_widget_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<WidgetVariantConstructor> variantConstructors;

/// Private field the named constructors pin the selected enum value into.
static const variantFieldName = '_variant';

const MixWidgetModel({
required this.widgetName,
required this.factoryReference,
Expand All @@ -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 '';

Expand Down
28 changes: 22 additions & 6 deletions packages/mix_generator/lib/src/mix_widget_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,29 @@ class MixWidgetGenerator extends GeneratorForAnnotation<MixWidget> {
? const <WidgetCallTypeParam>[]
: _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(
Expand All @@ -268,14 +281,17 @@ class MixWidgetGenerator extends GeneratorForAnnotation<MixWidget> {
),
factoryReference: functionName,
isFunctionFactory: true,
factoryParams: factoryParams,
factoryParams: publicFactoryParams,
callParams: callParams,
callTypeParams: callTypeParams,
stylerCallForwardsKey: call.forwardsKey,
targetTypeReference: callSource.targetTypeReference,
targetConstructorName: callSource.targetConstructorName,
doc: function.documentationComment,
variantParamName: variantConstructors == null ? null : _variantParamName,
variantFieldTypeCode: variantConstructors == null
? null
: variantParam?.typeCode,
variantConstructors: variantConstructors ?? const [],
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/mix_generator/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -288,6 +281,7 @@ void main() {
],
stylerCallForwardsKey: true,
variantParamName: 'variant',
variantFieldTypeCode: 'ButtonVariant',
variantConstructors: [
WidgetVariantConstructor(
name: 'solid',
Expand All @@ -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);
Expand All @@ -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', () {
Expand All @@ -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',
Expand All @@ -352,7 +344,7 @@ void main() {
contains(
" @Deprecated('Use solid instead.')\n"
' const Button.legacy({super.key}) '
': variant = ButtonVariant.legacy;',
': _variant = ButtonVariant.legacy;',
),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1291,14 +1291,16 @@ RadioStyler fortalRadioStyle({Variant variant = Variant.surface}) =>
contains(
'class FortalRadio<T extends RadioValue> 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<T> values;'),
contains('return fortalRadioStyle('),
contains('variant: this.variant'),
contains('variant: this._variant'),
contains('.call<T>('),
contains('value: this.value'),
contains('values: this.values'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]),
);
Expand Down Expand Up @@ -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;'),
Expand All @@ -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,'),
Expand Down
Loading
Loading