Skip to content

feat(mix_generator): generate named-only MixWidget constructors from enum variants#999

Open
leoafarias wants to merge 4 commits into
mainfrom
fix/998
Open

feat(mix_generator): generate named-only MixWidget constructors from enum variants#999
leoafarias wants to merge 4 commits into
mainfrom
fix/998

Conversation

@leoafarias

@leoafarias leoafarias commented Jul 24, 2026

Copy link
Copy Markdown
Member

Closes #998.

Summary

Adds @MixWidget(variants:), which derives a widget's constructors from an enum-backed NamedVariant domain instead of a factory parameter. The generated wrapper has no unnamed constructor, no public variant: argument, and no public variant field.

enum FortalButtonVariant with EnumVariant { solid, soft, outline }

@MixWidget(target: RemixButton.new, variants: FortalButtonVariant)
BoxStyler fortalButtonStyle({FortalButtonSize size = .size2}) {
  return _base(size).variantsFromEnum(
    FortalButtonVariant.values,
    (variant) => switch (variant) {
      .solid => _solidStyle(),
      .soft => _softStyle(),
      .outline => _outlineStyle(),
    },
  );
}

FortalButton.solid(size: .size2, label: 'Save');
FortalButton.soft(size: .size3, label: 'Save');

Generated output (verified against the real packages, not stubs):

class FortalButton extends StatelessWidget {
  const FortalButton.solid({super.key, this.size = FortalButtonSize.size2, required this.label})
    : _variant = FortalButtonVariant.solid;
  // ...one per value

  final FortalButtonVariant _variant;
  final FortalButtonSize size;
  final String label;

  @override
  Widget build(BuildContext context) => RemixButton(
        key: this.key,
        style: fortalButtonStyle(size: this.size).applyVariant(this._variant),
        label: this.label,
      );
}

The value is stored privately because Dart forbids privately named parameters — that is precisely what makes the widget selectable only through its named constructors.

Design note: explicit annotation instead of a naming convention

The issue proposed discovering the enum by convention (same library, named <WidgetName>Variant). I implemented an explicit variants: field instead, because the convention is not actually opt-in: any existing user who happens to have enum CardVariant with EnumVariant beside cardStyle() would silently lose Card(...) on upgrade — a breaking public-API change with no source change on their side. The convention also forces the enum into the same library as the recipe, which fights the usual separate variants.dart split.

The explicit field costs one annotation parameter and makes "unchanged when it does not apply" true by construction rather than by luck.

Runtime API in mix

  • variantsFromEnum(values, builder) — registers a VariantStyle per enum value. The builder must return a style for every value, so a switch expression makes coverage exhaustive at compile time.
  • applyVariant(NamedVariant) — applies one variant and throws when none is registered. applyVariants (plural) keeps its silent no-op semantics.

The throw is the load-bearing half of "missing variant-style mappings produce a clear failure" — previously unreachable, since applyVariants returns the base style unchanged when nothing matches. Paired with variantsFromEnum, "unregistered" reliably means "genuinely forgotten". A deliberately unstyled variant registers an empty styler.

Validation

variants: is validated rather than silently skipped. The type must be an enum, mix in EnumVariant, be visible from the annotated library, have at least one reachable value, avoid colliding with generated widget type parameters, and not be combined with a variant factory parameter.

This differs deliberately from the legacy convention, which degrades silently on a type-parameter collision to stay non-breaking. Here, degrading would emit an unnamed-constructor widget — the opposite of what was asked for — so it fails.

Backwards compatibility

The existing variant factory-parameter convention is untouched: unnamed constructor, public field, forwarding to the recipe. Both modes are tested and coexist in one library. A byte-identical-output regression test guards the no-variant path.

Consolidation

  • Collapsed the coupled variantParamName + variantConstructors model fields into one nullable WidgetVariantDomain, so the "name is set iff constructors exist" invariant holds by construction. Net field count unchanged; the two modes differ by exactly one documented bit.
  • Split discovery from enumeration so both modes share _variantConstructorsFor (two real consumers), while callers decide what a collision means.
  • The builder needed only three one-line conditionals.

Verification

  • 2760 mix / 354 mix_generator / 14 mix_annotations tests pass; packages/mix analyzes clean.
  • Beyond the stub-based generator tests, a throwaway package was built against the real packages and run through build_runner: generated output matched the target exactly, flutter analyze was clean, and widget tests confirmed .solid/.soft render different variants and that a missing mapping throws StateError at build time.

Incidental fix

The shared widget test stub's framework.dart re-exported key.dart without importing it, so Widget.key resolved to InvalidType and every super.key chained through it did too. Existing tests missed this because their stylers declared Key? key directly. It blocked all direct-target fixtures built on the shared stub; the fix is one import.

Note on branch name

The branch is fix/998 as requested, though the change is additive and all three changelog entries are tagged FEATfeat/998 would match the repo convention. Flagging in case the branch name matters to release tooling.

…enum variants

Add `@MixWidget(variants:)`, which derives a widget's constructors from an
`EnumVariant` enum instead of a factory parameter. The generated wrapper has
no unnamed constructor, no public `variant:` argument, and no public variant
field: each named constructor pins one enum value into a private field, and
`build()` applies it to the recipe result with `applyVariant` before the style
reaches the target widget.

Supporting runtime API in `mix`:

- `variantsFromEnum` registers a `VariantStyle` for every enum value. Its
  builder must return a style for each value, so a `switch` expression makes
  variant coverage exhaustive at compile time.
- `applyVariant` applies a single `NamedVariant` and throws when none is
  registered, so a forgotten mapping fails loudly instead of silently
  resolving to the base style. `applyVariants` keeps its no-op semantics.

`variants:` is validated rather than silently skipped: the type must be an
enum, mix in `EnumVariant`, be visible from the annotated library, have at
least one reachable value, avoid colliding with generated widget type
parameters, and not be combined with a `variant` factory parameter.

The existing `variant` factory-parameter convention is unchanged, including
its unnamed constructor and public field. Both modes are covered by tests and
can coexist in one library.

Consolidate the coupled `variantParamName`/`variantConstructors` model fields
into a single nullable `WidgetVariantDomain`, so the "name is set iff
constructors exist" invariant holds by construction, and share enum-constant
enumeration between the two discovery paths.

Also fix the shared widget test stub, whose `framework.dart` re-exported
`key.dart` without importing it. `Widget.key` therefore resolved to
InvalidType and every `super.key` chained through it did too, which blocked
direct-target fixtures built on the shared stub.
Apply `dart format` to three files flagged by `format:check`, and make the
`MixWidget.variants` test fixture enum public so its constants are not
reported as unused private fields.
Address findings from an independent review of the `variants:` feature.

Model the variant domain as a sealed hierarchy instead of one class whose
mode was inferred from `declaredTypeCode != null`. `ForwardedVariantDomain`
and `OwnedVariantDomain` state their shape directly, so the builder switches
on type rather than re-deriving a boolean at four call sites, and an
inconsistent combination is no longer constructible.

Reject a styler that does not declare `applyVariant`. It comes from
`VariantStyleMixin`, not from `Style<S>`, so a hand-rolled styler previously
satisfied `@MixWidget` and then failed to compile inside the generated part
instead of reporting against the annotation.

Compare domains rather than parameter names when detecting a conflict with
the `variant` factory-parameter convention. A parameter merely named
`variant` that is positional, nullable, or non-enum never backs a domain, so
it was being rejected with an inaccurate message; it now forwards normally
alongside the private variant field.

Reject a parameter named `_variant`, which would declare the private field a
second time. Dart only forbids privately named parameters when they are
named, so a positional one reached the generated part.

Cover the paths that previously had only builder-level unit tests:
variable-backed factories, the styler `call()` path without `target:`, and
each new validation.
…ection

Address an adversarial review of the `variants:` feature.

Move the changelog entries off already-published versions. `mix` and
`mix_annotations` 2.2.0-beta.1 and `mix_generator` 2.2.0-beta.2 are on
pub.dev and cannot be republished, so their artifacts would never contain
these APIs. Each entry moves to the next unpublished version and its pubspec
is bumped to match. Raise `mix_generator`'s `mix_annotations` lower bound to
the release declaring `MixWidget.variants`, and document that generated code
needs `mix` 2.2.0-beta.2 for `applyVariant`.

Detect a conflicting `variant` factory parameter before curation.
`factoryParameters: .only({})` hid the parameter from the curated list, so
the conflict went unreported: the recipe resolved its own default variant and
the generated widget applied a second one on top of it. Detection now reads
the function's declared parameters, while the forwarded domain still requires
the parameter to survive curation.

Match `applyVariant` on `Variant.key` rather than `==`. Dart forbids enums
from overriding `==`, so `NamedVariant('solid') == MyVariant.solid` was true
while the reverse was false, making a strict lookup depend on operand order.

Qualify the exhaustiveness claim: `variantsFromEnum` covers the whole enum
only when passed `E.values`, and a subset leaves the rest to `applyVariant`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate named-only @MixWidget constructors from enum-backed NamedVariants

1 participant