diff --git a/.github/README.md b/.github/README.md index 2d8fe06..68e0c7c 100644 --- a/.github/README.md +++ b/.github/README.md @@ -28,11 +28,11 @@ Or add to `pubspec.yaml`: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.13.0 - theme_extensions_builder: ^7.3.0 + theme_extensions_builder: ^7.5.0 ``` ## 🚀 Quick Start @@ -143,6 +143,18 @@ Check out the [example project](../packages/theme_extensions_builder/example) fo - **Custom Components**: Buttons, cards, and typography showcases - **Best Practices**: Real-world organization patterns +## 🛠️ Development + +The repository is a [pub workspace](https://dart.dev/tools/pub/workspaces): one `flutter pub get` at the root resolves every package, including the example app. + +```bash +flutter pub get +scripts/prepare_push.sh # format, analyze, build and test every package +scripts/update_goldens.sh # regenerate the golden files after a generator change +``` + +The generator tests run on the Dart SDK alone. The Flutter classes the fixtures use are small stand-ins in `packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart`, with the same `lerp` and `merge` signatures as the real ones. The example app is where the generated code meets the real framework, so CI regenerates and analyzes it on every push. + ## 📄 License MIT License - see the [LICENSE](../LICENSE) file for details. diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 9427423..3a860e0 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -1,43 +1,107 @@ -name: Dart CI +name: CI on: - # workflow_dispatch: # Only manual trigger push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] + +env: + BUILDER: packages/theme_extensions_builder + ANNOTATION: packages/theme_extensions_builder_annotation + EXAMPLE: packages/theme_extensions_builder/example jobs: test: + name: Test (${{ matrix.deps }} dependencies) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The builder advertises a range of analyzer versions; the lower + # bound is exercised by pinning the analyzer stack to it. + deps: [latest, lowest] steps: - uses: actions/checkout@v4 - - uses: dart-lang/setup-dart@v1 + # The example app is a workspace member, so the workspace is resolved + # with the Flutter SDK. Everything else runs on the Dart SDK it bundles. + - uses: subosito/flutter-action@v2 with: - sdk: stable + channel: stable + cache: true - - name: Install dependencies (annotation) - working-directory: packages/theme_extensions_builder_annotation - run: dart pub get --no-example + - name: Resolve the workspace + run: flutter pub get - - name: Run formatter check (annotation) - working-directory: packages/theme_extensions_builder_annotation - run: dart format --set-exit-if-changed . + # `dart pub downgrade analyzer ...` changes nothing at the workspace + # root, and a plain `dart pub downgrade` also takes the test runner to + # a version that no longer runs on the current SDK. The lower bounds + # are pinned outright instead; everything else resolves around them. + # + # The analyzer floor is the lowest version the test tooling compiles + # against: every `dart_style` the goldens are formatted with needs + # 13.1.0. `build` floats, as the tooling needs 4.x. + - name: Pin the lower bounds + if: matrix.deps == 'lowest' + run: | + cat >> pubspec.yaml <<'EOF' + dependency_overrides: + analyzer: 13.1.0 + source_gen: 4.2.3 + EOF + flutter pub get - - name: Run tests (annotation) - working-directory: packages/theme_extensions_builder_annotation - run: dart test + - name: Check formatting + if: matrix.deps == 'latest' + run: dart format --set-exit-if-changed $BUILDER $ANNOTATION - - name: Install dependencies (builder) - working-directory: packages/theme_extensions_builder - run: dart pub get --no-example + - name: Analyze + run: dart analyze --fatal-infos $BUILDER $ANNOTATION - - name: Run formatter check (builder) - working-directory: packages/theme_extensions_builder - run: dart format --set-exit-if-changed . + - name: Test annotation + working-directory: ${{ env.ANNOTATION }} + run: dart test - - name: Run tests (builder) - working-directory: packages/theme_extensions_builder + - name: Test builder + working-directory: ${{ env.BUILDER }} run: dart test + + generated: + name: Generated files are up to date + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Resolve the workspace + run: flutter pub get + + # The runtime tests import the committed generated files, so a stale + # file would test yesterday's generator. + - name: Regenerate the builder fixtures + working-directory: ${{ env.BUILDER }} + run: dart run build_runner build + + - name: Regenerate the example + working-directory: ${{ env.EXAMPLE }} + run: dart run build_runner build + + # The generator tests run against stubs of the Flutter classes; the + # example is the one place the generated code meets the real framework. + - name: Analyze the example + working-directory: ${{ env.EXAMPLE }} + run: flutter analyze --fatal-infos + + # A generated file that is new is untracked, which `git diff` cannot + # see; adding it to the index without its content makes it show up. + - name: Fail on stale generated files + run: | + git add --intent-to-add -- '*.g.theme.dart' + git diff --exit-code -- '*.g.theme.dart' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b4200a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Workspace resolution lives at the root; the lock file is not published. +.dart_tool/ +/pubspec.lock diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d53b137..07f8702 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,7 +11,7 @@ "clean" ], "options": { - "cwd": "packages/theme_extensions_builder/test" + "cwd": "packages/theme_extensions_builder" }, "group": "build", "presentation": { @@ -36,7 +36,7 @@ "--dart-jit-vm-arg=--disable-service-auth-codes" ], "options": { - "cwd": "packages/theme_extensions_builder/test" + "cwd": "packages/theme_extensions_builder" }, "dependsOn": "example: run build_runner clean", "group": "build", diff --git a/docs/installation.md b/docs/installation.md index d605493..21e9e0a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -22,11 +22,11 @@ Or edit `pubspec.yaml` manually: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.13.0 - theme_extensions_builder: ^7.3.0 + theme_extensions_builder: ^7.5.0 ``` Then fetch dependencies: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 05a06b9..1d4fdd2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -51,6 +51,25 @@ dart run build_runner clean dart run build_runner build ``` +## The Build Stops With A Generator Error + +The generator checks the annotated class before writing anything. Each message names the class and what to change: + +- **`` `X` has no constructor named `_internal`. ``** The `constructor:` option names a constructor that does not exist. Declare it, or point the option at an existing one. +- **`` `X` has no unnamed constructor, which the generated code calls. ``** The class only has named constructors. Add `constructor: 'name'` to the annotation. +- **`` The constructor `X` has no named parameter for the field `y` ``** Every field is passed to the constructor by name. Add `this.y` to the constructor, or mark the field with `@ignore`. +- **`` The constructor `X` requires `y`, which is not among the fields the generated code passes to it ``** The generated code only passes fields, so a required parameter has to be one: a positional parameter, a parameter that is not a field, or a field marked `@ignore` leaves the generated call short of an argument. Make the parameter optional, or declare it as `this.y` without `@ignore`. +- **`` `X` is generic, and the generated mixin cannot be ``** The mixin names the class without type arguments, so a type parameter would be undefined inside it. Remove the type parameters, or write the theme methods by hand. +- **`` The generated mixin `_$X` declares `merge`, so `X` cannot have a field of that name ``** A field is a getter, which cannot override the mixin's method. `copyWith`, `merge` and `lerp` are taken for `@ThemeGen`, `copyWith` and `lerp` for `@ThemeExtensions`. Rename the field. +- **`` `X` does not apply the generated mixin `_$X` ``** The generated methods live in the mixin, so a class without `with _$X` has none of them. Add the clause. +- **`` `X` must extend `ThemeExtension` ``** `@ThemeExtensions` needs a class that extends `ThemeExtension` of itself. Extend it, or use `@ThemeGen` for a plain class. +- **`` `...` is not a valid Dart identifier ``** / **`` `...` is a reserved word ``** `contextAccessorName` is written into the generated code as a getter name. Use an identifier that is not a keyword. +- **`` WidgetStateProperty must have a nullable generic type ``** `WidgetStateProperty.lerp` takes a lerp function with nullable parameters, so the generic has to be nullable: `WidgetStateProperty`. + +A warning such as `` The `lerp` method of X has an unsupported signature `` does not stop the build. The field type declares a `lerp` or `merge` the generator cannot call, so the field switches over at `t = 0.5` or is overwritten instead. Rename the method or give it a supported signature if it was meant to be used. + +A warning such as `` WidgetStateProperty cannot be interpolated: `X` has no static `X? lerp(X?, X?, double)` `` means the generic of a `WidgetStateProperty` field offers no lerp function for `WidgetStateProperty.lerp` to call, whether it declares no `lerp` at all or one of another shape. The field switches over at `t = 0.5`. Give `X` a static `lerp` that accepts and returns nulls if it was meant to be interpolated. + ## Analyzer Errors In Generated Files Checklist: diff --git a/packages/theme_extensions_builder/.pubignore b/packages/theme_extensions_builder/.pubignore new file mode 100644 index 0000000..8c550ca --- /dev/null +++ b/packages/theme_extensions_builder/.pubignore @@ -0,0 +1,7 @@ +# The example app is a member of the development workspace: its pubspec.yaml +# says `resolution: workspace`, which does not resolve outside the repository, +# and its platform folders are noise on pub.dev. The sources and the README +# stay, so the Example tab still has something to show. +example/* +!example/lib +!example/README.md diff --git a/packages/theme_extensions_builder/CHANGELOG.md b/packages/theme_extensions_builder/CHANGELOG.md index 5a47fb3..8fcaf30 100644 --- a/packages/theme_extensions_builder/CHANGELOG.md +++ b/packages/theme_extensions_builder/CHANGELOG.md @@ -1,3 +1,18 @@ +## 7.5.0 + +- **Breaking**: `hashCode` changes for classes that inherit fields, which now come after the class' own. +- **Changed**: `lerp` keeps the endpoints when one side is null (`t = 0` gives `a`, `t = 1` gives `b`), and `merge` keeps the current value when the incoming one is null. +- **New**: The annotated class is validated before generation. A missing constructor, a field without a named parameter, a required parameter the generated code cannot pass, a generic class, a missing `with _$X`, a field named `copyWith`, `merge` or `lerp`, a `@ThemeExtensions` class not extending `ThemeExtension`, or an invalid `contextAccessorName` now stop the build with a message naming the class and the fix. +- **Fixed**: Generated code that did not compile for an instance `lerp` on a nullable field or with a nullable result, a non-nullable `lerp`/`merge` parameter on a nullable field, a static call on `Box` instead of `Box`, a method declared on a supertype (the result is cast back), a generic field type (methods resolve through the instantiated type), and a field inherited from a generic superclass (`T` is substituted). +- **Fixed**: An unrelated `lerp` or `merge` no longer fails the build or is mistaken for a supported one; an unusable signature is a build warning naming the field and its fallback. +- **Fixed**: A `WidgetStateProperty` generic has to offer a static `lerp` that accepts nulls, and is reported once when it does not; a non-nullable generic is an error at the field, a nested one falls back; `double` and `Duration` generics are detected by element. +- **Fixed**: Field collection follows Dart's resolution: a narrowed field keeps the narrowed type, mixins win over the superclass chain, `implements` contributes nothing, private and static fields are skipped, and `@ignore` on a redeclaration drops the inherited one. `Duration` is detected by element. +- **Fixed**: A field typed as a `@ThemeGen` class, or a subclass of one, merges the same way on clean and incremental builds; a hand-written `merge` is used as declared. +- **Fixed**: `const` follows the constructor named in `constructor:`; `constructor: ''` selects the unnamed one. +- **Fixed**: Annotations are matched by package as well as by name, so a user class called `ThemeGen` is not taken for one. +- **Fixed**: The example is published without its workspace-only files, so it resolves when copied from pub.dev. +- **Updated**: Analyzer `>=13.1.0 <15.0.0` (the lower bound CI tests against) and Dart SDK `>=3.13.0 <4.0.0`; `collection` and `meta` are no longer dependencies; the `platforms` key is gone from the pubspec. + ## 7.4.0 - *Updated*: Analyzer dependency to ">=9.0.0 <14.0.0" diff --git a/packages/theme_extensions_builder/README.md b/packages/theme_extensions_builder/README.md index 23ae7b7..b90252b 100644 --- a/packages/theme_extensions_builder/README.md +++ b/packages/theme_extensions_builder/README.md @@ -29,11 +29,11 @@ Or add manually to `pubspec.yaml`: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.13.0 - theme_extensions_builder: ^7.4.0 + theme_extensions_builder: ^7.5.0 ``` ## 🚀 Quick Start @@ -472,20 +472,38 @@ The example includes ready-to-use components and demonstrates best practices for ## 🔧 Build Configuration -### build.yaml (Optional) - -You can customize the build configuration: +The builder takes no options. What `build.yaml` can configure is which files it looks at, which keeps builds fast in a large project: ```yaml targets: $default: builders: theme_extensions_builder: - enabled: true - options: - # Add custom options here if needed + generate_for: + - lib/theme/**.dart ``` +## 🚧 Limitations + +- **Types are written by name.** The generated code refers to field types the way the analyzer displays them, without import prefixes. A theme file that imports Flutter `as m` gets a bare `Color` in the generated part, which does not resolve. Import Flutter without a prefix in the files that declare themes. +- **`copyWith` cannot set a nullable field to `null`.** Passing `null` means "keep the current value", as in Flutter's own theme classes. +- **Null handling in `lerp` depends on the type.** `double` and `Duration` treat a missing side as zero, like Flutter's `lerpDouble`. A type with a static `lerp` that accepts nulls, such as `Color`, decides for itself. A type whose `lerp` cannot take a null keeps the nearer side: `t < 0.5` gives the first value, otherwise the second. + +## 🚨 Generation Errors + +The generator checks the annotated class before it writes anything, and stops the build with a message pointing at the class when: + +- the constructor named in `constructor:` does not exist, or there is no unnamed constructor to fall back to; +- a field has no named parameter of the same name in that constructor (mark it `@ignore` if it is not part of the theme); +- that constructor requires a parameter the generated code does not pass: a positional one, one that is not a field, or the parameter of an `@ignore`d field; +- the class is generic, or does not apply the generated `_$ClassName` mixin; +- a field is named after a member the mixin declares (`copyWith`, `merge`, `lerp`); +- a `@ThemeExtensions` class does not extend `ThemeExtension`; +- `contextAccessorName` is not a valid identifier, or is a reserved word; +- a `WidgetStateProperty` field has a non-nullable generic. + +A field type whose `lerp` or `merge` has a signature the generator cannot call is not an error. The build logs a warning naming the field, which then switches over at `t = 0.5` instead of being interpolated, or is overwritten instead of being merged. + ## ⚡ Tips and Best Practices 1. **Use descriptive names**: Name your theme extensions clearly (e.g., `ButtonTheme`, `CardTheme`) diff --git a/packages/theme_extensions_builder/analysis_options.yaml b/packages/theme_extensions_builder/analysis_options.yaml index 380e10d..9e8ec77 100644 --- a/packages/theme_extensions_builder/analysis_options.yaml +++ b/packages/theme_extensions_builder/analysis_options.yaml @@ -5,5 +5,4 @@ formatter: analyzer: exclude: - - test/theme_gen/goldens/**.dart - - test/theme_extensions/goldens/**.dart + - test/fixtures/goldens/**.dart diff --git a/packages/theme_extensions_builder/build.yaml b/packages/theme_extensions_builder/build.yaml index 1a5d396..9eb1800 100644 --- a/packages/theme_extensions_builder/build.yaml +++ b/packages/theme_extensions_builder/build.yaml @@ -3,8 +3,11 @@ targets: builders: theme_extensions_builder: generate_for: - - test/theme_gen/**.dart - - test/theme_extensions/**.dart + include: + - test/fixtures/*.dart + exclude: + # These classes are meant to fail generation; see invalid_test. + - test/fixtures/invalid_*.dart builders: theme_extensions_builder: diff --git a/packages/theme_extensions_builder/example/analysis_options.yaml b/packages/theme_extensions_builder/example/analysis_options.yaml index 1c64636..c3c9c71 100644 --- a/packages/theme_extensions_builder/example/analysis_options.yaml +++ b/packages/theme_extensions_builder/example/analysis_options.yaml @@ -1,5 +1,11 @@ -include: package:pro_lints/common.yaml +include: package:pro_lints/recommended.yaml formatter: trailing_commas: preserve +analyzer: + exclude: + - android/** + - ios/** + - macos/** + - linux/** diff --git a/packages/theme_extensions_builder/example/lib/pages/home_page.dart b/packages/theme_extensions_builder/example/lib/pages/home_page.dart index c7fe7c4..f4c85ba 100644 --- a/packages/theme_extensions_builder/example/lib/pages/home_page.dart +++ b/packages/theme_extensions_builder/example/lib/pages/home_page.dart @@ -6,6 +6,8 @@ import '../theme/extensions/spacing_theme.dart'; import 'widgets/base_card.dart'; import 'widgets/button_showcase.dart'; import 'widgets/custom_button.dart'; +import 'widgets/input_showcase.dart'; +import 'widgets/lerp_showcase.dart'; import 'widgets/typography_showcase.dart'; class HomePage extends StatefulWidget { @@ -46,6 +48,8 @@ class _HomePageState extends State { _buildButtonsPage(context), _buildTypographyPage(context), _buildCardsPage(context), + _buildInputsPage(context), + _buildLerpPage(context), ], ), bottomNavigationBar: NavigationBar( @@ -76,6 +80,16 @@ class _HomePageState extends State { selectedIcon: Icon(Icons.dashboard), label: 'Cards', ), + NavigationDestination( + icon: Icon(Icons.edit_outlined), + selectedIcon: Icon(Icons.edit), + label: 'Inputs', + ), + NavigationDestination( + icon: Icon(Icons.animation_outlined), + selectedIcon: Icon(Icons.animation), + label: 'Lerp', + ), ], ), ); @@ -189,6 +203,28 @@ class _HomePageState extends State { ); } + Widget _buildInputsPage(BuildContext context) { + final spacing = context.spacingTheme; + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: spacing.pageHorizontal, + vertical: spacing.pageVertical, + ), + child: const InputShowcase(), + ); + } + + Widget _buildLerpPage(BuildContext context) { + final spacing = context.spacingTheme; + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: spacing.pageHorizontal, + vertical: spacing.pageVertical, + ), + child: const LerpShowcase(), + ); + } + Widget _buildCardsPage(BuildContext context) { final spacing = context.spacingTheme; return SingleChildScrollView( diff --git a/packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart b/packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart new file mode 100644 index 0000000..9afe9bb --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/pages/widgets/input_showcase.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; + +import '../../theme/extensions/spacing_theme.dart'; +import '../../theme/extensions/widgets/input_theme.dart'; + +/// Text inputs driven entirely by [InputThemeExtension]. +/// +/// Every visible part of a field comes from the extension: the border colour +/// and the fill are `WidgetStateProperty` resolved against the state +/// of the field, and the border width, radius, padding, text styles and the +/// duration of the focus animation are plain fields. Toggle the theme to see +/// all of them interpolate at once. +class InputShowcase extends StatefulWidget { + const InputShowcase({super.key}); + + @override + State createState() => _InputShowcaseState(); +} + +class _InputShowcaseState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(text: 'hunter2'); + final _searchController = TextEditingController(); + final _notesController = TextEditingController(); + final _invalidController = TextEditingController(text: 'not-an-email'); + + var _obscurePassword = true; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _searchController.dispose(); + _notesController.dispose(); + _invalidController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final spacing = context.spacingTheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Inputs', style: textTheme.headlineMedium), + SizedBox(height: spacing.sm), + Text( + 'Border and fill colours are WidgetStateProperty, resolved ' + 'against the state of each field. Focus one to see the state change, ' + 'toggle the theme to see the whole set interpolate.', + style: textTheme.bodyMedium, + ), + SizedBox(height: spacing.lg), + ThemedTextField( + label: 'Email', + hint: 'you@example.com', + helperText: 'We only use it to send the newsletter.', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + prefixIcon: Icons.alternate_email, + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Password', + hint: 'At least 8 characters', + controller: _passwordController, + obscureText: _obscurePassword, + prefixIcon: Icons.lock_outline, + suffix: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + size: 20, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + tooltip: _obscurePassword ? 'Show password' : 'Hide password', + ), + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Search', + hint: 'Type to filter', + controller: _searchController, + prefixIcon: Icons.search, + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Email (error state)', + controller: _invalidController, + errorText: 'Enter a valid email address', + prefixIcon: Icons.alternate_email, + ), + SizedBox(height: spacing.md), + const ThemedTextField( + label: 'Account id (disabled)', + hint: 'Assigned automatically', + enabled: false, + prefixIcon: Icons.badge_outlined, + ), + SizedBox(height: spacing.md), + ThemedTextField( + label: 'Notes', + hint: 'Anything else we should know?', + controller: _notesController, + maxLines: 4, + ), + ], + ); + } +} + +/// A text field painted from [InputThemeExtension] rather than from the +/// Material input decoration theme. +class ThemedTextField extends StatefulWidget { + const ThemedTextField({ + required this.label, + this.hint, + this.helperText, + this.errorText, + this.controller, + this.keyboardType, + this.prefixIcon, + this.suffix, + this.obscureText = false, + this.enabled = true, + this.maxLines = 1, + super.key, + }); + + final String label; + final String? hint; + final String? helperText; + final String? errorText; + final TextEditingController? controller; + final TextInputType? keyboardType; + final IconData? prefixIcon; + final Widget? suffix; + final bool obscureText; + final bool enabled; + final int maxLines; + + @override + State createState() => _ThemedTextFieldState(); +} + +class _ThemedTextFieldState extends State { + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _focusNode.addListener(() => setState(() {})); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + /// The states the theme resolves its colours against. + Set get _states => { + if (!widget.enabled) WidgetState.disabled, + if (widget.errorText != null) WidgetState.error, + if (_focusNode.hasFocus) WidgetState.focused, + }; + + String _describe(Set states) => + states.isEmpty ? '{}' : states.map((state) => state.name).join(', '); + + @override + Widget build(BuildContext context) { + final theme = context.inputTheme; + final spacing = context.spacingTheme; + final states = _states; + + final borderColor = theme.borderColor.resolve(states); + final labelColor = theme.labelColor.resolve(states); + final isFocused = states.contains(WidgetState.focused); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: theme.labelStyle.copyWith(color: labelColor), + ), + SizedBox(height: spacing.xs), + AnimatedContainer( + duration: theme.focusDuration, + curve: Curves.easeOut, + padding: theme.contentPadding, + decoration: BoxDecoration( + color: theme.fillColor.resolve(states), + borderRadius: theme.borderRadius, + border: Border.all( + color: borderColor ?? Colors.transparent, + width: isFocused ? theme.focusedBorderWidth : theme.borderWidth, + ), + ), + child: Row( + spacing: spacing.sm, + children: [ + if (widget.prefixIcon != null) + Icon(widget.prefixIcon, size: 20, color: labelColor), + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + enabled: widget.enabled, + obscureText: widget.obscureText, + keyboardType: widget.keyboardType, + maxLines: widget.maxLines, + decoration: InputDecoration.collapsed( + hintText: widget.hint, + hintStyle: TextStyle(color: theme.hintColor), + ), + ), + ), + if (widget.suffix != null) widget.suffix!, + ], + ), + ), + if (widget.errorText != null || widget.helperText != null) ...[ + SizedBox(height: spacing.xs), + Text( + widget.errorText ?? widget.helperText!, + style: widget.errorText != null + ? theme.errorStyle + : theme.helperStyle, + ), + ], + SizedBox(height: spacing.xs), + Text('states: ${_describe(states)}', style: theme.helperStyle), + ], + ); + } +} diff --git a/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart b/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart new file mode 100644 index 0000000..f27b4de --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/pages/widgets/lerp_showcase.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; + +import '../../app.dart'; +import '../../theme/dark_theme.dart'; +import '../../theme/extensions/app_theme.dart'; +import '../../theme/extensions/spacing_theme.dart'; +import '../../theme/light_theme.dart'; +import 'custom_button.dart'; + +/// Shows what `lerp` produces for a nullable field at every point of a theme +/// transition. +/// +/// `AppThemeExtension.optionalBorderSide` is set in the dark theme and absent +/// in the light one, so one side of the interpolation is always null. An +/// instance of `BorderSide` cannot be interpolated with nothing, so the +/// generated code has to pick a side; this page shows where it picks it, next +/// to what the generator emitted before 7.5.0. +class LerpShowcase extends StatefulWidget { + const LerpShowcase({super.key}); + + @override + State createState() => _LerpShowcaseState(); +} + +class _LerpShowcaseState extends State + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: kThemeAnimationDuration, + )..addListener(() => setState(() {})); + + late final AppThemeExtension _light = lightTheme + .extension()!; + late final AppThemeExtension _dark = darkTheme + .extension()!; + + var _toDark = true; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + AppThemeExtension get _from => _toDark ? _light : _dark; + + AppThemeExtension get _to => _toDark ? _dark : _light; + + /// What the generator emits today. + AppThemeExtension get _current => + _from.lerp(_to, _controller.value) as AppThemeExtension; + + /// What the generator emitted before 7.5.0: whichever side was not null won + /// outright, at every `t` including the endpoints. + BorderSide? get _previousBorderSide { + final a = _from.optionalBorderSide; + final b = _to.optionalBorderSide; + + if (a == null) { + return b; + } + + if (b == null) { + return a; + } + + return BorderSide.lerp(a, b, _controller.value); + } + + @override + Widget build(BuildContext context) { + final spacing = context.spacingTheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Nullable field interpolation', style: textTheme.headlineMedium), + SizedBox(height: spacing.sm), + Text( + 'optionalBorderSide is a BorderSide? that only the dark theme sets. ' + 'Drag t, or press play to run it at the real theme animation speed.', + style: textTheme.bodyMedium, + ), + SizedBox(height: spacing.lg), + SegmentedButton( + segments: const [ + ButtonSegment(value: true, label: Text('light → dark')), + ButtonSegment(value: false, label: Text('dark → light')), + ], + selected: {_toDark}, + onSelectionChanged: (selection) => + setState(() => _toDark = selection.first), + ), + SizedBox(height: spacing.md), + Row( + children: [ + IconButton.filledTonal( + onPressed: () => _controller + ..reset() + ..forward(), + icon: const Icon(Icons.play_arrow), + tooltip: 'Play the transition', + ), + Expanded( + child: Slider( + value: _controller.value, + label: _controller.value.toStringAsFixed(2), + divisions: 100, + onChanged: (value) => _controller.value = value, + ), + ), + SizedBox( + width: 56, + child: Text( + 't = ${_controller.value.toStringAsFixed(2)}', + style: textTheme.labelMedium, + ), + ), + ], + ), + SizedBox(height: spacing.md), + Row( + spacing: spacing.md, + children: [ + Expanded( + child: _BorderPreview( + label: 'Generated now', + side: _current.optionalBorderSide, + fill: _current.primaryColor, + ), + ), + Expanded( + child: _BorderPreview( + label: 'Before 7.5.0', + side: _previousBorderSide, + fill: _current.primaryColor, + ), + ), + ], + ), + SizedBox(height: spacing.lg), + Text( + 'The fill is primaryColor, a non-nullable Color: it interpolates ' + 'smoothly and is identical in both boxes. Only the border differs, ' + 'and only because one side of it is null.', + style: textTheme.bodySmall, + ), + SizedBox(height: spacing.sectionSpacing), + Text('Live theme', style: textTheme.titleLarge), + SizedBox(height: spacing.sm), + Text( + 'The same field, read from the real theme. Toggle and watch when the ' + 'border shows up: it lands halfway through the transition rather ' + 'than on its first frame.', + style: textTheme.bodyMedium, + ), + SizedBox(height: spacing.md), + _BorderPreview( + label: 'context.appTheme', + side: context.appTheme.optionalBorderSide, + fill: context.appTheme.primaryColor, + ), + SizedBox(height: spacing.md), + CustomButton( + label: 'Toggle Dark/Light Theme', + icon: Icons.brightness_6, + onPressed: () => context.appState.toggleTheme(), + ), + ], + ); + } +} + +class _BorderPreview extends StatelessWidget { + const _BorderPreview({ + required this.label, + required this.side, + required this.fill, + }); + + final String label; + final BorderSide? side; + final Color fill; + + @override + Widget build(BuildContext context) { + final spacing = context.spacingTheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 96, + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(12), + border: side == null ? null : Border.fromBorderSide(side!), + ), + ), + SizedBox(height: spacing.sm), + Text(label, style: textTheme.labelLarge), + Text( + side == null ? 'null' : 'width ${side!.width.toStringAsFixed(1)}', + style: textTheme.bodySmall, + ), + ], + ); + } +} diff --git a/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart b/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart index 5685d59..2c8ed2f 100644 --- a/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart +++ b/packages/theme_extensions_builder/example/lib/theme/dark_theme.dart @@ -5,6 +5,7 @@ import 'extensions/spacing_theme.dart'; import 'extensions/typography_theme.dart'; import 'extensions/widgets/button_theme.dart'; import 'extensions/widgets/card_theme.dart'; +import 'extensions/widgets/input_theme.dart'; ThemeData get darkTheme => ThemeData( brightness: .dark, @@ -18,7 +19,9 @@ ThemeData get darkTheme => ThemeData( backgroundColor: Colors.black, layoutMode: .expanded, borderSide: BorderSide.none, - optionalBorderSide: null, + // Present in the dark theme only, so the lerp showcase has a field that + // appears on one side of the transition and is absent on the other. + optionalBorderSide: BorderSide(color: Colors.tealAccent, width: 4), ), CardThemeExtension( borderRadius: const .all(.circular(16)), @@ -77,6 +80,36 @@ ThemeData get darkTheme => ThemeData( backgroundColor: .fromRGBO(33, 33, 33, 1), ), ), + const InputThemeExtension( + borderColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.white12, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.tealAccent, + WidgetState.any: Colors.white24, + }), + fillColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Color(0x0AFFFFFF), + WidgetState.focused: Color(0x1400BFA5), + WidgetState.any: Color(0xFF121212), + }), + labelColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.white38, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.tealAccent, + WidgetState.any: Colors.white70, + }), + borderRadius: BorderRadius.all(Radius.circular(16)), + borderWidth: 1, + focusedBorderWidth: 3, + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 18), + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + helperStyle: TextStyle(fontSize: 12, color: Colors.white70), + errorStyle: TextStyle(fontSize: 12, color: Colors.redAccent), + focusDuration: Duration(milliseconds: 250), + // hintColor is left at its default null here, so it also shows a + // nullable field being interpolated: Color.lerp accepts nulls, so the + // hint fades instead of switching. + ), const SpacingThemeExtension( xs: 4, sm: 8, diff --git a/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart b/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart index 17fbee9..d57d57a 100644 --- a/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart +++ b/packages/theme_extensions_builder/example/lib/theme/extensions/app_theme.g.theme.dart @@ -49,10 +49,11 @@ mixin _$AppThemeExtension on ThemeExtension { )!, layoutMode: t < 0.5 ? _this.layoutMode : other.layoutMode, borderSide: BorderSide.lerp(_this.borderSide, other.borderSide, t), - optionalBorderSide: _this.optionalBorderSide == null - ? other.optionalBorderSide - : other.optionalBorderSide == null - ? _this.optionalBorderSide + optionalBorderSide: + _this.optionalBorderSide == null || other.optionalBorderSide == null + ? t < 0.5 + ? _this.optionalBorderSide + : other.optionalBorderSide : BorderSide.lerp( _this.optionalBorderSide!, other.optionalBorderSide!, diff --git a/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart new file mode 100644 index 0000000..6f0122a --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'input_theme.g.theme.dart'; + +/// Theme for text inputs. +/// +/// The colours that change with the state of the field are declared as +/// `WidgetStateProperty`, which the generator interpolates through +/// `WidgetStateProperty.lerp`. The generic has to be nullable for that: the +/// lerp function it is handed takes nullable arguments. +@themeExtensions +class InputThemeExtension extends ThemeExtension + with _$InputThemeExtension { + const InputThemeExtension({ + required this.borderColor, + required this.fillColor, + required this.labelColor, + required this.borderRadius, + required this.borderWidth, + required this.focusedBorderWidth, + required this.contentPadding, + required this.labelStyle, + required this.helperStyle, + required this.errorStyle, + required this.focusDuration, + this.hintColor, + }); + + /// Border colour per state: focused, error, disabled, or plain. + final WidgetStateProperty borderColor; + + /// Background of the field, also per state. + final WidgetStateProperty fillColor; + + /// Colour of the floating label, also per state. + final WidgetStateProperty labelColor; + + final BorderRadius borderRadius; + final double borderWidth; + final double focusedBorderWidth; + final EdgeInsets contentPadding; + final TextStyle labelStyle; + final TextStyle helperStyle; + final TextStyle errorStyle; + + /// How long the border takes to move between states. + final Duration focusDuration; + + /// Optional, so it also shows a nullable field being interpolated. + final Color? hintColor; +} diff --git a/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart new file mode 100644 index 0000000..d110d11 --- /dev/null +++ b/packages/theme_extensions_builder/example/lib/theme/extensions/widgets/input_theme.g.theme.dart @@ -0,0 +1,156 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'input_theme.dart'; + +// ************************************************************************** +// ThemeExtensionsGenerator +// ************************************************************************** + +mixin _$InputThemeExtension on ThemeExtension { + @override + ThemeExtension copyWith({ + WidgetStateProperty? borderColor, + WidgetStateProperty? fillColor, + WidgetStateProperty? labelColor, + BorderRadius? borderRadius, + double? borderWidth, + double? focusedBorderWidth, + EdgeInsets? contentPadding, + TextStyle? labelStyle, + TextStyle? helperStyle, + TextStyle? errorStyle, + Duration? focusDuration, + Color? hintColor, + }) { + final _this = (this as InputThemeExtension); + + return InputThemeExtension( + borderColor: borderColor ?? _this.borderColor, + fillColor: fillColor ?? _this.fillColor, + labelColor: labelColor ?? _this.labelColor, + borderRadius: borderRadius ?? _this.borderRadius, + borderWidth: borderWidth ?? _this.borderWidth, + focusedBorderWidth: focusedBorderWidth ?? _this.focusedBorderWidth, + contentPadding: contentPadding ?? _this.contentPadding, + labelStyle: labelStyle ?? _this.labelStyle, + helperStyle: helperStyle ?? _this.helperStyle, + errorStyle: errorStyle ?? _this.errorStyle, + focusDuration: focusDuration ?? _this.focusDuration, + hintColor: hintColor ?? _this.hintColor, + ); + } + + @override + ThemeExtension lerp( + ThemeExtension? other, + double t, + ) { + if (other is! InputThemeExtension) { + return this; + } + + final _this = (this as InputThemeExtension); + + return InputThemeExtension( + borderColor: WidgetStateProperty.lerp( + _this.borderColor, + other.borderColor, + t, + Color.lerp, + )!, + fillColor: WidgetStateProperty.lerp( + _this.fillColor, + other.fillColor, + t, + Color.lerp, + )!, + labelColor: WidgetStateProperty.lerp( + _this.labelColor, + other.labelColor, + t, + Color.lerp, + )!, + borderRadius: BorderRadius.lerp( + _this.borderRadius, + other.borderRadius, + t, + )!, + borderWidth: lerpDouble$(_this.borderWidth, other.borderWidth, t)!, + focusedBorderWidth: lerpDouble$( + _this.focusedBorderWidth, + other.focusedBorderWidth, + t, + )!, + contentPadding: EdgeInsets.lerp( + _this.contentPadding, + other.contentPadding, + t, + )!, + labelStyle: TextStyle.lerp(_this.labelStyle, other.labelStyle, t)!, + helperStyle: TextStyle.lerp(_this.helperStyle, other.helperStyle, t)!, + errorStyle: TextStyle.lerp(_this.errorStyle, other.errorStyle, t)!, + focusDuration: lerpDuration$( + _this.focusDuration, + other.focusDuration, + t, + )!, + hintColor: Color.lerp(_this.hintColor, other.hintColor, t), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as InputThemeExtension); + final _other = (other as InputThemeExtension); + + return _other.borderColor == _this.borderColor && + _other.fillColor == _this.fillColor && + _other.labelColor == _this.labelColor && + _other.borderRadius == _this.borderRadius && + _other.borderWidth == _this.borderWidth && + _other.focusedBorderWidth == _this.focusedBorderWidth && + _other.contentPadding == _this.contentPadding && + _other.labelStyle == _this.labelStyle && + _other.helperStyle == _this.helperStyle && + _other.errorStyle == _this.errorStyle && + _other.focusDuration == _this.focusDuration && + _other.hintColor == _this.hintColor; + } + + @override + int get hashCode { + final _this = (this as InputThemeExtension); + + return Object.hash( + runtimeType, + _this.borderColor, + _this.fillColor, + _this.labelColor, + _this.borderRadius, + _this.borderWidth, + _this.focusedBorderWidth, + _this.contentPadding, + _this.labelStyle, + _this.helperStyle, + _this.errorStyle, + _this.focusDuration, + _this.hintColor, + ); + } +} + +extension InputThemeExtensionBuildContext on BuildContext { + InputThemeExtension get inputTheme => + Theme.of(this).extension()!; +} diff --git a/packages/theme_extensions_builder/example/lib/theme/light_theme.dart b/packages/theme_extensions_builder/example/lib/theme/light_theme.dart index 08f0b9c..116d046 100644 --- a/packages/theme_extensions_builder/example/lib/theme/light_theme.dart +++ b/packages/theme_extensions_builder/example/lib/theme/light_theme.dart @@ -5,6 +5,7 @@ import 'extensions/spacing_theme.dart'; import 'extensions/typography_theme.dart'; import 'extensions/widgets/button_theme.dart'; import 'extensions/widgets/card_theme.dart'; +import 'extensions/widgets/input_theme.dart'; ThemeData get lightTheme => ThemeData( brightness: .light, @@ -93,6 +94,34 @@ ThemeData get lightTheme => ThemeData( backgroundColor: .fromRGBO(245, 245, 245, 1), ), ), + const InputThemeExtension( + borderColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.black12, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.orange, + WidgetState.any: Colors.black26, + }), + fillColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Color(0x0A000000), + WidgetState.focused: Color(0x14FF9800), + WidgetState.any: Colors.white, + }), + labelColor: WidgetStateProperty.fromMap({ + WidgetState.disabled: Colors.black26, + WidgetState.error: Colors.redAccent, + WidgetState.focused: Colors.orange, + WidgetState.any: Colors.black54, + }), + borderRadius: BorderRadius.all(Radius.circular(8)), + borderWidth: 1, + focusedBorderWidth: 2, + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14), + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + helperStyle: TextStyle(fontSize: 12, color: Colors.black54), + errorStyle: TextStyle(fontSize: 12, color: Colors.redAccent), + focusDuration: Duration(milliseconds: 150), + hintColor: Colors.black38, + ), const SpacingThemeExtension( xs: 4, sm: 8, diff --git a/packages/theme_extensions_builder/example/pubspec.lock b/packages/theme_extensions_builder/example/pubspec.lock deleted file mode 100644 index b3407fb..0000000 --- a/packages/theme_extensions_builder/example/pubspec.lock +++ /dev/null @@ -1,461 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b - url: "https://pub.dev" - source: hosted - version: "100.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" - url: "https://pub.dev" - source: hosted - version: "13.0.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 - url: "https://pub.dev" - source: hosted - version: "4.0.6" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" - url: "https://pub.dev" - source: hosted - version: "2.15.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" - url: "https://pub.dev" - source: hosted - version: "8.12.6" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "59d53ef8eaed9d288ed9767618e2b31c4fa0383a127db59d5eb2e737a7638a60" - url: "https://pub.dev" - source: hosted - version: "3.1.9" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" - url: "https://pub.dev" - source: hosted - version: "4.12.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" - url: "https://pub.dev" - source: hosted - version: "0.12.20" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" - source: hosted - version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - pro_lints: - dependency: "direct dev" - description: - name: pro_lints - sha256: "70377f7bbffad1ab57b04403f003bf0713910034c3b2d3d185d2870643277b9a" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 - url: "https://pub.dev" - source: hosted - version: "4.2.3" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" - url: "https://pub.dev" - source: hosted - version: "0.7.12" - theme_extensions_builder: - dependency: "direct dev" - description: - path: ".." - relative: true - source: path - version: "7.4.0" - theme_extensions_builder_annotation: - dependency: "direct main" - description: - path: "../../theme_extensions_builder_annotation" - relative: true - source: path - version: "7.4.0" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.11.5 <4.0.0" diff --git a/packages/theme_extensions_builder/example/pubspec.yaml b/packages/theme_extensions_builder/example/pubspec.yaml index 8349533..f9360f2 100644 --- a/packages/theme_extensions_builder/example/pubspec.yaml +++ b/packages/theme_extensions_builder/example/pubspec.yaml @@ -4,17 +4,19 @@ publish_to: 'none' version: 1.0.0 environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" + +resolution: workspace dependencies: flutter: sdk: flutter - theme_extensions_builder_annotation: ^7.3.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_runner: ^2.15.0 - pro_lints: ^6.1.0 - theme_extensions_builder: ^7.3.0 + pro_lints: ^6.2.0 + theme_extensions_builder: ^7.5.0 flutter: uses-material-design: true diff --git a/packages/theme_extensions_builder/example/pubspec_overrides.yaml b/packages/theme_extensions_builder/example/pubspec_overrides.yaml deleted file mode 100644 index c368267..0000000 --- a/packages/theme_extensions_builder/example/pubspec_overrides.yaml +++ /dev/null @@ -1,6 +0,0 @@ -dependency_overrides: - theme_extensions_builder_annotation: - path: ../../theme_extensions_builder_annotation - - theme_extensions_builder: - path: ../ diff --git a/packages/theme_extensions_builder/lib/builder.dart b/packages/theme_extensions_builder/lib/builder.dart index 0d58fdf..baa331d 100644 --- a/packages/theme_extensions_builder/lib/builder.dart +++ b/packages/theme_extensions_builder/lib/builder.dart @@ -6,10 +6,7 @@ import 'src/generator/theme_gen/generator.dart'; /// Function used by the build runner Builder themeExtensionsBuilder(BuilderOptions options) => PartBuilder( - [ - ThemeExtensionsGenerator(builderOptions: options), - ThemeGenGenerator(builderOptions: options), - ], + [const ThemeExtensionsGenerator(), const ThemeGenGenerator()], '.g.theme.dart', header: ''' // coverage:ignore-file diff --git a/packages/theme_extensions_builder/lib/src/common/analysis.dart b/packages/theme_extensions_builder/lib/src/common/analysis.dart deleted file mode 100644 index 9520642..0000000 --- a/packages/theme_extensions_builder/lib/src/common/analysis.dart +++ /dev/null @@ -1,361 +0,0 @@ -/// @docImport 'fields_visitor_config.dart'; - -library; - -import 'package:analyzer/dart/analysis/results.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:source_gen/source_gen.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -import 'fields_visitor_config.dart'; -import 'symbols/field_info.dart'; -import 'symbols/lerp_info.dart'; -import 'symbols/merge_info.dart'; -import 'symbols/parameter_info.dart'; - -/// Creates a [FieldInfo] from the given [element]. -/// -/// The [config] parameter controls what information should be collected: -/// - When [FieldsVisitorConfig.includeLerpLookup] is `false`, lerp method -/// lookups are skipped -/// - When [FieldsVisitorConfig.includeMergeLookup] is `false`, merge method -/// lookups are skipped -/// -/// Skipping unnecessary lookups can significantly improve performance. -FieldInfo fieldSymbol( - FieldElement element, { - FieldsVisitorConfig config = const FieldsVisitorConfig(), -}) { - final name = element.displayName; - final elementType = element.type; - final isNullable = elementType.nullabilitySuffix == .question; - final baseType = elementType.baseType; - final isDouble = elementType.isDartCoreDouble; - final isDuration = elementType.isDuration; - - return FieldInfo( - name: name, - typeName: baseType, - isNullable: isNullable, - isDouble: isDouble, - isDuration: isDuration, - isStatic: element.isStatic, - merge: config.includeMergeLookup - ? _mergeInfo(elementType) - : const NoMerge(), - lerp: config.includeLerpLookup - ? _lerpInfo(elementType, element) - : const NoLerp(), - ); -} - -/// Gets information about the lerp method for the given [type]. -/// -/// Returns information about static or instance lerp methods, or [NoLerp] -/// if no suitable lerp method is found or if the type is not an interface. -/// -/// Throws [StateError] if a lerp method exists but has an invalid signature. -LerpInfo _lerpInfo(DartType type, FieldElement fieldElement) { - final typeElement = type.element; - - if (typeElement is! InterfaceElement) { - return const NoLerp(); - } - - final method = _lookupMethod(typeElement, 'lerp'); - - if (method == null) { - return const NoLerp(); - } - - final params = method.formalParameters; - - // WidgetStateProperty and WidgetStateColor use a different signature for - // lerp. Check for 4-parameter version first, as WidgetStateProperty has - //both 3 and 4 parameter versions - if (params case [final p1, final p2, final p3, final p4] - // Check for static lerp method with 4 parameters - // - first two parameters should have the same type as the class type - // - third parameter should be double - // - fourth parameter is a lerp function for the inner type - when type is InterfaceType && - method.isStatic && - p3.type.isDartCoreDouble && - _checkSubtype(p1, type) && - _checkSubtype(p2, type)) { - // Check p4 is a function type having signature: - // R Function(T? a, T? b, double t) - if (p4.type case FunctionType( - formalParameters: [final f1, final f2, final f3], - )) { - // For generic functions like T? Function(T?, T?, double), we can't easily - // check exact type compatibility without type substitution. - // Just verify the structure: 3 parameters where the third is double. - // The first two parameters should be nullable to match the lerp pattern. - final isValidSignature = - f1.type.nullabilitySuffix == .question && - f2.type.nullabilitySuffix == .question && - f3.type.isDartCoreDouble; - - if (!isValidSignature) { - // Unsupported lerp function signature - return const NoLerp(); - } - } - - final innerType = type.typeArguments.single; - - // Check that the generic type is nullable - if (!innerType.hasNullableSuffix) { - final typeName = type.getDisplayString(); - final innerTypeName = innerType.getDisplayString(); - throw StateError( - 'WidgetStateProperty must have a nullable generic type for field ' - '${fieldElement.name}. Found: $typeName\n' - 'The generic type must be nullable because WidgetStateProperty.lerp ' - 'requires a lerp function with nullable parameters.\n' - 'Change the field type from $typeName to ' - 'WidgetStateProperty<$innerTypeName?> to fix this issue.', - ); - } - - final baseTypeName = type.element.displayName; - final genericType = innerType.baseType; - - return WidgetStatePropertyLerp( - baseTypeName: baseTypeName, - genericType: genericType, - isNullableGeneric: innerType.hasNullableSuffix, - ); - } - - if (params case [final p1, final p2, final p3] - // Check for static lerp method - // - should have three parameters - // - first two parameters should have the same type as the class type - // - third parameter should be double - when method.isStatic && - p3.type.isDartCoreDouble && - _checkSubtype(p1, type) && - _checkSubtype(p2, type)) { - final args = _mapArgs(params); - - return StaticLerp( - optionalResult: method.returnType.hasNullableSuffix, - args: args, - ); - } else if (params case [final p1, final p2] - // Check for instance lerp method: - // - should have only two parameters - // - first parameter type should match the class type - // - second parameter should be double - when !method.isStatic && - p2.type.isDartCoreDouble && - _checkSubtype(p1, type)) { - final args = _mapArgs(params); - - return InstanceLerp( - optionalResult: method.returnType.hasNullableSuffix, - args: args, - ); - } - - throw StateError( - 'Lerp method has invalid signature for type ' - '${type.getDisplayString()} of field ${fieldElement.name} ' - 'method: ${method.displayName} isStatic: ${method.isStatic} ', - ); -} - -/// Checks if a parameter type is a subtype of the given [type]. -/// -/// This function performs a type compatibility check between a formal parameter -/// and a target type. It handles nullability by promoting the type to non-null -/// before checking subtype relationships. -/// -/// Returns `true` if: -/// - Both [FormalParameterElement.type] and [type] are interface types -/// - [type] can be used as an instance of the parameter's type -/// - The non-null version of [type] is a subtype of that instance -/// -/// Returns `false` if either type is not an interface type or if the subtype -/// relationship doesn't hold. -/// -/// This is primarily used to validate lerp method signatures, ensuring that -/// parameters accept the correct types for interpolation. -bool _checkSubtype(FormalParameterElement param, DartType type) { - final typeElement = type.element; - if (typeElement is! InterfaceElement) { - return false; - } - - final parameterType = param.type; - - final paramTypeElement = parameterType.element; - if (paramTypeElement is! InterfaceElement) { - return false; - } - - final supertypeInstance = type.asInstanceOf(paramTypeElement); - if (supertypeInstance == null) { - return false; - } - - final typeSystem = typeElement.library.typeSystem; - final nonNullType = typeSystem.promoteToNonNull(type); - - return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); -} - -/// Maps a list of [parameters] to a list of [ParameterInfo] symbols. -List _mapArgs(List parameters) => - parameters.map(_mapArg).toList(growable: false); - -/// Creates an [ParameterInfo] from the given [parameter]. -ParameterInfo _mapArg(FormalParameterElement parameter) { - final name = parameter.displayName; - final type = parameter.type.getDisplayString(); - final isNullable = parameter.type.nullabilitySuffix == .question; - - return ParameterInfo(name: name, type: type, isNullable: isNullable); -} - -/// Cache for method lookups to avoid repeated expensive lookups. -/// Using Expando to avoid memory leaks - entries are automatically removed -/// when InterfaceElement is garbage collected. -final _methodCache = Expando>('method_cache'); - -/// Looks up a method with the given [name] in the [typeElement]. -/// If the method is not found directly on the type, it looks up -/// inherited methods as well. -/// Results are cached to avoid repeated expensive lookups. -MethodElement? _lookupMethod(InterfaceElement typeElement, String name) { - var cache = _methodCache[typeElement]; - if (cache == null) { - cache = {}; - _methodCache[typeElement] = cache; - } - - if (cache.containsKey(name)) { - return cache[name]; - } - - final method = typeElement.getMethod(name); - - if (method != null) { - cache[name] = method; - return method; - } - - final inheritedMethod = typeElement.lookUpInheritedMethod( - methodName: name, - library: typeElement.library, - ); - - cache[name] = inheritedMethod; - return inheritedMethod; -} - -/// Gets information about the merge method for the given [type]. -/// -/// This can improve performance when merge details aren't needed. -MergeInfo _mergeInfo(DartType type) { - final typeElement = type.element; - - if (typeElement is! InterfaceElement) { - return const NoMerge(); - } - - // Check if element or its supertypes have @ThemeGen annotation. - // Using the annotation implies that the merge method exists, as it is - // impossible to get information about the merge method during the build - // phase. - const themeGenChecker = TypeChecker.typeNamed(ThemeGen); - if (themeGenChecker.hasAnnotationOfExact(typeElement)) { - return const InstanceMerge(); - } - - final method = _lookupMethod(typeElement, 'merge'); - if (method == null) { - return const NoMerge(); - } - - final params = method.formalParameters; - - if (params case [final p1, final p2] - // Check for static merge method - // - should have two parameters - // - both parameters should have the same type as the class type - when method.isStatic && p1.type.baseType == p2.type.baseType) { - return const StaticMerge(); - } - - if (params case [final p1] - // Check for instance merge method: - // - should have only one parameter - // - parameter type should match the class type - when !method.isStatic && p1.type.baseType == type.baseType) { - return const InstanceMerge(); - } - - throw StateError('Merge method not found'); -} - -extension DartTypeExtension on DartType { - /// Returns the base type name without nullability suffix. - String get baseType { - final displayString = getDisplayString(); - final result = nullabilitySuffix == .question - ? displayString.replaceFirst(RegExp(r'\?$'), '') - : displayString; - - return result; - } - - /// Returns true if the type has a nullable suffix. - bool get hasNullableSuffix => nullabilitySuffix == .question; - - /// Returns true if the type is Duration. - bool get isDuration => baseType == 'Duration'; -} - -/// Gets the names of mixins applied to the given [element]. -List getMixinsNames({required ClassElement element}) { - final library = element.library.session.getParsedLibraryByElement( - element.library, - ); - - if (library is! ParsedLibraryResult) { - throw StateError('Could not get parsed library for element'); - } - - ClassDeclaration? classDeclaration; - - outerLoop: - for (final unit in library.units) { - for (final decl in unit.unit.declarations) { - if (decl is ClassDeclaration && - decl.namePart.typeName.lexeme == element.displayName) { - classDeclaration = decl; - break outerLoop; - } - } - } - - final withClause = classDeclaration?.withClause; - - if (withClause == null) { - throw StateError( - 'Mixin clause is missing for class ${element.displayName}. ' - 'Try adding "with _\$${element.displayName}" to the class declaration.', - ); - } - - final result = withClause.mixinTypes - .map((e) => e.name.lexeme) - .toList(growable: false); - - return result; -} diff --git a/packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart b/packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart deleted file mode 100644 index 6b0be22..0000000 --- a/packages/theme_extensions_builder/lib/src/common/base_class_visiter.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; - -/// Base class for visiting Dart element nodes in the AST. -/// -/// This class extends [ElementVisitor2] and provides empty implementations -/// for all visitor methods. Subclasses should override specific methods -/// to implement custom visiting behavior. -/// -/// Commonly used by code generators to traverse class elements and extract -/// information about fields, methods, and other class members. -class BaseClassVisitor extends ElementVisitor2 { - @override - void visitFieldElement(FieldElement element) {} - - @override - void visitClassElement(ClassElement element) {} - - @override - void visitConstructorElement(ConstructorElement element) {} - - @override - void visitEnumElement(EnumElement element) {} - - @override - void visitExtensionElement(ExtensionElement element) {} - - @override - void visitExtensionTypeElement(ExtensionTypeElement element) {} - - @override - void visitFieldFormalParameterElement(FieldFormalParameterElement element) {} - - @override - void visitFormalParameterElement(FormalParameterElement element) {} - - @override - void visitGenericFunctionTypeElement(GenericFunctionTypeElement element) {} - - @override - void visitGetterElement(GetterElement element) {} - - @override - void visitLabelElement(LabelElement element) {} - - @override - void visitLibraryElement(LibraryElement element) {} - - @override - void visitLocalFunctionElement(LocalFunctionElement element) {} - - @override - void visitLocalVariableElement(LocalVariableElement element) {} - - @override - void visitMethodElement(MethodElement element) {} - - @override - void visitMixinElement(MixinElement element) {} - - @override - void visitMultiplyDefinedElement(MultiplyDefinedElement element) {} - - @override - void visitPrefixElement(PrefixElement element) {} - - @override - void visitSetterElement(SetterElement element) {} - - @override - void visitSuperFormalParameterElement(SuperFormalParameterElement element) {} - - @override - void visitTopLevelFunctionElement(TopLevelFunctionElement element) {} - - @override - void visitTopLevelVariableElement(TopLevelVariableElement element) {} - - @override - void visitTypeAliasElement(TypeAliasElement element) {} - - @override - void visitTypeParameterElement(TypeParameterElement element) {} -} diff --git a/packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart b/packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart new file mode 100644 index 0000000..bbe0388 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/dart_type_extension.dart @@ -0,0 +1,31 @@ +import 'package:analyzer/dart/element/type.dart'; + +/// Helpers for reading a [DartType] the way the generators need it. +extension DartTypeExtension on DartType { + /// The display name of the type without its nullability suffix. + /// + /// This is the name the generated code declares variables and writes casts + /// with, type arguments included. + String get baseType { + final displayString = getDisplayString(); + + return hasNullableSuffix + ? displayString.substring(0, displayString.length - 1) + : displayString; + } + + /// Whether the type is written with a `?`. + bool get hasNullableSuffix => nullabilitySuffix == .question; + + /// Whether the type is `Duration` from `dart:core`. + /// + /// Checked by element rather than by name, so a user type that happens to + /// be called `Duration` is not interpolated as one. + bool get isDuration { + final typeElement = element; + + return typeElement != null && + typeElement.displayName == 'Duration' && + (typeElement.library?.isDartCore ?? false); + } +} diff --git a/packages/theme_extensions_builder/lib/src/common/field_symbol.dart b/packages/theme_extensions_builder/lib/src/common/field_symbol.dart new file mode 100644 index 0000000..9fed582 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/field_symbol.dart @@ -0,0 +1,31 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import 'dart_type_extension.dart'; +import 'lookup/lerp_lookup.dart'; +import 'lookup/merge_lookup.dart'; +import 'symbols/field_info.dart'; +import 'symbols/merge_info.dart'; + +/// Creates a [FieldInfo] for [element], a field of [type]. +/// +/// [type] is passed separately because it is not always `element.type`: a +/// field inherited from a generic superclass has the type arguments of the +/// inheriting class substituted in. +/// +/// When [includeMergeLookup] is `false`, the merge method lookup is skipped +/// and the field is reported as [NoMerge]. Use it for generators that don't +/// emit a `merge` method. +FieldInfo fieldSymbol( + FieldElement element, + DartType type, { + bool includeMergeLookup = true, +}) => FieldInfo( + name: element.displayName, + typeName: type.baseType, + isNullable: type.hasNullableSuffix, + isDouble: type.isDartCoreDouble, + isDuration: type.isDuration, + merge: includeMergeLookup ? mergeInfo(type, element) : const NoMerge(), + lerp: lerpInfo(type, element), +); diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visiter.dart b/packages/theme_extensions_builder/lib/src/common/fields_visiter.dart deleted file mode 100644 index 28920dd..0000000 --- a/packages/theme_extensions_builder/lib/src/common/fields_visiter.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; -import 'package:source_gen/source_gen.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -import 'analysis.dart'; -import 'base_class_visiter.dart'; -import 'fields_visitor_config.dart'; -import 'symbols/field_info.dart'; - -/// A visitor that collects field information from a class element. -/// -/// This visitor traverses class elements and extracts information about their -/// fields, converting them into [FieldInfo] objects. Fields annotated with -/// `@ignore` are excluded from the collection. -/// -/// The visitor only processes non-synthetic fields (fields that are explicitly -/// declared in the source code, not generated by the compiler). -/// -/// Example usage: -/// ```dart -/// // FieldsVisitorConfig config (default) -/// final visitor = FieldsVisitor(); -/// classElement.visitChildren(visitor); -/// final fields = visitor.fields; -/// ``` -class FieldsVisitor extends BaseClassVisitor { - /// Creates a [FieldsVisitor] with the specified [config]. - /// - /// The [config] controls what information should be collected during field - /// visiting. - FieldsVisitor({this.config = const FieldsVisitorConfig()}); - - /// Configuration controlling what information to collect. - /// - /// See [FieldsVisitorConfig] for available options and presets. - final FieldsVisitorConfig config; - - /// Internal set to store unique field information. - final Set _fields = {}; - - /// Returns an immutable list of collected field information. - /// - /// The list is created from the internal set, ensuring no duplicates - /// and preventing external modification. - List get fields => _fields.toList(growable: false); - - /// Type checker used to identify fields annotated with `@ignore`. - /// - /// Fields with this annotation will be skipped during the visit. - final ignoreAnnotationTypeChecker = TypeChecker.typeNamed(ignore.runtimeType); - - /// Visits a field element and collects its information if applicable. - /// - /// The field is added to the collection if: - /// - It is not annotated with `@ignore` - /// - It is not synthetic (compiler-generated) - /// - /// Synthetic fields are typically generated for getters/setters and should - /// not be included in the collected field information. - @override - void visitFieldElement(FieldElement element) { - // Skip fields annotated with @ignore - if (ignoreAnnotationTypeChecker.hasAnnotationOf(element)) { - return; - } - - // Only process non-synthetic fields (explicitly declared in source code) - if (element.isOriginDeclaration) { - final field = fieldSymbol(element, config: config); - _fields.add(field); - } - } -} diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart new file mode 100644 index 0000000..eed5c0a --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/fields_visitor.dart @@ -0,0 +1,124 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import 'field_symbol.dart'; +import 'symbols/field_info.dart'; +import 'type_checkers.dart'; + +/// Collects the fields of [element] together with the fields it inherits. +/// +/// Only the superclass chain and the applied mixins contribute: an interface +/// reached through `implements` has to be satisfied by [element] itself, so +/// its declarations would shadow nothing and cannot be constructed. +/// +/// Types are visited in Dart's own resolution order — the class first, then +/// its mixins from last applied to first, then the superclass chain — and the +/// first declaration of a name wins, so the declaration actually in effect is +/// the one that is collected. +/// +/// When [includeMergeLookup] is `false`, no merge method is looked up; see +/// [fieldSymbol]. +List collectFields( + ClassElement element, { + bool includeMergeLookup = true, +}) { + final collector = _FieldCollector(includeMergeLookup: includeMergeLookup); + final thisType = element.thisType; + + for (final type in [thisType, ..._inheritedTypes(thisType)]) { + collector.addDeclaredOn(type); + } + + return collector.fields; +} + +/// Yields the types [type] inherits members from, nearest first. +Iterable _inheritedTypes(InterfaceType type) sync* { + // A mixin is applied on top of the superclass, so a member it declares wins + // over the same member further up the chain. The last mixin applied wins + // over the ones before it. + yield* type.mixins.reversed; + + final superclass = type.superclass; + + if (superclass != null && !superclass.isDartCoreObject) { + yield superclass; + yield* _inheritedTypes(superclass); + } +} + +/// Collects field information from the types of a class hierarchy. +/// +/// Only fields the generated code can pass to a constructor are collected: +/// explicitly declared instance fields that are public and not annotated with +/// `@ignore`. +class _FieldCollector { + _FieldCollector({required this.includeMergeLookup}); + + /// Whether to look up merge methods on field types. + final bool includeMergeLookup; + + /// Collected field information, keyed by field name. + /// + /// Keying by name means a redeclared field is collected once. The first + /// declaration seen wins; see [collectFields] for the visiting order that + /// makes the nearest declaration the first one. + final Map _fields = {}; + + /// Names already decided on, including the ones that were skipped. + /// + /// An `@ignore` on a redeclaration has to suppress the inherited + /// declaration too, so a skipped name still claims its place. + final Set _claimed = {}; + + /// The collected fields, in the order they were visited. + List get fields => _fields.values.toList(growable: false); + + /// Adds the fields declared on the class of [type]. + /// + /// The declarations are read off the class, but their types through + /// [type]: a field declared as `T value` on `Base` is a `num value` on + /// `Base`, and `num` is the type the generated code has to write. + void addDeclaredOn(InterfaceType type) { + for (final element in type.element.fields) { + // Only explicitly declared fields: a synthetic field backs a getter or + // setter, and cannot be passed to a constructor. + if (!element.isOriginDeclaration) { + continue; + } + + // A static field is not part of an instance. Dart forbids a static and + // an instance member of the same name in one hierarchy, so it cannot + // shadow an inherited field either. + if (element.isStatic) { + continue; + } + + // A private field cannot be passed to a generated constructor call, and + // a private name is not a valid named parameter either. + if (element.isPrivate) { + continue; + } + + final name = element.displayName; + + if (!_claimed.add(name)) { + continue; + } + + if (ignoreChecker.hasAnnotationOf(element)) { + continue; + } + + // The getter looked up on the instantiated type carries the substituted + // field type. + final fieldType = type.getGetter(name)?.returnType ?? element.type; + + _fields[name] = fieldSymbol( + element, + fieldType, + includeMergeLookup: includeMergeLookup, + ); + } + } +} diff --git a/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart b/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart deleted file mode 100644 index 9b088be..0000000 --- a/packages/theme_extensions_builder/lib/src/common/fields_visitor_config.dart +++ /dev/null @@ -1,60 +0,0 @@ -/// @docImport 'fields_visiter.dart'; - -library; - -/// Configuration for [FieldsVisitor] behavior. -/// -/// This class controls what information should be collected during field -/// visiting. Disabling unnecessary lookups can significantly improve -/// performance for generators that don't use certain features. -class FieldsVisitorConfig { - /// Creates a [FieldsVisitorConfig] with the specified settings. - /// - /// Example usage: - /// ```dart - /// // For ThemeExtensions (only needs lerp) - /// final config = FieldsVisitorConfig( - /// includeMerge: false, - /// includeMergeLookup: false, - /// ); - /// - /// // For ThemeGen (needs both lerp and merge) - /// final config = FieldsVisitorConfig.full(); - /// - /// // Minimal config (skip all lookups) - /// final config = FieldsVisitorConfig.minimal(); - /// ``` - const FieldsVisitorConfig({ - this.includeLerpLookup = true, - this.includeMergeLookup = true, - }); - - /// Whether to perform method lookups for lerp methods. - /// - /// When `false`, skips expensive method lookups in _lerpInfo. - /// The lerp info will still be collected but without method lookup details. - final bool includeLerpLookup; - - /// Whether to perform method lookups for merge methods. - /// - /// When `false`, skips expensive method lookups in _mergeInfo. - /// Only relevant when [includeMergeLookup] is `true`. - final bool includeMergeLookup; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is FieldsVisitorConfig && - runtimeType == other.runtimeType && - includeLerpLookup == other.includeLerpLookup && - includeMergeLookup == other.includeMergeLookup; - - @override - int get hashCode => includeLerpLookup.hashCode ^ includeMergeLookup.hashCode; - - @override - String toString() => - 'FieldsVisitorConfig(' - 'includeLerpLookup: $includeLerpLookup, ' - 'includeMergeLookup: $includeMergeLookup)'; -} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart new file mode 100644 index 0000000..060926e --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/lookup/lerp_lookup.dart @@ -0,0 +1,257 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'package:source_gen/source_gen.dart'; + +import '../dart_type_extension.dart'; +import '../symbols/lerp_info.dart'; +import 'method_lookup.dart'; + +/// Decides how a field of [type] is interpolated. +/// +/// Returns information about static or instance lerp methods, or [NoLerp] if +/// the type is not an interface, has no lerp method, or has one whose +/// signature we cannot call. A type is free to declare an unrelated `lerp` +/// method, so an unknown signature falls back to [NoLerp] rather than failing +/// the build. +/// +/// Throws [InvalidGenerationSourceError] for a `WidgetStateProperty` field +/// with a non-nullable generic, which is a mistake we can point at. +LerpInfo lerpInfo(DartType type, FieldElement fieldElement) => + _lerpInfo(type, fieldElement, nested: false); + +/// [lerpInfo], for the field type itself or, when [nested], for the generic +/// of a `WidgetStateProperty` field. +/// +/// A nested type is reported by the caller as part of the outer field, so +/// nothing is warned about here, and a mistake in it is a fallback rather +/// than an error: an error would name the outer field while pointing at a +/// type that is not its own. +LerpInfo _lerpInfo( + DartType type, + FieldElement fieldElement, { + required bool nested, +}) { + if (type is! InterfaceType) { + return const NoLerp(); + } + + final method = lookupMethod(type, 'lerp'); + + if (method == null) { + return const NoLerp(); + } + + void warn() { + if (!nested) { + warnUnsupported('lerp', type, fieldElement); + } + } + + final params = callableParameters(method); + + if (params == null) { + warn(); + + return const NoLerp(); + } + + final strict = hasStrictSignature(method); + + // WidgetStateProperty and WidgetStateColor use a different signature for + // lerp. Check for the 4-parameter version first, as WidgetStateProperty has + // both 3 and 4 parameter versions. + if (params case [final p1, final p2, final p3, final p4] + // Check for static lerp method with 4 parameters + // - first two parameters should have the same type as the class type + // - third parameter should be double + // - fourth parameter is a lerp function for the inner type + when method.isStatic && + p3.type.isDartCoreDouble && + checkSubtype(p1, type, strict: strict) && + checkSubtype(p2, type, strict: strict)) { + return _widgetStatePropertyLerp(type, p1, p4, fieldElement, nested: nested); + } + + if (params case [final p1, final p2, final p3] + // Check for static lerp method + // - should have three parameters + // - first two parameters should have the same type as the class type + // - third parameter should be double + when method.isStatic && + p3.type.isDartCoreDouble && + checkSubtype(p1, type, strict: strict) && + checkSubtype(p2, type, strict: strict)) { + if (!isUsableAs(method.returnType, type, type)) { + warn(); + + return const NoLerp(); + } + + return StaticLerp( + optionalResult: method.returnType.hasNullableSuffix, + isNullableParameter: + p1.type.hasNullableSuffix && p2.type.hasNullableSuffix, + ); + } + + if (params case [final p1, final p2] + // Check for instance lerp method: + // - should have only two parameters + // - first parameter type should match the class type + // - second parameter should be double + when !method.isStatic && + p2.type.isDartCoreDouble && + checkSubtype(p1, type, strict: strict)) { + // A method declared on a supertype returns that supertype, which the + // generated code casts back to the field type. Anything else is not a + // result we can use. + final needsCast = !isUsableAs(method.returnType, type, type); + + if (needsCast && !isUsableAs(type, method.returnType, type)) { + warn(); + + return const NoLerp(); + } + + return InstanceLerp( + optionalResult: method.returnType.hasNullableSuffix, + needsCast: needsCast, + ); + } + + // The type declares a `lerp` we don't know how to call. + warn(); + + return const NoLerp(); +} + +/// Decides how a `WidgetStateProperty` shaped [type] is interpolated. +/// +/// [p1] is the first parameter of the four parameter `lerp`, which names the +/// declaring type, and [lerpFunction] is its last parameter. [nested] is +/// passed through from [_lerpInfo]. +LerpInfo _widgetStatePropertyLerp( + InterfaceType type, + FormalParameterElement p1, + FormalParameterElement lerpFunction, + FieldElement fieldElement, { + required bool nested, +}) { + void warn() { + if (!nested) { + warnUnsupported('lerp', type, fieldElement); + } + } + + // The fourth parameter has to be a lerp function itself, with the + // signature `R Function(T? a, T? b, double t)`. + // + // For generic functions like T? Function(T?, T?, double) we can't easily + // check exact type compatibility without type substitution, so only the + // structure is verified. + if (lerpFunction.type + case FunctionType(formalParameters: [final f1, final f2, final f3]) + when f1.type.hasNullableSuffix && + f2.type.hasNullableSuffix && + f3.type.isDartCoreDouble) { + // The generic is read from the declaring type rather than from the field + // type, so that a non-generic subclass such as `WidgetStateColor` + // resolves to `WidgetStateProperty`. + final declaringElement = p1.type.element; + final declaringType = declaringElement is InterfaceElement + ? type.asInstanceOf(declaringElement) + : null; + + if (declaringType == null || declaringType.typeArguments.length != 1) { + warn(); + + return const NoLerp(); + } + + final baseTypeName = declaringType.element.displayName; + final innerType = declaringType.typeArguments.single; + + // Check that the generic type is nullable. Inside another generic this is + // one more shape the outer lerp function cannot take, which the caller + // reports; on the field itself it is a mistake worth stopping for. + if (!innerType.hasNullableSuffix) { + if (nested) { + return const NoLerp(); + } + + final typeName = type.getDisplayString(); + final innerTypeName = innerType.getDisplayString(); + + throw InvalidGenerationSourceError( + '$baseTypeName must have a nullable generic type, because ' + '$baseTypeName.lerp requires a lerp function with nullable ' + 'parameters. Found: $typeName', + element: fieldElement, + todo: + 'Change the type of ${fieldElement.displayName} to ' + '$baseTypeName<$innerTypeName?>', + ); + } + + final genericIsDouble = innerType.isDartCoreDouble; + final genericIsDuration = innerType.isDuration; + + // Anything else is interpolated by a static lerp on the generic itself, + // which `WidgetStateProperty.lerp` calls with nullable arguments. + if (!genericIsDouble && !genericIsDuration) { + final innerLerp = _lerpInfo(innerType, fieldElement, nested: true); + + if (innerLerp is! StaticLerp || + !innerLerp.optionalResult || + !innerLerp.isNullableParameter) { + if (!nested) { + _warnUninterpolatedGeneric( + type, + baseTypeName, + innerType, + fieldElement, + ); + } + + return const NoLerp(); + } + } + + return WidgetStatePropertyLerp( + baseTypeName: baseTypeName, + genericType: innerType.baseType, + genericIsDouble: genericIsDouble, + genericIsDuration: genericIsDuration, + ); + } + + // A four parameter lerp whose last parameter isn't a lerp function is not + // something we know how to call. + warn(); + + return const NoLerp(); +} + +/// Reports a `WidgetStateProperty` shaped [type] whose generic [innerType] +/// cannot be interpolated. +/// +/// The lerp function `WidgetStateProperty.lerp` takes is a static `lerp` on +/// the generic that accepts and returns a null. Whether the generic has no +/// `lerp` at all or one of another shape, the outcome is the same, so the +/// message names the signature that is missing rather than the one found. +void _warnUninterpolatedGeneric( + InterfaceType type, + String baseTypeName, + DartType innerType, + FieldElement fieldElement, +) { + final generic = innerType.baseType; + + log.warning( + '${type.baseType} cannot be interpolated: `$generic` has no static ' + '`$generic? lerp($generic?, $generic?, double)` for `$baseTypeName.lerp` ' + 'to call, so the field `${fieldElement.displayName}` switches over at ' + 't = 0.5 instead of being interpolated.', + ); +} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart new file mode 100644 index 0000000..cae0c9a --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/lookup/merge_lookup.dart @@ -0,0 +1,98 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import '../dart_type_extension.dart'; +import '../symbols/merge_info.dart'; +import '../type_checkers.dart'; +import 'method_lookup.dart'; + +/// Decides how a field of [type] is merged. +/// +/// Returns [NoMerge] when the type is not an interface, has no merge method, +/// or declares one whose signature we cannot call. +MergeInfo mergeInfo(DartType type, FieldElement fieldElement) { + if (type is! InterfaceType) { + return const NoMerge(); + } + + final method = lookupMethod(type, 'merge'); + + if (method == null) { + return _promisedMerge(type); + } + + final params = callableParameters(method); + + if (params == null) { + warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); + } + + final strict = hasStrictSignature(method); + + if (params case [final p1, final p2] + // Check for static merge method + // - should have two parameters + // - both parameters should accept the class type + // - the result should be usable as the class type + when method.isStatic && + checkSubtype(p1, type, strict: strict) && + checkSubtype(p2, type, strict: strict) && + isUsableAs(method.returnType, type, type)) { + return const StaticMerge(); + } + + if (params case [final p1] + // Check for instance merge method: + // - should have only one parameter + // - parameter type should accept the class type + when !method.isStatic && checkSubtype(p1, type, strict: strict)) { + // As for lerp: a method declared on a supertype returns that supertype, + // which the generated code casts back to the field type. + final needsCast = !isUsableAs(method.returnType, type, type); + + if (needsCast && !isUsableAs(type, method.returnType, type)) { + warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); + } + + return InstanceMerge( + isNullableParameter: p1.type.hasNullableSuffix, + needsCast: needsCast, + ); + } + + // The type declares a `merge` we don't know how to call. + warnUnsupported('merge', type, fieldElement); + + return const NoMerge(); +} + +/// The `merge` a type without one is going to have once its part file is +/// generated. +/// +/// A `@ThemeGen` class gets `T merge(T? other)` from its generated mixin, +/// which may not exist yet when this runs. The annotation is taken as the +/// promise that it will. A subclass of the annotated class inherits that +/// method, whose result is the base type and has to be cast back — the same +/// shape the lookup resolves once the mixin exists, so the generated code +/// does not depend on whether the build is clean or incremental. +/// +/// A `merge` the class writes itself is found by the lookup before this is +/// reached, so a hand-written signature is never mistaken for the generated +/// one. +MergeInfo _promisedMerge(InterfaceType type) { + if (themeGenChecker.hasAnnotationOfExact(type.element)) { + return const InstanceMerge(); + } + + final inheritsThemeGen = type.allSupertypes.any( + (supertype) => themeGenChecker.hasAnnotationOfExact(supertype.element), + ); + + return inheritsThemeGen + ? const InstanceMerge(needsCast: true) + : const NoMerge(); +} diff --git a/packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart b/packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart new file mode 100644 index 0000000..d731942 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/lookup/method_lookup.dart @@ -0,0 +1,120 @@ +/// Shared pieces of the `lerp` and `merge` lookups. +library; + +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; + +import '../dart_type_extension.dart'; + +/// Looks up a method with the given [name] on [type]. +/// +/// Instance methods are resolved against the instantiated type, including +/// inherited ones, so their parameter and return types have the type +/// arguments of [type] substituted in. Static methods are neither inherited +/// nor substituted, so they are read off the element. +MethodElement? lookupMethod(InterfaceType type, String name) => + type.lookUpMethod(name, type.element.library) ?? + type.element.getMethod(name); + +/// The parameters the generated code has to fill in to call [method]. +/// +/// Optional parameters take no part in a signature check: a method stays +/// callable the way we expect when it has extra defaulted parameters. A +/// required named parameter cannot be filled in at all, which is reported as +/// `null`. +List? callableParameters(MethodElement method) { + if (method.formalParameters.any((p) => p.isRequiredNamed)) { + return null; + } + + return method.formalParameters + .where((p) => p.isRequiredPositional) + .toList(growable: false); +} + +/// Whether the parameter types of [method] can be compared as written. +/// +/// A generic method's parameter types mention its own type parameters, which +/// can't be substituted here, so those are matched on the declaring class +/// only. +bool hasStrictSignature(MethodElement method) => method.typeParameters.isEmpty; + +/// Checks that a value of [type] can be passed to [param]. +/// +/// Nullability is ignored on both sides: a lerp method taking `T?` accepts a +/// non-nullable field, and a nullable field is null checked at the call site. +/// +/// When [strict] is `true` the parameter type is compared as written, type +/// arguments included. It has to be `false` for a generic method, whose +/// parameter type mentions type parameters we cannot substitute here; only +/// the declaring class is checked then. +bool checkSubtype( + FormalParameterElement param, + DartType type, { + required bool strict, +}) { + final typeElement = type.element; + if (typeElement is! InterfaceElement) { + return false; + } + + final parameterType = param.type; + + final paramTypeElement = parameterType.element; + if (paramTypeElement is! InterfaceElement) { + return false; + } + + final typeSystem = typeElement.library.typeSystem; + final nonNullType = typeSystem.promoteToNonNull(type); + + if (strict) { + return typeSystem.isSubtypeOf( + nonNullType, + typeSystem.promoteToNonNull(parameterType), + ); + } + + final supertypeInstance = type.asInstanceOf(paramTypeElement); + if (supertypeInstance == null) { + return false; + } + + return typeSystem.isSubtypeOf(nonNullType, supertypeInstance); +} + +/// Checks that a value of [subtype] can be used where [supertype] is expected. +/// +/// Nullability is ignored on both sides: a `T? merge(T other)` is still a +/// merge method, the generated code just has to cope with the null. +bool isUsableAs(DartType subtype, DartType supertype, InterfaceType context) { + final typeSystem = context.element.library.typeSystem; + + return typeSystem.isSubtypeOf( + typeSystem.promoteToNonNull(subtype), + typeSystem.promoteToNonNull(supertype), + ); +} + +/// Reports a [methodName] method that exists but cannot be called. +/// +/// A type is free to declare an unrelated `lerp` or `merge`, so this is not an +/// error, but it is worth saying out loud: without the warning "the type has +/// no such method" and "the method is not one I can call" look the same in the +/// generated code. +void warnUnsupported( + String methodName, + DartType type, + FieldElement fieldElement, +) { + final fallback = methodName == 'lerp' + ? 'switches over at t = 0.5 instead of being interpolated' + : 'is overwritten instead of being merged'; + + log.warning( + 'The `$methodName` method of ${type.baseType} has an ' + 'unsupported signature, so the field `${fieldElement.displayName}` ' + '$fallback.', + ); +} diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart index fd86e9f..6b692cc 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/field_info.dart @@ -1,11 +1,11 @@ import 'lerp_info.dart'; import 'merge_info.dart'; -/// Represents comprehensive information about a class field during code -/// generation. +/// What the generators need to know about one instance field. /// -/// This class stores all metadata needed to generate `copyWith`, `lerp`, -/// `merge`, `==`, and `hashCode` methods for theme extensions. +/// Carries everything needed to emit `copyWith`, `lerp`, `merge`, `==` and +/// `hashCode` for the field. Static and private fields, and fields marked +/// `@ignore`, are never turned into a [FieldInfo]. final class FieldInfo { /// Creates a [FieldInfo] with the specified properties. const FieldInfo({ @@ -16,15 +16,28 @@ final class FieldInfo { required this.isDuration, required this.merge, required this.lerp, - required this.isStatic, }); /// The name of the field. final String name; /// The type name of the field without nullability suffix. + /// + /// Type arguments are part of it, so this is the name to declare a variable + /// or write a cast with. Use [baseTypeName] to call a static member. final String typeName; + /// The type name without type arguments. + /// + /// A static member is reached through the class, not through an + /// instantiation of it: `Box.lerp(...)` is valid where `Box.lerp(...)` + /// is not. + String get baseTypeName { + final index = typeName.indexOf('<'); + + return index == -1 ? typeName : typeName.substring(0, index); + } + /// Whether the field type is nullable. final bool isNullable; @@ -44,11 +57,6 @@ final class FieldInfo { /// Information about how to interpolate (lerp) this field type. final LerpInfo lerp; - /// Whether the field is static. - /// - /// Static fields are typically filtered out during code generation. - final bool isStatic; - @override bool operator ==(Object other) => identical(this, other) || @@ -59,7 +67,6 @@ final class FieldInfo { isNullable == other.isNullable && isDouble == other.isDouble && isDuration == other.isDuration && - isStatic == other.isStatic && merge == other.merge && lerp == other.lerp; @@ -71,7 +78,6 @@ final class FieldInfo { isNullable, isDouble, isDuration, - isStatic, merge, lerp, ); @@ -83,7 +89,6 @@ final class FieldInfo { 'isNullable: $isNullable, ' 'isDouble: $isDouble, ' 'isDuration: $isDuration, ' - 'isStatic: $isStatic, ' 'merge: $merge, ' 'lerp: $lerp)'; } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart index 31dc28e..e358d2f 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/lerp_info.dart @@ -1,42 +1,26 @@ -import 'package:collection/collection.dart'; - -import 'parameter_info.dart'; - -const _listEquality = ListEquality(); - -/// Base sealed class representing information about a lerp (linear -/// interpolation) method. +/// How a field type is interpolated. /// -/// This is used during code generation to determine how to generate lerp -/// logic for different field types. +/// Decided once per field while the class is analysed, then switched over by +/// the code builders. sealed class LerpInfo { const LerpInfo(); } -/// Represents a static lerp method with specific signature requirements. -/// -/// Static lerp methods typically have the signature: -/// `static T? lerp(T? a, T? b, double t)` +/// A static `lerp` on the field type: `static T? lerp(T? a, T? b, double t)`. final class StaticLerp extends LerpInfo { /// Creates a [StaticLerp] with the specified properties. - const StaticLerp({required this.optionalResult, required this.args}); - - /// The parameters of the lerp method. - final List args; + const StaticLerp({ + required this.optionalResult, + required this.isNullableParameter, + }); /// Whether the return type of the lerp method is nullable. final bool optionalResult; - /// Returns `true` if the lerp method signature accepts nullable parameters - /// and returns a nullable result. + /// Whether the method accepts a null on both sides. /// - /// This is determined by checking if the result is optional and the first - /// two arguments are nullable. - bool get isNullableSignature => - optionalResult && - args.length >= 2 && - args[0].isNullable && - args[1].isNullable; + /// When it doesn't, the call site has to guard against a null itself. + final bool isNullableParameter; @override bool operator ==(Object other) => @@ -44,34 +28,37 @@ final class StaticLerp extends LerpInfo { other is StaticLerp && runtimeType == other.runtimeType && optionalResult == other.optionalResult && - _listEquality.equals(args, other.args); + isNullableParameter == other.isNullableParameter; @override - int get hashCode => Object.hash(runtimeType, optionalResult); + int get hashCode => + Object.hash(runtimeType, optionalResult, isNullableParameter); @override String toString() => - 'StaticLerp(optionalResult: $optionalResult, args: $args)'; + 'StaticLerp(optionalResult: $optionalResult, ' + 'isNullableParameter: $isNullableParameter)'; } -/// Represents an instance lerp method on a class. +/// An instance `lerp` on the field type: `T lerp(T other, double t)`. /// -/// Instance lerp methods typically have the signature: -/// `T lerp(T other, double t)` +/// The generated code never passes a null to it: a nullable field is guarded +/// at the call site whatever the parameter type is, so that `t == 0` keeps +/// `a` and `t == 1` keeps `b` when the other side is null. final class InstanceLerp extends LerpInfo { /// Creates an [InstanceLerp] with the specified properties. - const InstanceLerp({required this.optionalResult, required this.args}); - - /// The parameters of the lerp method. - final List args; + const InstanceLerp({required this.optionalResult, this.needsCast = false}); /// Whether the return type of the lerp method is nullable. final bool optionalResult; - /// Returns `true` if the lerp method signature accepts a nullable parameter - /// and returns a nullable result. - bool get isNullableSignature => - optionalResult && args.isNotEmpty && args[0].isNullable; + /// Whether the result has to be cast back to the field type. + /// + /// A method declared on a supertype returns that supertype: the generated + /// `lerp` of a theme extension returns `ThemeExtension`, not `T`. A + /// method that already returns the field type needs no cast, and adding one + /// would trip `unnecessary_cast` in the generated file. + final bool needsCast; @override bool operator ==(Object other) => @@ -79,52 +66,76 @@ final class InstanceLerp extends LerpInfo { other is InstanceLerp && runtimeType == other.runtimeType && optionalResult == other.optionalResult && - _listEquality.equals(args, other.args); + needsCast == other.needsCast; @override - int get hashCode => Object.hash(runtimeType, optionalResult, args); + int get hashCode => Object.hash(runtimeType, optionalResult, needsCast); @override String toString() => - 'InstanceLerp(optionalResult: $optionalResult, args: $args)'; + 'InstanceLerp(optionalResult: $optionalResult, needsCast: $needsCast)'; } +/// A `WidgetStateProperty` shaped field, interpolated through the four +/// parameter `WidgetStateProperty.lerp` with a lerp function for the generic. final class WidgetStatePropertyLerp extends LerpInfo { /// Creates a [WidgetStatePropertyLerp] with the specified properties. const WidgetStatePropertyLerp({ required this.baseTypeName, required this.genericType, - required this.isNullableGeneric, + required this.genericIsDouble, + required this.genericIsDuration, }); /// The base type name without generics. /// For `WidgetStateProperty` this is 'WidgetStateProperty'. final String baseTypeName; - /// The generic type with nullability. + /// The generic type without its nullability suffix. /// For `WidgetStateProperty` this is 'Color'. + /// + /// The generic is always nullable: a non-nullable one is refused before a + /// [WidgetStatePropertyLerp] is made, because the lerp function + /// `WidgetStateProperty.lerp` takes has to accept a null. final String genericType; - final bool isNullableGeneric; + /// Whether the generic is `double` from `dart:core`. + final bool genericIsDouble; - bool get genericIsDouble => genericType == 'double'; - - bool get genericIsDuration => genericType == 'Duration'; + /// Whether the generic is `Duration` from `dart:core`. + final bool genericIsDuration; + /// The generic without its own type arguments. + /// + /// The inner lerp is reached through the class, so a generic generic — + /// `WidgetStateProperty?>` — has to call `Box.lerp`, not + /// `Box.lerp`. + String get genericBaseTypeName { + final index = genericType.indexOf('<'); + + return index == -1 ? genericType : genericType.substring(0, index); + } + + // genericIsDouble and genericIsDuration are decided by element, not by + // name, so a user type called `Duration` shares genericType with the real + // one while being interpolated differently. @override bool operator ==(Object other) => identical(this, other) || other is WidgetStatePropertyLerp && runtimeType == other.runtimeType && baseTypeName == other.baseTypeName && - genericType == other.genericType; + genericType == other.genericType && + genericIsDouble == other.genericIsDouble && + genericIsDuration == other.genericIsDuration; @override int get hashCode => Object.hash( runtimeType, - baseTypeName, genericType, + genericIsDouble, + genericIsDuration, ); @override @@ -132,13 +143,15 @@ final class WidgetStatePropertyLerp extends LerpInfo { 'WidgetStatePropertyLerp(' 'baseTypeName: $baseTypeName, ' 'genericType: $genericType, ' - ')'; + 'genericIsDouble: $genericIsDouble, ' + 'genericIsDuration: $genericIsDuration)'; } -/// Indicates that no lerp method is available for the field type. +/// No usable lerp method on the field type. /// -/// When this is used, the generator will fall back to a simple conditional -/// expression: `t < 0.5 ? a : b` +/// `double` and `Duration` fields are still interpolated, through +/// `lerpDouble$` and `lerpDuration$`. Anything else switches over at +/// `t < 0.5 ? a : b`. final class NoLerp extends LerpInfo { /// Creates a [NoLerp] instance. const NoLerp(); diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart index 8c355da..9351519 100644 --- a/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart +++ b/packages/theme_extensions_builder/lib/src/common/symbols/merge_info.dart @@ -4,16 +4,6 @@ /// instances together. sealed class MergeInfo { const MergeInfo(); - - @override - bool operator ==(Object other) => - identical(this, other) || other.runtimeType == runtimeType; - - @override - int get hashCode => runtimeType.hashCode; - - @override - String toString() => 'MergeInfo()'; } /// Indicates that no merge method is available for the field type. @@ -53,18 +43,41 @@ final class StaticMerge extends MergeInfo { } /// Represents an instance merge method with the signature: -/// `T merge(T other)` +/// `T merge(T other)` or `T merge(T? other)` final class InstanceMerge extends MergeInfo { /// Creates an [InstanceMerge] instance. - const InstanceMerge(); + const InstanceMerge({ + this.isNullableParameter = true, + this.needsCast = false, + }); + + /// Whether the result has to be cast back to the field type. + /// + /// A method declared on a supertype returns that supertype, so the value it + /// produces has to be narrowed before it can be passed on. A method that + /// already returns the field type needs no cast, and adding one would trip + /// `unnecessary_cast` in the generated file. + final bool needsCast; + + /// Whether the merge method accepts a nullable argument. + /// + /// Methods generated by `@ThemeGen` do, so this defaults to `true`. When a + /// method doesn't, the call site has to guard against a null `other`. + final bool isNullableParameter; @override bool operator ==(Object other) => - identical(this, other) || other.runtimeType == runtimeType; + identical(this, other) || + other is InstanceMerge && + runtimeType == other.runtimeType && + isNullableParameter == other.isNullableParameter && + needsCast == other.needsCast; @override - int get hashCode => runtimeType.hashCode; + int get hashCode => Object.hash(runtimeType, isNullableParameter, needsCast); @override - String toString() => 'InstanceMerge()'; + String toString() => + 'InstanceMerge(isNullableParameter: $isNullableParameter, ' + 'needsCast: $needsCast)'; } diff --git a/packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart b/packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart deleted file mode 100644 index 0a85ddc..0000000 --- a/packages/theme_extensions_builder/lib/src/common/symbols/parameter_info.dart +++ /dev/null @@ -1,37 +0,0 @@ -/// Represents information about a method parameter during code generation. -/// -/// This class stores metadata about parameters in lerp and merge methods, -/// including the parameter name, type, and nullability. -final class ParameterInfo { - /// Creates a [ParameterInfo] with the specified properties. - const ParameterInfo({ - required this.name, - required this.type, - required this.isNullable, - }); - - /// The name of the parameter. - final String name; - - /// The type of the parameter as a string. - final String type; - - /// Whether the parameter type is nullable. - final bool isNullable; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ParameterInfo && - runtimeType == other.runtimeType && - name == other.name && - type == other.type && - isNullable == other.isNullable; - - @override - int get hashCode => Object.hash(runtimeType, name, type, isNullable); - - @override - String toString() => - 'ParameterInfo(name: $name, type: $type, isNullable: $isNullable)'; -} diff --git a/packages/theme_extensions_builder/lib/src/common/type_checkers.dart b/packages/theme_extensions_builder/lib/src/common/type_checkers.dart new file mode 100644 index 0000000..542c3f7 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/type_checkers.dart @@ -0,0 +1,22 @@ +import 'package:source_gen/source_gen.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +/// The package the annotations come from. +/// +/// Every checker is restricted to it: a user class that happens to be called +/// `ThemeGen` must not be mistaken for the annotation. +const annotationPackage = 'theme_extensions_builder_annotation'; + +/// Matches the `@ThemeGen` annotation. +const themeGenChecker = TypeChecker.typeNamed( + ThemeGen, + inPackage: annotationPackage, +); + +/// Matches the `@ignore` annotation. +/// +/// The annotation class is private, so it is reached through the constant. +final ignoreChecker = TypeChecker.typeNamed( + ignore.runtimeType, + inPackage: annotationPackage, +); diff --git a/packages/theme_extensions_builder/lib/src/common/validation.dart b/packages/theme_extensions_builder/lib/src/common/validation.dart new file mode 100644 index 0000000..1501f5d --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/common/validation.dart @@ -0,0 +1,339 @@ +/// Checks on the annotated class that turn a cryptic error in the generated +/// file into an [InvalidGenerationSourceError] pointing at the cause. +library; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:source_gen/source_gen.dart'; + +import 'symbols/field_info.dart'; + +/// The name of the mixin generated for [element]: `_$ClassName`. +/// +/// The mixin is applied by name, so the name is a convention shared with the +/// annotated class. +String generatedMixinName(ClassElement element) => '_\$${element.displayName}'; + +/// Resolves the constructor the generated code instantiates [element] with. +/// +/// [name] is the `constructor` option of the annotation; `null` selects the +/// unnamed constructor. +ConstructorElement resolveConstructor(ClassElement element, String? name) { + final className = element.displayName; + final constructor = name == null + ? element.unnamedConstructor + : element.getNamedConstructor(name); + + if (constructor != null) { + return constructor; + } + + if (name == null) { + throw InvalidGenerationSourceError( + '`$className` has no unnamed constructor, which the generated code ' + 'calls.', + element: element, + todo: + 'Declare `$className({...})`, or point `constructor:` at the ' + 'constructor to use.', + ); + } + + throw InvalidGenerationSourceError( + '`$className` has no constructor named `$name`.', + element: element, + todo: + 'Declare `$className.$name({...})`, or point `constructor:` at an ' + 'existing constructor.', + ); +} + +/// Checks that [element] declares no type parameters. +/// +/// The generated mixin has none: it names the class without type arguments +/// in its `on` clause, its constructor calls and its `lerp` signature, so a +/// type parameter of the class would be undefined inside it. +void checkNotGeneric(ClassElement element) { + final typeParameters = element.typeParameters; + + if (typeParameters.isEmpty) { + return; + } + + final className = element.displayName; + final parameters = typeParameters + .map((parameter) => parameter.displayName) + .join(', '); + + throw InvalidGenerationSourceError( + '`$className<$parameters>` is generic, and the generated mixin cannot ' + 'be: it instantiates `$className` without type arguments.', + element: element, + todo: + 'Remove the type parameters from `$className`, or write its theme ' + 'methods by hand.', + ); +} + +/// Checks that [fields] and [constructor] agree on what the generated code +/// passes. +/// +/// The generated `copyWith`, `lerp` and `merge` build a new instance with one +/// named argument per field, so each field needs a named parameter of the +/// same name, and every required parameter has to be one of those: a +/// parameter the generated code cannot fill in is a missing argument in the +/// generated file. +void checkConstructorParameters( + ClassElement element, + ConstructorElement constructor, + List fields, +) { + final constructorName = constructor.name == 'new' + ? element.displayName + : '${element.displayName}.${constructor.name}'; + + final named = { + for (final parameter in constructor.formalParameters) + if (parameter.isNamed) parameter.displayName, + }; + + final missing = [ + for (final field in fields) + if (!named.contains(field.name)) field.name, + ]; + + if (missing.isNotEmpty) { + final fieldList = missing.map((name) => '`$name`').join(', '); + final plural = missing.length > 1; + + throw InvalidGenerationSourceError( + 'The constructor `$constructorName` has no named ' + '${plural ? 'parameters' : 'parameter'} for the ' + '${plural ? 'fields' : 'field'} $fieldList, which the generated code ' + 'passes to it.', + element: element, + todo: + 'Add `this.${missing.first}`${plural ? ' and the others' : ''} to ' + '`$constructorName`, or mark the ' + '${plural ? 'fields' : 'field'} with `@ignore`.', + ); + } + + final passed = {for (final field in fields) field.name}; + + // A required positional parameter is never passed: the generated code only + // names its arguments. + final unfilled = [ + for (final parameter in constructor.formalParameters) + if (parameter.isRequired && + !(parameter.isNamed && passed.contains(parameter.displayName))) + parameter.displayName, + ]; + + if (unfilled.isEmpty) { + return; + } + + final parameterList = unfilled.map((name) => '`$name`').join(', '); + final plural = unfilled.length > 1; + + throw InvalidGenerationSourceError( + 'The constructor `$constructorName` requires $parameterList, which ' + '${plural ? 'are' : 'is'} not among the fields the generated code passes ' + 'to it.', + element: element, + todo: + 'Make `${unfilled.first}`${plural ? ' and the others' : ''} optional, ' + 'or make ${plural ? 'them fields' : 'it a field'} the generated code ' + 'passes: declare ${plural ? 'them' : 'it'} as ' + '`this.${unfilled.first}`, without `@ignore` on the field.', + ); +} + +/// Checks that no field in [fields] takes a name in [reserved], the members +/// the generated mixin declares. +/// +/// A field is a getter, which cannot override a method the mixin declares, +/// and a static method cannot share a name with an instance member: the class +/// applying the mixin fails to compile with an error that never mentions the +/// generator. +void checkReservedFieldNames( + ClassElement element, + List fields, { + required Set reserved, +}) { + final clashing = fields + .map((field) => field.name) + .where(reserved.contains) + .firstOrNull; + + if (clashing == null) { + return; + } + + throw InvalidGenerationSourceError( + 'The generated mixin `${generatedMixinName(element)}` declares ' + '`$clashing`, so `${element.displayName}` cannot have a field of that ' + 'name.', + element: element, + todo: 'Rename the field `$clashing`.', + ); +} + +/// Checks that [element] applies the generated mixin. +/// +/// Without it the generated methods exist but are reachable from nowhere: +/// another theme holding a field of this type calls a `merge` the class does +/// not have. +/// +/// The mixin lives in the file about to be generated, so on a clean build it +/// does not resolve and leaves no trace in the element model. The clause is +/// read from the parsed declaration instead. A declaration the session cannot +/// produce is not held against the class. +void checkMixinApplied(ClassElement element) { + final mixinName = generatedMixinName(element); + final declaration = _declarationOf(element); + + if (declaration == null) { + return; + } + + final mixins = declaration.withClause?.mixinTypes ?? const []; + + if (mixins.any((type) => type.name.lexeme == mixinName)) { + return; + } + + final className = element.displayName; + + throw InvalidGenerationSourceError( + '`$className` does not apply the generated mixin `$mixinName`, which ' + 'holds the generated methods.', + element: element, + todo: 'Add `with $mixinName` to the declaration of `$className`.', + ); +} + +/// The parsed declaration of [element], or `null` when the session cannot +/// parse its library. +ClassDeclaration? _declarationOf(ClassElement element) { + final library = element.library; + final parsed = library.session.getParsedLibraryByElement(library); + + if (parsed is! ParsedLibraryResult) { + return null; + } + + final name = element.displayName; + + for (final unit in parsed.units) { + for (final declaration in unit.unit.declarations) { + if (declaration is ClassDeclaration && + declaration.namePart.typeName.lexeme == name) { + return declaration; + } + } + } + + return null; +} + +/// Checks that [element] extends `ThemeExtension`. +/// +/// The generated mixin is declared `on ThemeExtension`, so it cannot be +/// applied to anything else. +void checkExtendsThemeExtension(ClassElement element) { + final className = element.displayName; + + final themeExtension = element.allSupertypes + .where((type) => type.element.name == 'ThemeExtension') + .firstOrNull; + + final typeArguments = themeExtension?.typeArguments; + + if (typeArguments != null && + typeArguments.length == 1 && + typeArguments.single.element == element) { + return; + } + + throw InvalidGenerationSourceError( + '`$className` must extend `ThemeExtension<$className>` to be annotated ' + 'with `@ThemeExtensions`.', + element: element, + todo: + 'Declare it as ' + '`class $className extends ThemeExtension<$className> ' + 'with _\$$className`, or use `@ThemeGen` for a plain class.', + ); +} + +final _identifier = RegExp(r'^[A-Za-z_$][A-Za-z0-9_$]*$'); + +/// Words the language keeps for itself, which the identifier pattern cannot +/// tell from a name. +const _reservedWords = { + 'assert', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'default', + 'do', + 'else', + 'enum', + 'extends', + 'false', + 'final', + 'finally', + 'for', + 'if', + 'in', + 'is', + 'new', + 'null', + 'rethrow', + 'return', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'var', + 'void', + 'while', + 'with', +}; + +/// Checks that [value], given as the annotation option [option], can be +/// written into the generated code as a name. +void checkIdentifier( + String value, { + required String option, + required ClassElement element, +}) { + if (_reservedWords.contains(value)) { + throw InvalidGenerationSourceError( + '`$value` is a reserved word, so it cannot be used as `$option`.', + element: element, + todo: 'Use a name that is not a Dart keyword.', + ); + } + + if (_identifier.hasMatch(value)) { + return; + } + + throw InvalidGenerationSourceError( + '`$value` is not a valid Dart identifier, so it cannot be used as ' + '`$option`.', + element: element, + todo: + r'Use letters, digits, `_` and `$` only, and do not start with a ' + 'digit.', + ); +} diff --git a/packages/theme_extensions_builder/lib/src/config/config.dart b/packages/theme_extensions_builder/lib/src/config/config.dart index 2e0122f..62f6182 100644 --- a/packages/theme_extensions_builder/lib/src/config/config.dart +++ b/packages/theme_extensions_builder/lib/src/config/config.dart @@ -17,13 +17,9 @@ sealed class BaseConfig { required this.constConstructor, }); - /// The fields to be included in the generated theme extension. + /// The fields the generated code is built from. final List fields; - /// The fields that are supported for generation (non-static fields). - Iterable get filteredFields => - fields.where((field) => !field.isStatic); - /// The name of the class to be generated. final String className; @@ -31,7 +27,9 @@ sealed class BaseConfig { /// constructor will be used. final String? constructor; - /// Whether to generate a const constructor. + /// Whether [constructor] is `const`. + /// + /// A const constructor is invoked with `const` when it takes no arguments. final bool constConstructor; } diff --git a/packages/theme_extensions_builder/lib/src/extensions/string.dart b/packages/theme_extensions_builder/lib/src/extensions/string.dart index ee06494..d87d9c6 100644 --- a/packages/theme_extensions_builder/lib/src/extensions/string.dart +++ b/packages/theme_extensions_builder/lib/src/extensions/string.dart @@ -1,19 +1,18 @@ /// Extension for converting strings to camelCase format. extension StringCamelCase on String { - /// Converts the string to camelCase, with special handling for 'Extension' - /// suffix. + /// Converts the string to camelCase, optionally dropping a suffix. /// - /// This getter: - /// - Returns empty string if the input is empty + /// This method: + /// - Returns an empty string if the input is empty /// - Converts the first character to lowercase - /// - Removes 'Extension' suffix if present (e.g., 'MyExtension' → 'my') + /// - Removes [suffixToRemove] from the end when it is present /// /// Examples: /// ```dart - /// 'HelloWorld'.camelCase // 'helloWorld' - /// 'MyThemeExtension'.camelCase // 'myTheme' - /// 'theme'.camelCase // 'theme' - /// ''.camelCase // '' + /// 'HelloWorld'.camelCase() // 'helloWorld' + /// 'theme'.camelCase() // 'theme' + /// ''.camelCase() // '' + /// 'MyThemeExtension'.camelCase(suffixToRemove: 'Extension') // 'myTheme' /// ``` String camelCase({String? suffixToRemove}) { if (isEmpty) { diff --git a/packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart b/packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart new file mode 100644 index 0000000..cdd0886 --- /dev/null +++ b/packages/theme_extensions_builder/lib/src/generator/annotation_reader.dart @@ -0,0 +1,22 @@ +import 'package:source_gen/source_gen.dart'; + +/// Reads the optional string options of an annotation. +extension AnnotationReader on ConstantReader { + /// The value of the string field [name], or `null` when it is null or + /// empty. + /// + /// An empty string means the same as leaving the option out, so that + /// `constructor: ''` selects the unnamed constructor rather than emitting + /// `ClassName.()`. + String? optionalString(String name) { + final value = read(name); + + if (value.isNull) { + return null; + } + + final string = value.stringValue; + + return string.isEmpty ? null : string; + } +} diff --git a/packages/theme_extensions_builder/lib/src/generator/common.dart b/packages/theme_extensions_builder/lib/src/generator/common.dart index fe08de5..8fc4286 100644 --- a/packages/theme_extensions_builder/lib/src/generator/common.dart +++ b/packages/theme_extensions_builder/lib/src/generator/common.dart @@ -1,26 +1,235 @@ -/// Common code generation utilities for theme builders. -/// -/// This library provides shared code generation functions used by both -/// ThemeExtensions and ThemeGen generators, including equality operators, -/// hash codes, and utility extensions. +/// Code generation shared by the `@ThemeExtensions` and `@ThemeGen` +/// generators: `copyWith`, the per-field `lerp` expression, `==`, `hashCode`, +/// and the small helpers around `code_builder`. library; import 'package:code_builder/code_builder.dart'; -import 'package:meta/meta.dart'; +import '../common/symbols/field_info.dart'; +import '../common/symbols/lerp_info.dart'; import '../config/config.dart'; +/// The local the generated methods read the current instance through. +/// +/// The methods live in a mixin, so `this` is the mixin type; the local holds +/// it cast to the class, which is what the fields are declared on. +const thisAlias = '_this'; + +/// A reference to [thisAlias]. +Reference get thisRef => thisAlias.ref; + +/// The `t` parameter of a `lerp`. +Reference get tRef => 't'.ref; + +/// Emits the `DartEmitter` configuration every generator uses. +/// +/// The output is a part file, so nothing is ever imported and no allocator is +/// needed; only the null safety syntax matters. +DartEmitter partEmitter() => DartEmitter(useNullSafetySyntax: true); + +/// `final _this = (this as ClassName);` +Expression declareThis(BaseConfig config) => + declareFinal(thisAlias).assign('this'.ref.asA(config.className.ref)); + +/// Builds a new instance of the configured class with [args]. +/// +/// The call is `const` when the constructor allows it and there is nothing +/// to pass, which is the only case where the arguments are constant too. +Expression construct(BaseConfig config, Map args) => + (args.isEmpty && config.constConstructor + ? InvokeExpression.constOf + : InvokeExpression.newOf)( + config.className.ref, + [], + args, + [], + config.constructor, + ); + +/// Generates `copyWith`: every field as an optional named parameter that +/// falls back to the current value. +Method copyWithMethod( + BaseConfig config, { + required Reference returns, + bool isOverride = false, +}) => Method((m) { + final fields = config.fields; + + m + ..name = 'copyWith' + ..returns = returns + ..optionalParameters.addAll( + fields.map( + (field) => Parameter( + (p) => p + ..name = field.name + ..named = true + ..type = field.typeName.typeRef(isNullable: true), + ), + ), + ) + ..body = Block((b) { + if (fields.isNotEmpty) { + b + ..addExpression(declareThis(config)) + ..addEmptyLine(); + } + + b.addExpression( + construct(config, { + for (final field in fields) + field.name: field.name.ref.ifNullThen(thisRef.property(field.name)), + }).returned, + ); + }); + + if (isOverride) { + m.annotations.add(overrideAnnotation); + } +}); + +/// The `@override` annotation. +Reference get overrideAnnotation => 'override'.ref; + +/// The expression that interpolates [field] between [a] and [b] at `t`. +/// +/// [a] and [b] are the two values of the field, `a.field` and `b.field` or +/// `_this.field` and `other.field`, depending on the generator. +Expression lerpFieldExpression(FieldInfo field, Expression a, Expression b) { + final staticLerp = field.baseTypeName.ref.property('lerp'); + final fieldType = field.typeName.typeRef(isNullable: field.isNullable); + + // An interpolation that returns a nullable result still has to produce a + // value for a non-nullable field. + Expression nullCheckedUnlessNullable(Expression expression) => + field.isNullable ? expression : expression.nullChecked; + + return switch (field.lerp) { + // Non-nullable field, static lerp returning an optional result: + // Class.lerp(a.field, b.field, t)! + StaticLerp(optionalResult: true) when !field.isNullable => staticLerp([ + a, + b, + tRef, + ]).nullChecked, + + // Non-nullable field, static lerp returning a non-optional result: + // Class.lerp(a.field, b.field, t) + StaticLerp() when !field.isNullable => staticLerp([a, b, tRef]), + + // Nullable field, static lerp taking nullable arguments: + // Class.lerp(a.field, b.field, t) + StaticLerp(isNullableParameter: true) => staticLerp([a, b, tRef]), + + // Nullable field, static lerp taking non-nullable arguments: + // a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : Class.lerp(a.field!, b.field!, t) + StaticLerp() => _nullGuarded( + a, + b, + staticLerp([a.nullChecked, b.nullChecked, tRef]), + ), + + // Nullable field, instance lerp declared on a supertype: + // a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : (a.field!.lerp(b.field!, t) as Class?) + InstanceLerp(needsCast: true) when field.isNullable => _nullGuarded( + a, + b, + a.nullChecked.property('lerp')([b.nullChecked, tRef]).asA(fieldType), + ), + + // Nullable field, instance lerp: + // a.field == null || b.field == null + // ? (t < 0.5 ? a.field : b.field) + // : a.field!.lerp(b.field!, t) + InstanceLerp() when field.isNullable => _nullGuarded( + a, + b, + a.nullChecked.property('lerp')([b.nullChecked, tRef]), + ), + + // Non-nullable field, instance lerp declared on a supertype: + // (a.field.lerp(b.field, t) as Class) + InstanceLerp(needsCast: true) => + a.property('lerp')([b, tRef]).asA(fieldType), + + // Non-nullable field, instance lerp returning an optional result: + // a.field.lerp(b.field, t)! + InstanceLerp(optionalResult: true) => + a.property('lerp')([b, tRef]).nullChecked, + + // Non-nullable field, instance lerp: + // a.field.lerp(b.field, t) + InstanceLerp() => a.property('lerp')([b, tRef]), + + // WidgetStateProperty.lerp(a.field, b.field, t, Color.lerp) + WidgetStatePropertyLerp( + :final baseTypeName, + :final genericType, + :final genericBaseTypeName, + :final genericIsDouble, + :final genericIsDuration, + ) => + nullCheckedUnlessNullable( + baseTypeName.ref.property('lerp')( + [ + a, + b, + tRef, + if (genericIsDouble) + r'lerpDouble$'.ref + else if (genericIsDuration) + r'lerpDuration$'.ref + else + genericBaseTypeName.ref.property('lerp'), + ], + {}, + [genericType.typeRef(isNullable: true)], + ), + ), + + // lerpDouble$(a.field, b.field, t) + NoLerp() when field.isDouble => nullCheckedUnlessNullable( + r'lerpDouble$'.ref([a, b, tRef]), + ), + + // lerpDuration$(a.field, b.field, t) + NoLerp() when field.isDuration => nullCheckedUnlessNullable( + r'lerpDuration$'.ref([a, b, tRef]), + ), + + // t < 0.5 ? a.field : b.field + NoLerp() => _switchOver(a, b), + }; +} + +/// `t < 0.5 ? a : b` +Expression _switchOver(Expression a, Expression b) => + tRef.lessThan(literalNum(0.5)).conditional(a, b); + +/// Wraps [lerpCall] so that it only runs when both sides are present. +/// +/// An interpolation that cannot accept a null falls back to the value the +/// timeline is closest to, which keeps `t == 0` on [a] and `t == 1` on [b]. +Expression _nullGuarded(Expression a, Expression b, Expression lerpCall) => a + .equalTo(literalNull) + .or(b.equalTo(literalNull)) + .conditional(_switchOver(a, b), lerpCall); + /// Generates the equality operator (`==`) for theme classes. /// /// The generated method performs identity and type checks before comparing -/// all non-static fields from [config]. Returns `true` if all fields are equal, -/// `false` otherwise. +/// all fields from [config]. Returns `true` if all fields are equal, `false` +/// otherwise. Method equalOperator(BaseConfig config) => Method((m) { - final fields = config.filteredFields; + final fields = config.fields; final className = config.className; m ..name = 'operator ==' - ..annotations.add('override'.ref) + ..annotations.add(overrideAnnotation) ..returns = 'bool'.ref ..requiredParameters.add( Parameter( @@ -40,7 +249,7 @@ Method equalOperator(BaseConfig config) => Method((m) { ..addEmptyLine() ..statements.add( ifStatement( - 'other'.ref.prop('runtimeType').notEqualTo('runtimeType'.ref), + 'other'.ref.property('runtimeType').notEqualTo('runtimeType'.ref), Block((b) => b.addExpression(literalFalse.returned)), ), ) @@ -48,9 +257,7 @@ Method equalOperator(BaseConfig config) => Method((m) { if (fields.isNotEmpty) { b - ..addExpression( - declareFinal('_this').assign('this'.ref.asA(className.ref)), - ) + ..addExpression(declareThis(config)) ..addExpression( declareFinal('_other').assign('other'.ref.asA(className.ref)), ) @@ -59,8 +266,8 @@ Method equalOperator(BaseConfig config) => Method((m) { fields .map( (field) => '_other'.ref - .prop(field.name) - .equalTo('_this'.ref.prop(field.name)), + .property(field.name) + .equalTo(thisRef.property(field.name)), ) .reduce((a, b) => a.and(b)) .returned, @@ -78,122 +285,76 @@ Method equalOperator(BaseConfig config) => Method((m) { /// - **1-19 fields**: Uses `Object.hash()` for optimal performance /// - **20+ fields**: Uses `Object.hashAll()` for unlimited field support Method hashMethod(BaseConfig config) => Method((m) { - final fields = config.filteredFields; - final className = config.className; + final fields = config.fields; m ..name = 'hashCode' - ..annotations.add('override'.ref) + ..annotations.add(overrideAnnotation) ..returns = 'int'.ref ..type = MethodType.getter ..body = Block((b) { if (fields.isNotEmpty) { b - ..addExpression( - declareFinal('_this').assign('this'.ref.asA(className.ref)), - ) + ..addExpression(declareThis(config)) ..addEmptyLine(); } + final values = [ + 'runtimeType'.ref, + for (final field in fields) thisRef.property(field.name), + ]; + switch (fields.length) { case 0: - b.addExpression('runtimeType'.ref.prop('hashCode').returned); + b.addExpression('runtimeType'.ref.property('hashCode').returned); case <= 19: - b.addExpression( - 'Object'.ref - .prop('hash')([ - 'runtimeType'.ref, - for (final field in fields) '_this'.ref.prop(field.name), - ]) - .returned, - ); + b.addExpression('Object'.ref.property('hash')(values).returned); case _: b.addExpression( - 'Object'.ref - .prop('hashAll')([ - literalList([ - 'runtimeType'.ref, - for (final field in fields) '_this'.ref.prop(field.name), - ]), - ]) - .returned, + 'Object'.ref.property('hashAll')([literalList(values)]).returned, ); } }); }); -/// Generates an if-else statement as code. +/// Generates an if statement as code. /// /// Creates a code block with the given [condition], executing [ifBlock] when -/// true and optionally [elseBlock] when false. +/// true. /// /// This is a utility function for generating conditional code when using -/// code_builder, as it doesn't provide a built-in if-else construct. -Code ifStatement(Expression condition, Block ifBlock, [Block? elseBlock]) { - final visiter = DartEmitter(); - final conditionV = condition.accept(visiter); - final ifBlockV = ifBlock.accept(visiter); - final elseBlockV = elseBlock?.accept(visiter); - - final ifElse = - 'if($conditionV){$ifBlockV}' - '${elseBlockV != null ? 'else {$elseBlockV}' : ''}'; - - return Code(ifElse); -} +/// code_builder, as it doesn't provide a built-in if construct. +Code ifStatement(Expression condition, Block ifBlock) { + final visitor = partEmitter(); + final conditionV = condition.accept(visitor); + final ifBlockV = ifBlock.accept(visitor); -/// A wrapper around [Reference] that guarantees a non-null symbol. -/// -/// This extension type provides convenient access to the symbol property -/// without null checks, as it's guaranteed to be non-null when constructed -/// through the provided extensions. -extension type const Ref._(Reference ref) implements Reference { - /// Returns the non-null symbol from the underlying [Reference]. - @redeclare - String get symbol => ref.symbol!; + return Code('if($conditionV){$ifBlockV}'); } /// Extension providing shortcut methods for creating references from strings. extension StringRef on String { - /// Creates a [Ref] from this string as a symbol reference. + /// Creates a [Reference] from this string as a symbol reference. /// /// Example: /// ```dart /// 'MyClass'.ref // Ref to MyClass /// ``` - Ref get ref => Ref._(Reference(this)); + Reference get ref => Reference(this); - /// Creates a [Ref] representing a type reference. + /// Creates a [TypeReference] from this string. /// /// Example: /// ```dart /// 'String'.typeRef() // String /// 'int'.typeRef(isNullable: true) // int? /// ``` - Ref typeRef({bool isNullable = false}) => Ref._( - TypeReference( - (b) => b - ..isNullable = isNullable - ..symbol = this, - ), + TypeReference typeRef({bool isNullable = false}) => TypeReference( + (b) => b + ..isNullable = isNullable + ..symbol = this, ); } -/// Extension providing convenient property access for expressions. -extension ExpressionExtensions on Expression { - /// Accesses a property on this expression. - /// - /// When [nullSafe] is `true`, uses null-safe property access (`?.`). - /// Otherwise uses regular property access (`.`). - /// - /// Example: - /// ```dart - /// 'obj'.ref.prop('field') // obj.field - /// 'obj'.ref.prop('field', nullSafe: true) // obj?.field - /// ``` - BinaryExpression prop(String name, {bool nullSafe = false}) => - (nullSafe ? nullSafeProperty(name) : property(name)) as BinaryExpression; -} - /// Extension providing utility methods for building code blocks. extension BlockBuilderExtensions on BlockBuilder { /// Adds an empty line to the code block for better readability. diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart index 038a785..00e9012 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/code_builder.dart @@ -1,11 +1,15 @@ import 'package:code_builder/code_builder.dart'; -import '../../common/symbols/lerp_info.dart'; import '../../config/config.dart'; import '../../extensions/string.dart'; import '../common.dart'; -/// Generates code for `ThemeExtension` mixins and related helpers. +/// The members the generated mixin declares that a field cannot share a name +/// with. +const themeExtensionsReservedNames = {'copyWith', 'lerp'}; + +/// Generates the mixin for a `@ThemeExtensions` class, and the `BuildContext` +/// extension that reaches it. class ThemeExtensionsCodeBuilder { const ThemeExtensionsCodeBuilder(); @@ -21,25 +25,19 @@ class ThemeExtensionsCodeBuilder { final mix = Mixin((m) { m ..name = config.themeExtensionMixinName - ..on = TypeReference( - (t) => t - ..symbol = 'ThemeExtension' - ..types.add(config.className.ref), - ) + ..on = _themeExtensionRef(config) ..methods.addAll([ - copyWith(config), + copyWithMethod( + config, + returns: _themeExtensionRef(config), + isOverride: true, + ), lerpMethod(config), equalOperator(config), hashMethod(config), ]); }); - final emitter = DartEmitter( - allocator: Allocator.simplePrefixing(), - useNullSafetySyntax: true, - orderDirectives: true, - ); - final library = Library( (b) => b.body.addAll([ mix, @@ -47,63 +45,10 @@ class ThemeExtensionsCodeBuilder { ]), ); - return library.accept(emitter).toString(); + return library.accept(partEmitter()).toString(); } } -/// Generates the `copyWith` method for the theme extension. -/// -/// Allows creating a copy of the theme extension with some fields replaced. -Method copyWith(ThemeExtensionsConfig config) => Method((m) { - final fields = config.filteredFields; - - m - ..name = 'copyWith' - ..annotations.add('override'.ref) - ..returns = _buildThemeExtensionRef(config) - ..optionalParameters.addAll( - fields.map( - (field) => Parameter( - (p) => p - ..name = field.name - ..named = true - ..type = field.typeName.typeRef(isNullable: true), - ), - ), - ) - ..body = Block((b) { - if (fields.isNotEmpty) { - b - ..addExpression( - declareFinal( - '_this'.ref.symbol, - ).assign('this'.ref.asA(config.className.ref)), - ) - ..addEmptyLine(); - } - - final args = {}; - for (final field in fields) { - args[field.name] = field.name.ref.ifNullThen( - '_this'.ref.prop(field.name), - ); - } - - b.addExpression( - (fields.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - args, - [], - config.constructor, - ) - .returned, - ); - }); -}); - /// Generates the `lerp` (linear interpolation) method for the theme extension. /// /// Supports: @@ -114,13 +59,13 @@ Method copyWith(ThemeExtensionsConfig config) => Method((m) { Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { m ..name = 'lerp' - ..annotations.add('override'.ref) - ..returns = _buildThemeExtensionRef(config) + ..annotations.add(overrideAnnotation) + ..returns = _themeExtensionRef(config) ..requiredParameters.addAll([ Parameter( (p) => p ..name = 'other' - ..type = _buildThemeExtensionRef(config, isNullable: true), + ..type = _themeExtensionRef(config, isNullable: true), ), Parameter( (p) => p @@ -129,7 +74,7 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { ), ]) ..body = Block((b) { - final fields = config.filteredFields; + final fields = config.fields; b ..statements.add( @@ -142,199 +87,25 @@ Method lerpMethod(ThemeExtensionsConfig config) => Method((m) { if (fields.isNotEmpty) { b - ..addExpression( - declareFinal( - '_this'.ref.symbol, - ).assign('this'.ref.asA(config.className.ref)), - ) + ..addExpression(declareThis(config)) ..addEmptyLine(); } - final args = {}; - - for (final field in fields) { - final tProp = '_this'.ref.prop(field.name); - final oProp = 'other'.ref.prop(field.name); - - // Handle NoLerp with double field - if (field.lerp case NoLerp() when field.isDouble) { - // lerpDouble$(_this.field, other.field, t) or - // lerpDouble$(_this.field, other.field, t)! - final expression = r'lerpDouble$'.ref([tProp, oProp, 't'.ref]); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - continue; - } - - // Handle NoLerp with duration field - if (field.lerp case NoLerp() when field.isDuration) { - // lerpDuration$(_this.field, other.field, t) or - // lerpDuration$(_this.field, other.field, t)! - final expression = r'lerpDuration$'.ref([tProp, oProp, 't'.ref]); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - continue; - } - - if (field.lerp case NoLerp()) { - // Default conditional expression - - args[field.name] = 't'.ref - .lessThan(literalNum(0.5)) - .conditional( - '_this'.ref.prop(field.name), - 'other'.ref.prop(field.name), - ); - - continue; - } - - final sLerp = field.typeName.ref.prop('lerp'); - - // Handle StaticLerp with non-nullable signature and optional - // field - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when field.isNullable) { - // _this.side == null - // ? other.side - // : other.side == null - // ? _this.side - // : Side.lerp(_this.side!, other.side!, t), - args[field.name] = tProp - .equalTo(literalNull) - .conditional( - oProp, - oProp - .equalTo(literalNull) - .conditional( - tProp, - sLerp([tProp.nullChecked, oProp.nullChecked, 't'.ref]), - ), - ); - continue; - } - // Handle StaticLerp with non-nullable signature and - // non-optional field - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when !field.isNullable) { - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - continue; - } - - // Handle StaticLerp with nullable signature and - // non-optional field - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when !field.isNullable) { - // FieldType.lerp(_this.field!, other.field!, t)! - args[field.name] = sLerp([tProp, oProp, 't'.ref]).nullChecked; - continue; - } - - // Handle StaticLerp with nullable signature and optional - // field - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when field.isNullable) { - // FieldType.lerp(_this.field, other.field, t) - args[field.name] = sLerp([tProp, oProp, 't'.ref]); - continue; - } - - // Handle InstanceLerp with optional field - if (field.lerp case InstanceLerp( - optionalResult: true, - ) when field.isNullable) { - // _this.field?.lerp(other.field, t) - args[field.name] = tProp.prop('lerp', nullSafe: true)([ - oProp, - 't'.ref, - ]); - continue; - } - - // Handle InstanceLerp with non-optional result and nullable field - if (field.lerp case InstanceLerp( - optionalResult: false, - ) when field.isNullable) { - // _this.field?.lerp(other.field, t) as FieldType? - args[field.name] = tProp - .prop('lerp', nullSafe: true)([oProp, 't'.ref]) - .asA(field.typeName.typeRef(isNullable: true)); - continue; - } - - // Handle InstanceLerp with non-optional field - if (field.lerp case InstanceLerp() when !field.isNullable) { - // _this.field.lerp(other.field, t) as FieldType - args[field.name] = tProp - .prop('lerp')([oProp, 't'.ref]) - .asA(field.typeName.typeRef()); - continue; - } - - // Handle WidgetStateProperty lerp with inner lerp function - if (field.lerp case WidgetStatePropertyLerp( - :final baseTypeName, - :final genericType, - :final isNullableGeneric, - :final genericIsDouble, - :final genericIsDuration, - )) { - // Get the inner lerp function reference - final innerLerpFn = genericIsDouble - ? r'lerpDouble$'.ref - : genericIsDuration - ? r'lerpDuration$'.ref - : genericType.ref.prop('lerp'); - - // WidgetStateProperty.lerp( - // _this.field, - // other.field, - // t, - // Color.lerp - // ) - final expression = baseTypeName.ref.prop('lerp')( - [tProp, oProp, 't'.ref, innerLerpFn], - {}, - [genericType.typeRef(isNullable: isNullableGeneric)], - ); - - args[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; - } - - throw UnimplementedError( - 'Lerp method not implemented for field: ${field.name}', - ); - } + final args = { + for (final field in fields) + field.name: lerpFieldExpression( + field, + thisRef.property(field.name), + 'other'.ref.property(field.name), + ), + }; - b.addExpression( - (args.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - args, - [], - config.constructor, - ) - .returned, - ); + b.addExpression(construct(config, args).returned); }); }); -// Returns a type reference for `ThemeExtension` based on [config]. -TypeReference _buildThemeExtensionRef( + +/// A reference to `ThemeExtension`. +TypeReference _themeExtensionRef( ThemeExtensionsConfig config, { bool isNullable = false, }) => TypeReference( @@ -350,28 +121,24 @@ TypeReference _buildThemeExtensionRef( /// ```dart /// context.myThemeExtension /// ``` -Extension contextExtension(ThemeExtensionsConfig config) { - final result = Extension((b) { - b - ..name = '${config.className}BuildContext' - ..on = 'BuildContext'.ref - ..methods.add( - Method((mb) { - mb - ..type = MethodType.getter - ..lambda = true - ..name = - config.contextAccessorName ?? - config.className.camelCase(suffixToRemove: 'Extension') - ..returns = config.className.ref - ..body = 'Theme'.ref - .prop('of')(['this'.ref]) - .prop('extension')([], {}, [config.className.ref]) - .nullChecked - .code; - }), - ); - }); - - return result; -} +Extension contextExtension(ThemeExtensionsConfig config) => Extension((b) { + b + ..name = '${config.className}BuildContext' + ..on = 'BuildContext'.ref + ..methods.add( + Method((mb) { + mb + ..type = MethodType.getter + ..lambda = true + ..name = + config.contextAccessorName ?? + config.className.camelCase(suffixToRemove: 'Extension') + ..returns = config.className.ref + ..body = 'Theme'.ref + .property('of')(['this'.ref]) + .property('extension')([], {}, [config.className.ref]) + .nullChecked + .code; + }), + ); +}); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart index a8ac9d0..5654d10 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_extensions/generator.dart @@ -3,9 +3,11 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../../common/fields_visiter.dart'; -import '../../common/fields_visitor_config.dart'; +import '../../common/fields_visitor.dart'; +import '../../common/type_checkers.dart'; +import '../../common/validation.dart'; import '../../config/config.dart'; +import '../annotation_reader.dart'; import 'code_builder.dart'; /// Code generator for classes annotated with `@ThemeExtensions`. @@ -26,11 +28,11 @@ import 'code_builder.dart'; /// } /// ``` class ThemeExtensionsGenerator extends GeneratorForAnnotation { - /// Creates a [ThemeExtensionsGenerator] with optional [builderOptions]. - const ThemeExtensionsGenerator({this.builderOptions}); - - /// Optional build configuration options. - final BuilderOptions? builderOptions; + /// Creates a [ThemeExtensionsGenerator]. + /// + /// The annotation is matched by package as well as by name, so a user class + /// called `ThemeExtensions` does not trigger the generator. + const ThemeExtensionsGenerator() : super(inPackage: annotationPackage); @override Future generateForAnnotatedElement( @@ -46,50 +48,49 @@ class ThemeExtensionsGenerator extends GeneratorForAnnotation { ); } + checkNotGeneric(element); + checkExtendsThemeExtension(element); + final buildContextExtension = annotation .read('buildContextExtension') .boolValue; - final constructor = annotation.read('constructor').literalValue as String?; - final constConstructor = element.constructors.any((c) => c.isConst); - - final contextAccessorName = - annotation.read('contextAccessorName').literalValue as String?; - - // ThemeExtensions needs lerp but doesn't generate merge methods - final fieldsVisiter = FieldsVisitor( - config: const FieldsVisitorConfig(includeMergeLookup: false), + final contextAccessorName = annotation.optionalString( + 'contextAccessorName', ); - // Get all supertypes to visit their fields as well - final allSupertypes = element.allSupertypes; - - for (final supertype in allSupertypes) { - final superElement = supertype.element; - if (!supertype.isDartCoreObject) { - superElement.visitChildren(fieldsVisiter); - } + if (contextAccessorName != null) { + checkIdentifier( + contextAccessorName, + option: 'contextAccessorName', + element: element, + ); } - element.visitChildren(fieldsVisiter); + final constructorName = annotation.optionalString('constructor'); + final constructor = resolveConstructor(element, constructorName); + + // ThemeExtensions needs lerp but doesn't generate merge methods + final fields = collectFields(element, includeMergeLookup: false); - // Use naming convention instead of expensive AST parsing - // Assume the mixin follows the standard pattern: _$ClassName - final mixinName = '_\$${element.displayName}'; + checkConstructorParameters(element, constructor, fields); + checkReservedFieldNames( + element, + fields, + reserved: themeExtensionsReservedNames, + ); + checkMixinApplied(element); - final generatorConfig = ThemeExtensionsConfig( - fields: fieldsVisiter.fields, + final config = ThemeExtensionsConfig( + fields: fields, className: element.displayName, contextAccessorName: contextAccessorName, buildContextExtension: buildContextExtension, - constructor: constructor, - themeExtensionMixinName: mixinName, - constConstructor: constConstructor, + constructor: constructorName, + themeExtensionMixinName: generatedMixinName(element), + constConstructor: constructor.isConst, ); - const generator = ThemeExtensionsCodeBuilder(); - final code = generator.generate(generatorConfig); - - return code; + return const ThemeExtensionsCodeBuilder().generate(config); } } diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart index 9ef85a3..7c796c2 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/code_builder.dart @@ -1,12 +1,18 @@ import 'package:code_builder/code_builder.dart'; import '../../common/symbols/field_info.dart'; -import '../../common/symbols/lerp_info.dart'; import '../../common/symbols/merge_info.dart'; import '../../config/config.dart'; import '../common.dart'; -/// Generates code for theme extensions based on a given configuration. +/// The members the generated mixin declares that a field cannot share a name +/// with. +/// +/// `canMerge` is left out on purpose: the mixin declares it as a getter, so a +/// `bool` field is a valid override and is handled by [staticLerp]. +const themeGenReservedNames = {'copyWith', 'merge', 'lerp'}; + +/// Generates the mixin for a `@ThemeGen` class. class ThemeGenCodeBuilder { const ThemeGenCodeBuilder(); @@ -21,22 +27,16 @@ class ThemeGenCodeBuilder { ..methods.addAll([ canMerge(config), staticLerp(config), - copyWith(config), + copyWithMethod(config, returns: config.className.ref), merge(config), equalOperator(config), hashMethod(config), ]); }); - // Set up the Dart code emitter - final emitter = DartEmitter( - allocator: Allocator.simplePrefixing(), - useNullSafetySyntax: true, - orderDirectives: true, - ); + final library = Library((lib) => lib.body.add(mix)); - final mixinLibrary = Library((lib) => lib.body.addAll([mix])); - return mixinLibrary.accept(emitter).toString(); + return library.accept(partEmitter()).toString(); } } @@ -50,55 +50,6 @@ Method canMerge(ThemeGenConfig config) => Method((m) { ..body = literalTrue.code; }); -/// Generates a `copyWith` method for the theme class. -Method copyWith(ThemeGenConfig config) => Method((m) { - final fields = config.filteredFields; - - m - ..name = 'copyWith' - ..returns = config.className.ref - ..optionalParameters.addAll( - fields.map( - (field) => Parameter( - (p) => p - ..name = field.name - ..named = true - ..type = field.typeName.typeRef(isNullable: true), - ), - ), - ) - ..body = Block((b) { - // If there are fields, create a _this variable for easier access - if (fields.isNotEmpty) { - b - ..addExpression( - declareFinal('_this').assign('this'.ref.asA(config.className.ref)), - ) - ..addEmptyLine(); - } - - final args = {}; - for (final field in fields) { - args[field.name] = field.name.ref.ifNullThen( - '_this'.ref.prop(field.name), - ); - } - - b.addExpression( - (fields.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - args, - [], - config.constructor, - ) - .returned, - ); - }); -}); - /// Generates a `merge` method for the theme class. Method merge(ThemeGenConfig config) => Method((m) { m @@ -112,91 +63,107 @@ Method merge(ThemeGenConfig config) => Method((m) { ), ) ..body = Block((b) { - final fields = config.filteredFields; - b - // Create a _this variable for easier access to the current instance - ..addExpression( - declareFinal( - '_this'.ref.symbol, - ).assign('this'.ref.asA(config.className.ref)), - ) + ..addExpression(declareThis(config)) ..addEmptyLine() // Return `_this` if other is null or identical to `_this` ..statements.add( ifStatement( 'other'.ref .equalTo(literalNull) - .or('identical'.ref(['_this'.ref, 'other'.ref])), - Block((b) => b.addExpression('_this'.ref.returned)), + .or('identical'.ref([thisRef, 'other'.ref])), + Block((b) => b.addExpression(thisRef.returned)), ), ) ..addEmptyLine() // Return `other` if it cannot be merged ..statements.add( ifStatement( - 'other'.ref.negate().prop('canMerge'), + 'other'.ref.negate().property('canMerge'), Block((b) => b.addExpression('other'.ref.returned)), ), ) ..addEmptyLine(); - final args = {}; - for (final field in fields) { - final thisProp = '_this'.ref.prop(field.name); - final otherProp = 'other'.ref.prop(field.name); - - final staticMerge = field.typeName.ref.prop('merge'); - final instanceMerge = thisProp.prop('merge'); - - // Handle different merge strategies based on field configuration - - // No merge method, just take the other property - // `property: other.property` - if (field.merge case NoMerge()) { - args[field.name] = otherProp; - continue; - } - - // Static merge method with optional field - if (field.merge case StaticMerge() when field.isNullable) { - args[field.name] = thisProp - .notEqualTo(literalNull) - .and(otherProp.notEqualTo(literalNull)) - .conditional( - staticMerge([thisProp.nullChecked, otherProp.nullChecked]), - otherProp, - ); - continue; - } - - // Static merge method with non-optional field - if (field.merge case StaticMerge() when !field.isNullable) { - args[field.name] = staticMerge([thisProp, otherProp]); - continue; - } - - // Instance merge method with optional field - if (field.merge case InstanceMerge() when field.isNullable) { - args[field.name] = thisProp - .nullSafeProperty('merge')([otherProp]) - .ifNullThen(otherProp); - continue; - } - - // Instance merge method with non-optional field - if (field.merge case InstanceMerge() when !field.isNullable) { - args[field.name] = instanceMerge([otherProp]); - continue; - } - - throw StateError('Unsupported merge info for field ${field.name}'); - } + final args = { + for (final field in config.fields) + field.name: _mergeFieldExpression( + field, + thisRef.property(field.name), + 'other'.ref.property(field.name), + ), + }; b.addExpression('copyWith'.ref([], args).returned); }); }); +/// The expression that merges [other] into [current] for [field]. +Expression _mergeFieldExpression( + FieldInfo field, + Expression current, + Expression other, +) { + final staticMerge = field.baseTypeName.ref.property('merge'); + + Expression castIfNeeded(Expression expression, {required bool needsCast}) => + needsCast + ? expression.asA(field.typeName.typeRef(isNullable: field.isNullable)) + : expression; + + // A merge that cannot take a null on either side keeps whichever value is + // present: + // _this.field == null + // ? other.field + // : other.field == null + // ? _this.field + // : + Expression whenBothPresent(Expression merge) => current + .equalTo(literalNull) + .conditional( + other, + other.equalTo(literalNull).conditional(current, merge), + ); + + // The result goes through `copyWith`, which reads a null as "keep the + // current value": a null `other.field`, or a null returned by the field's + // own `merge`, leaves `_this.field` in place. + return switch (field.merge) { + // No merge method, just take the other property + NoMerge() => other, + + // Class.merge(_this.field!, other.field!), guarded + StaticMerge() when field.isNullable => whenBothPresent( + staticMerge([current.nullChecked, other.nullChecked]), + ), + + // Class.merge(_this.field, other.field) + StaticMerge() => staticMerge([current, other]), + + // _this.field?.merge(other.field) ?? other.field + InstanceMerge(isNullableParameter: true, :final needsCast) + when field.isNullable => + castIfNeeded( + current.nullSafeProperty('merge')([other]), + needsCast: needsCast, + ).ifNullThen(other), + + // _this.field!.merge(other.field!), guarded + InstanceMerge(:final needsCast) when field.isNullable => whenBothPresent( + castIfNeeded( + current.nullChecked.property('merge')([other.nullChecked]), + needsCast: needsCast, + ), + ), + + // _this.field.merge(other.field) + InstanceMerge(:final needsCast) => castIfNeeded( + current.property('merge')([other]), + needsCast: needsCast, + ), + }; +} + /// Generates a static `lerp` method for interpolating between two theme /// instances. /// @@ -210,23 +177,21 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { ..requiredParameters.addAll([ Parameter( (p) => p - ..name = 'a'.ref.symbol + ..name = 'a' ..type = config.className.typeRef(isNullable: true), ), Parameter( (p) => p - ..name = 'b'.ref.symbol + ..name = 'b' ..type = config.className.typeRef(isNullable: true), ), Parameter( (p) => p - ..name = 't'.ref.symbol + ..name = 't' ..type = 'double'.ref, ), ]) ..body = Block((b) { - final fields = config.filteredFields; - b // If a and b are identical, return a ..statements.add( @@ -242,7 +207,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { 'a'.ref.equalTo(literalNull), Block( (b) => b.addExpression( - 't'.ref + tRef .equalTo(literalNum(1.0)) .conditional('b'.ref, literalNull) .returned, @@ -257,7 +222,7 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { 'b'.ref.equalTo(literalNull), Block( (b) => b.addExpression( - 't'.ref + tRef .equalTo(literalNum(0.0)) .conditional('a'.ref, literalNull) .returned, @@ -267,172 +232,19 @@ Method staticLerp(ThemeGenConfig config) => Method((m) { ) ..addEmptyLine(); - final argsResult = {}; - - for (final field in fields) { - final aProp = 'a'.ref.prop(field.name); - final bProp = 'b'.ref.prop(field.name); - final lerp = field.typeName.ref.prop('lerp'); - - // Handle different lerp strategies based on field configuration - - // Non-nullable field with non-nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when !field.isNullable) { - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - continue; - } - - // Non-nullable field with nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when !field.isNullable) { - // value: Class.lerp(a.field, b.field, t)! - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]).nullChecked; - continue; - } - - // Nullable field with non-nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: false, - ) when field.isNullable) { - // value: a.field == null - // ? b.field - // : b.field == null - // ? a.field - // : Class.lerp(a.field!, b.field!, t) - argsResult[field.name] = aProp - .equalTo(literalNull) - .conditional( - bProp, - bProp - .equalTo(literalNull) - .conditional( - aProp, - lerp([aProp.nullChecked, bProp.nullChecked, 't'.ref]), - ), - ); - - continue; - } - - // Nullable field with nullable lerp signature - if (field.lerp case StaticLerp( - isNullableSignature: true, - ) when field.isNullable) { - // value: Class.lerp(a.field, b.field, t) - argsResult[field.name] = lerp([aProp, bProp, 't'.ref]); - - continue; - } - - // Instance lerp method with optional result - if (field.lerp case InstanceLerp( - optionalResult: true, - ) when field.isNullable) { - // value: a.field?.lerp(b.field, t) - argsResult[field.name] = aProp.prop('lerp', nullSafe: true)([ - bProp, - 't'.ref, - ]); - - continue; - } - // Instance lerp method with non-optional result - if (field.lerp case InstanceLerp( - optionalResult: false, - ) when !field.isNullable) { - // value: a.field.lerp(b.field, t) - argsResult[field.name] = aProp - .prop('lerp')([bProp, 't'.ref]) - .asA(field.typeName.typeRef()); - - continue; - } - - // WidgetStateProperty lerp with inner lerp function - if (field.lerp case WidgetStatePropertyLerp( - :final baseTypeName, - :final genericType, - :final isNullableGeneric, - :final genericIsDouble, - :final genericIsDuration, - )) { - // Get the inner lerp function reference - final innerLerpFn = genericIsDouble - ? r'lerpDouble$'.ref - : genericIsDuration - ? r'lerpDuration$'.ref - : genericType.ref.prop('lerp'); - - // WidgetStateProperty.lerp( - // a.field, - // b.field, - // t, - // Color.lerp - // ) - - final expression = baseTypeName.ref.prop('lerp')( - [aProp, bProp, 't'.ref, innerLerpFn], - {}, - [genericType.typeRef(isNullable: isNullableGeneric)], - ); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; - } - - // When the field is of type double - if (field case FieldInfo(isDouble: true) when field.lerp is NoLerp) { - final expression = r'lerpDouble$'.ref([aProp, bProp, 't'.ref]); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; - - continue; - } - - // When the field is of type Duration - if (field case FieldInfo(isDuration: true) when field.lerp is NoLerp) { - final expression = r'lerpDuration$'.ref([aProp, bProp, 't'.ref]); - - argsResult[field.name] = field.isNullable - ? expression - : expression.nullChecked; + final args = {}; - continue; - } + for (final field in config.fields) { + final aProp = 'a'.ref.property(field.name); + final bProp = 'b'.ref.property(field.name); - // Special case for canMerge field - if (field.name == 'canMerge') { - argsResult[field.name] = bProp; - continue; - } - // Fallback to a simple conditional expression: - // t < 0.5 ? a.field : b.field - argsResult[field.name] = 't'.ref - .lessThan(literalNum(0.5)) - .conditional(aProp, bProp); + // A `canMerge` declared as a field rather than a getter is not + // interpolated: the result takes the value of `b`. + args[field.name] = field.name == 'canMerge' + ? bProp + : lerpFieldExpression(field, aProp, bProp); } - b.addExpression( - (argsResult.isEmpty && config.constConstructor - ? InvokeExpression.constOf - : InvokeExpression.newOf)( - config.className.ref, - [], - argsResult, - [], - config.constructor, - ) - .returned, - ); + b.addExpression(construct(config, args).returned); }); }); diff --git a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart index a0605c6..408f2af 100644 --- a/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart +++ b/packages/theme_extensions_builder/lib/src/generator/theme_gen/generator.dart @@ -3,8 +3,11 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../../common/fields_visiter.dart'; +import '../../common/fields_visitor.dart'; +import '../../common/type_checkers.dart'; +import '../../common/validation.dart'; import '../../config/config.dart'; +import '../annotation_reader.dart'; import 'code_builder.dart'; /// Code generator for classes annotated with `@ThemeGen`. @@ -26,11 +29,11 @@ import 'code_builder.dart'; /// } /// ``` class ThemeGenGenerator extends GeneratorForAnnotation { - /// Creates a [ThemeGenGenerator] with optional [builderOptions]. - const ThemeGenGenerator({this.builderOptions}); - - /// Optional build configuration options. - final BuilderOptions? builderOptions; + /// Creates a [ThemeGenGenerator]. + /// + /// The annotation is matched by package as well as by name, so a user class + /// called `ThemeGen` does not trigger the generator. + const ThemeGenGenerator() : super(inPackage: annotationPackage); @override Future generateForAnnotatedElement( @@ -46,33 +49,24 @@ class ThemeGenGenerator extends GeneratorForAnnotation { ); } - final constructor = annotation.read('constructor').literalValue as String?; - final constConstructor = element.constructors.any((c) => c.isConst); + checkNotGeneric(element); - final fieldsVisiter = FieldsVisitor(); - // Get all supertypes to visit their fields as well - final allSupertypes = element.allSupertypes; + final constructorName = annotation.optionalString('constructor'); + final constructor = resolveConstructor(element, constructorName); - for (final supertype in allSupertypes) { - final superElement = supertype.element; + final fields = collectFields(element); - if (!supertype.isDartCoreObject) { - superElement.visitChildren(fieldsVisiter); - } - } - // Finally, visit the original class to get its own fields - element.visitChildren(fieldsVisiter); + checkConstructorParameters(element, constructor, fields); + checkReservedFieldNames(element, fields, reserved: themeGenReservedNames); + checkMixinApplied(element); - final generatorConfig = ThemeGenConfig( - fields: fieldsVisiter.fields, + final config = ThemeGenConfig( + fields: fields, className: element.displayName, - constructor: constructor, - constConstructor: constConstructor, + constructor: constructorName, + constConstructor: constructor.isConst, ); - const generator = ThemeGenCodeBuilder(); - final code = generator.generate(generatorConfig); - - return code; + return const ThemeGenCodeBuilder().generate(config); } } diff --git a/packages/theme_extensions_builder/pubspec.yaml b/packages/theme_extensions_builder/pubspec.yaml index 739106a..4d3a99d 100644 --- a/packages/theme_extensions_builder/pubspec.yaml +++ b/packages/theme_extensions_builder/pubspec.yaml @@ -8,15 +8,7 @@ issue_tracker: https://github.com/pro100andrey/theme_extensions_builder/issues homepage: https://github.com/pro100andrey/theme_extensions_builder documentation: https://github.com/pro100andrey/theme_extensions_builder/blob/main/packages/theme_extensions_builder/README.md -version: 7.4.0 - -platforms: - android: - ios: - linux: - macos: - web: - windows: +version: 7.5.0 topics: - theme @@ -25,21 +17,21 @@ topics: - codegen environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" + +resolution: workspace dependencies: - analyzer: ">=9.0.0 <14.0.0" + analyzer: ">=13.1.0 <15.0.0" build: ">=3.0.0 <5.0.0" code_builder: ^4.11.1 - collection: ^1.19.1 - meta: ^1.16.0 source_gen: ">=4.2.3 <5.0.0" - theme_extensions_builder_annotation: ^7.4.0 + theme_extensions_builder_annotation: ^7.5.0 dev_dependencies: build_test: "^3.5.15" path: ^1.9.1 - pro_lints: ^6.1.0 + pro_lints: ^6.2.0 source_gen_test: ^1.3.6 test: ^1.31.1 \ No newline at end of file diff --git a/packages/theme_extensions_builder/pubspec_overrides.yaml b/packages/theme_extensions_builder/pubspec_overrides.yaml deleted file mode 100644 index dd7a01e..0000000 --- a/packages/theme_extensions_builder/pubspec_overrides.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# dependency_overrides: -# theme_extensions_builder_annotation: -# path: ../theme_extensions_builder_annotation diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme.dart similarity index 99% rename from packages/theme_extensions_builder/test/theme_gen/complex_theme.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme.dart index 751425b..f2eef6a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/complex_theme.dart @@ -3,7 +3,7 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann import 'empty_theme.dart'; import 'empty_theme_extension.dart'; -import 'mock.dart'; +import 'flutter_stubs.dart'; part 'complex_theme.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme.g.theme.dart similarity index 85% rename from packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme.g.theme.dart index 33d5ff9..746adac 100644 --- a/packages/theme_extensions_builder/test/theme_gen/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/complex_theme.g.theme.dart @@ -46,9 +46,10 @@ mixin _$ComplexThemeInternal { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -59,17 +60,30 @@ mixin _$ComplexThemeInternal { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -153,13 +167,14 @@ mixin _$ComplexThemeInternal { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, @@ -266,9 +281,10 @@ mixin _$ComplexTheme { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -279,17 +295,30 @@ mixin _$ComplexTheme { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -373,13 +402,14 @@ mixin _$ComplexTheme { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.dart similarity index 96% rename from packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme_extension.dart index 3943f9e..17633ed 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.dart @@ -3,7 +3,7 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann import 'empty_theme.dart'; import 'empty_theme_extension.dart'; -import 'mock.dart'; +import 'flutter_stubs.dart'; part 'complex_theme_extension.g.theme.dart'; @@ -129,6 +129,7 @@ final class ComplexThemeExtension extends ThemeExtension required this.optionalTheme, required this.optionalThemeExtension, + this.optionalLerpableWithOptionalResult, this.computedValue = 'computed', }); @@ -153,6 +154,7 @@ final class ComplexThemeExtension extends ThemeExtension final BorderSide? optionalBorderSide; final EmptyTheme? optionalTheme; final EmptyThemeExtension? optionalThemeExtension; + final LerpableWithOptionalResult? optionalLerpableWithOptionalResult; @ignore final String computedValue; diff --git a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.g.theme.dart similarity index 87% rename from packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/complex_theme_extension.g.theme.dart index 5f3bc68..e99a606 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/complex_theme_extension.g.theme.dart @@ -192,6 +192,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { BorderSide? optionalBorderSide, EmptyTheme? optionalTheme, EmptyThemeExtension? optionalThemeExtension, + LerpableWithOptionalResult? optionalLerpableWithOptionalResult, }) { final _this = (this as ComplexThemeExtension); @@ -216,6 +217,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { optionalTheme: optionalTheme ?? _this.optionalTheme, optionalThemeExtension: optionalThemeExtension ?? _this.optionalThemeExtension, + optionalLerpableWithOptionalResult: + optionalLerpableWithOptionalResult ?? + _this.optionalLerpableWithOptionalResult, ); } @@ -255,9 +259,10 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.requiredTheme, t, )!, - requiredThemeExtension: - (_this.requiredThemeExtension.lerp(other.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (_this.requiredThemeExtension.lerp( + other.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? _this.optionalInt : other.optionalInt, optionalDouble: lerpDouble$( _this.optionalDouble, @@ -272,10 +277,11 @@ mixin _$ComplexThemeExtension on ThemeExtension { t, ), optionalColor: Color.lerp(_this.optionalColor, other.optionalColor, t), - optionalBorderSide: _this.optionalBorderSide == null - ? other.optionalBorderSide - : other.optionalBorderSide == null - ? _this.optionalBorderSide + optionalBorderSide: + _this.optionalBorderSide == null || other.optionalBorderSide == null + ? t < 0.5 + ? _this.optionalBorderSide + : other.optionalBorderSide : BorderSide.lerp( _this.optionalBorderSide!, other.optionalBorderSide!, @@ -287,8 +293,25 @@ mixin _$ComplexThemeExtension on ThemeExtension { t, ), optionalThemeExtension: - (_this.optionalThemeExtension?.lerp(other.optionalThemeExtension, t) - as EmptyThemeExtension?), + _this.optionalThemeExtension == null || + other.optionalThemeExtension == null + ? t < 0.5 + ? _this.optionalThemeExtension + : other.optionalThemeExtension + : (_this.optionalThemeExtension!.lerp( + other.optionalThemeExtension!, + t, + ) as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + _this.optionalLerpableWithOptionalResult == null || + other.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? _this.optionalLerpableWithOptionalResult + : other.optionalLerpableWithOptionalResult + : _this.optionalLerpableWithOptionalResult!.lerp( + other.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -322,7 +345,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { _other.optionalColor == _this.optionalColor && _other.optionalBorderSide == _this.optionalBorderSide && _other.optionalTheme == _this.optionalTheme && - _other.optionalThemeExtension == _this.optionalThemeExtension; + _other.optionalThemeExtension == _this.optionalThemeExtension && + _other.optionalLerpableWithOptionalResult == + _this.optionalLerpableWithOptionalResult; } @override @@ -349,6 +374,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { _this.optionalBorderSide, _this.optionalTheme, _this.optionalThemeExtension, + _this.optionalLerpableWithOptionalResult, ); } } diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme.dart similarity index 97% rename from packages/theme_extensions_builder/test/theme_gen/empty_theme.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme.dart index 43b0ba1..f711830 100644 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/empty_theme.dart @@ -24,7 +24,7 @@ final class EmptyTheme with _$EmptyTheme { ) @themeGen final class EmptyThemeNonConst with _$EmptyThemeNonConst { - const EmptyThemeNonConst(); + EmptyThemeNonConst(); @override bool get canMerge => true; diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme.g.theme.dart similarity index 96% rename from packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme.g.theme.dart index 7a41cea..3d7754a 100644 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/empty_theme.g.theme.dart @@ -85,11 +85,11 @@ mixin _$EmptyThemeNonConst { return t == 0.0 ? a : null; } - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst copyWith() { - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst merge(EmptyThemeNonConst? other) { diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme_extension.dart similarity index 96% rename from packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme_extension.dart index e63f01f..9b964a6 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/empty_theme_extension.dart @@ -1,7 +1,7 @@ import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; +import 'flutter_stubs.dart'; part 'empty_theme_extension.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/empty_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/empty_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/empty_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/mock/mock.dart b/packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart similarity index 94% rename from packages/theme_extensions_builder/test/mock/mock.dart rename to packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart index 40dc356..ccbe283 100644 --- a/packages/theme_extensions_builder/test/mock/mock.dart +++ b/packages/theme_extensions_builder/test/fixtures/flutter_stubs.dart @@ -1,18 +1,16 @@ -/// Mock implementations of Flutter framework classes for testing. +/// Minimal stand-ins for the Flutter classes the generator tests need. /// -/// This library provides minimal mock implementations of key Flutter classes -/// that are needed for testing theme extensions without pulling in the entire -/// Flutter SDK as a dependency. This keeps tests lightweight and fast. +/// The generator is tested on the Dart SDK alone, so the Flutter types the +/// fixtures use — [ThemeExtension], [Color], [BorderSide], +/// [WidgetStateProperty], [BuildContext] and [Theme] — are declared here with +/// the same `lerp` and `merge` signatures as the real ones. That is the only +/// thing the generator looks at. /// -/// The mocks implement the essential API surface that theme_extensions_builder -/// relies on, including: -/// - [ThemeExtension]: The base class for custom theme extensions -/// - [Color]: Color representation and interpolation -/// - [BorderSide]: Border styling with interpolation support -/// - [BuildContext] and [Theme]: Context and theme access stubs +/// Keep the signatures in step with Flutter: the example app is what checks +/// the generated code against the real framework. /// -/// These implementations are intentionally simplified and should only be used -/// for testing the code generation output, not for production use. +/// This file sits next to the fixtures because `source_gen_test` reads a +/// fixture directory flat, so a fixture can only import its siblings. library; import 'dart:math' as math; diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme.g.theme.dart similarity index 84% rename from packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme.g.theme.dart index a975110..fe17a62 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme.g.theme.dart @@ -33,9 +33,10 @@ mixin _$ComplexTheme { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -46,17 +47,30 @@ mixin _$ComplexTheme { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -140,13 +154,14 @@ mixin _$ComplexTheme { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension.g.theme.dart similarity index 79% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension.g.theme.dart index f10056f..523f622 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension.g.theme.dart @@ -21,6 +21,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { BorderSide? optionalBorderSide, EmptyTheme? optionalTheme, EmptyThemeExtension? optionalThemeExtension, + LerpableWithOptionalResult? optionalLerpableWithOptionalResult, }) { final _this = (this as ComplexThemeExtension); @@ -45,6 +46,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { optionalTheme: optionalTheme ?? _this.optionalTheme, optionalThemeExtension: optionalThemeExtension ?? _this.optionalThemeExtension, + optionalLerpableWithOptionalResult: + optionalLerpableWithOptionalResult ?? + _this.optionalLerpableWithOptionalResult, ); } @@ -84,9 +88,10 @@ mixin _$ComplexThemeExtension on ThemeExtension { other.requiredTheme, t, )!, - requiredThemeExtension: - (_this.requiredThemeExtension.lerp(other.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (_this.requiredThemeExtension.lerp( + other.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? _this.optionalInt : other.optionalInt, optionalDouble: lerpDouble$( _this.optionalDouble, @@ -101,10 +106,11 @@ mixin _$ComplexThemeExtension on ThemeExtension { t, ), optionalColor: Color.lerp(_this.optionalColor, other.optionalColor, t), - optionalBorderSide: _this.optionalBorderSide == null - ? other.optionalBorderSide - : other.optionalBorderSide == null - ? _this.optionalBorderSide + optionalBorderSide: + _this.optionalBorderSide == null || other.optionalBorderSide == null + ? t < 0.5 + ? _this.optionalBorderSide + : other.optionalBorderSide : BorderSide.lerp( _this.optionalBorderSide!, other.optionalBorderSide!, @@ -116,8 +122,25 @@ mixin _$ComplexThemeExtension on ThemeExtension { t, ), optionalThemeExtension: - (_this.optionalThemeExtension?.lerp(other.optionalThemeExtension, t) - as EmptyThemeExtension?), + _this.optionalThemeExtension == null || + other.optionalThemeExtension == null + ? t < 0.5 + ? _this.optionalThemeExtension + : other.optionalThemeExtension + : (_this.optionalThemeExtension!.lerp( + other.optionalThemeExtension!, + t, + ) as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + _this.optionalLerpableWithOptionalResult == null || + other.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? _this.optionalLerpableWithOptionalResult + : other.optionalLerpableWithOptionalResult + : _this.optionalLerpableWithOptionalResult!.lerp( + other.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -151,7 +174,9 @@ mixin _$ComplexThemeExtension on ThemeExtension { _other.optionalColor == _this.optionalColor && _other.optionalBorderSide == _this.optionalBorderSide && _other.optionalTheme == _this.optionalTheme && - _other.optionalThemeExtension == _this.optionalThemeExtension; + _other.optionalThemeExtension == _this.optionalThemeExtension && + _other.optionalLerpableWithOptionalResult == + _this.optionalLerpableWithOptionalResult; } @override @@ -178,6 +203,7 @@ mixin _$ComplexThemeExtension on ThemeExtension { _this.optionalBorderSide, _this.optionalTheme, _this.optionalThemeExtension, + _this.optionalLerpableWithOptionalResult, ); } } diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_custom_accessor.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_custom_accessor.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_custom_accessor.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_custom_accessor.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_no_context.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_no_context.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/complex_theme_extension_no_context.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_extension_no_context.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_internal.g.theme.dart similarity index 85% rename from packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_internal.g.theme.dart index 693bb72..c61c8bd 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/complex_theme_internal.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/complex_theme_internal.g.theme.dart @@ -37,9 +37,10 @@ mixin _$ComplexThemeInternal { t, ), requiredTheme: EmptyTheme.lerp(a.requiredTheme, b.requiredTheme, t)!, - requiredThemeExtension: - (a.requiredThemeExtension.lerp(b.requiredThemeExtension, t) - as EmptyThemeExtension), + requiredThemeExtension: (a.requiredThemeExtension.lerp( + b.requiredThemeExtension, + t, + ) as EmptyThemeExtension), optionalInt: t < 0.5 ? a.optionalInt : b.optionalInt, optionalDouble: lerpDouble$(a.optionalDouble, b.optionalDouble, t), optionalString: t < 0.5 ? a.optionalString : b.optionalString, @@ -50,17 +51,30 @@ mixin _$ComplexThemeInternal { t, ), optionalColor: Color.lerp(a.optionalColor, b.optionalColor, t), - optionalBorderSide: a.optionalBorderSide == null - ? b.optionalBorderSide - : b.optionalBorderSide == null - ? a.optionalBorderSide + optionalBorderSide: + a.optionalBorderSide == null || b.optionalBorderSide == null + ? t < 0.5 + ? a.optionalBorderSide + : b.optionalBorderSide : BorderSide.lerp(a.optionalBorderSide!, b.optionalBorderSide!, t), optionalTheme: EmptyTheme.lerp(a.optionalTheme, b.optionalTheme, t), - optionalThemeExtension: t < 0.5 - ? a.optionalThemeExtension - : b.optionalThemeExtension, - optionalLerpableWithOptionalResult: a.optionalLerpableWithOptionalResult - ?.lerp(b.optionalLerpableWithOptionalResult, t), + optionalThemeExtension: + a.optionalThemeExtension == null || b.optionalThemeExtension == null + ? t < 0.5 + ? a.optionalThemeExtension + : b.optionalThemeExtension + : (a.optionalThemeExtension!.lerp(b.optionalThemeExtension!, t) + as EmptyThemeExtension?), + optionalLerpableWithOptionalResult: + a.optionalLerpableWithOptionalResult == null || + b.optionalLerpableWithOptionalResult == null + ? t < 0.5 + ? a.optionalLerpableWithOptionalResult + : b.optionalLerpableWithOptionalResult + : a.optionalLerpableWithOptionalResult!.lerp( + b.optionalLerpableWithOptionalResult!, + t, + ), ); } @@ -144,13 +158,14 @@ mixin _$ComplexThemeInternal { optionalBool: other.optionalBool, optionalDuration: other.optionalDuration, optionalColor: other.optionalColor, - optionalBorderSide: - _this.optionalBorderSide != null && other.optionalBorderSide != null - ? BorderSide.merge( + optionalBorderSide: _this.optionalBorderSide == null + ? other.optionalBorderSide + : other.optionalBorderSide == null + ? _this.optionalBorderSide + : BorderSide.merge( _this.optionalBorderSide!, other.optionalBorderSide!, - ) - : other.optionalBorderSide, + ), optionalTheme: _this.optionalTheme?.merge(other.optionalTheme) ?? other.optionalTheme, diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension_non_const.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension_non_const.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/empty_theme_extension_non_const.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_extension_non_const.g.theme.dart diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_non_const.g.theme.dart similarity index 92% rename from packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_non_const.g.theme.dart index bdbe524..ef33ada 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/empty_theme_non_const.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/empty_theme_non_const.g.theme.dart @@ -20,11 +20,11 @@ mixin _$EmptyThemeNonConst { return t == 0.0 ? a : null; } - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst copyWith() { - return const EmptyThemeNonConst(); + return EmptyThemeNonConst(); } EmptyThemeNonConst merge(EmptyThemeNonConst? other) { diff --git a/packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart new file mode 100644 index 0000000..dc70fdd --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/goldens/generic_inherited_theme.g.theme.dart @@ -0,0 +1,68 @@ +part of '../inherited_theme.dart'; + +mixin _$GenericInheritedTheme { + bool get canMerge => true; + + static GenericInheritedTheme? lerp( + GenericInheritedTheme? a, + GenericInheritedTheme? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return GenericInheritedTheme(value: t < 0.5 ? a.value : b.value); + } + + GenericInheritedTheme copyWith({num? value}) { + final _this = (this as GenericInheritedTheme); + + return GenericInheritedTheme(value: value ?? _this.value); + } + + GenericInheritedTheme merge(GenericInheritedTheme? other) { + final _this = (this as GenericInheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(value: other.value); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as GenericInheritedTheme); + final _other = (other as GenericInheritedTheme); + + return _other.value == _this.value; + } + + @override + int get hashCode { + final _this = (this as GenericInheritedTheme); + + return Object.hash(runtimeType, _this.value); + } +} diff --git a/packages/theme_extensions_builder/test/fixtures/goldens/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/inherited_theme.g.theme.dart new file mode 100644 index 0000000..a3bcc8e --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/goldens/inherited_theme.g.theme.dart @@ -0,0 +1,64 @@ +part of '../inherited_theme.dart'; + +mixin _$InheritedTheme { + bool get canMerge => true; + + static InheritedTheme? lerp(InheritedTheme? a, InheritedTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return InheritedTheme(inherited: t < 0.5 ? a.inherited : b.inherited); + } + + InheritedTheme copyWith({int? inherited}) { + final _this = (this as InheritedTheme); + + return InheritedTheme(inherited: inherited ?? _this.inherited); + } + + InheritedTheme merge(InheritedTheme? other) { + final _this = (this as InheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(inherited: other.inherited); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as InheritedTheme); + final _other = (other as InheritedTheme); + + return _other.inherited == _this.inherited; + } + + @override + int get hashCode { + final _this = (this as InheritedTheme); + + return Object.hash(runtimeType, _this.inherited); + } +} diff --git a/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart new file mode 100644 index 0000000..19d12c4 --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/goldens/lookup_theme.g.theme.dart @@ -0,0 +1,177 @@ +part of '../lookup_theme.dart'; + +mixin _$LookupTheme { + bool get canMerge => true; + + static LookupTheme? lerp(LookupTheme? a, LookupTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return LookupTheme( + curve: t < 0.5 ? a.curve : b.curve, + settings: t < 0.5 ? a.settings : b.settings, + optionalSettings: t < 0.5 ? a.optionalSettings : b.optionalSettings, + flags: t < 0.5 ? a.flags : b.flags, + clamped: t < 0.5 ? a.clamped : b.clamped, + unrelated: t < 0.5 ? a.unrelated : b.unrelated, + mode: t < 0.5 ? a.mode : b.mode, + pair: t < 0.5 ? a.pair : b.pair, + strict: t < 0.5 ? a.strict : b.strict, + box: a.box.lerp(b.box, t), + special: a.special == null || b.special == null + ? t < 0.5 + ? a.special + : b.special + : (a.special!.lerp(b.special!, t) as Special?), + soft: a.soft.lerp(b.soft, t)!, + fade: t < 0.5 ? a.fade : b.fade, + ratio: t < 0.5 ? a.ratio : b.ratio, + counter: t < 0.5 ? a.counter : b.counter, + narrowed: t < 0.5 ? a.narrowed : b.narrowed, + ); + } + + LookupTheme copyWith({ + Curve? curve, + Settings? settings, + Settings? optionalSettings, + Flags? flags, + Clamped? clamped, + Unrelated? unrelated, + Mode? mode, + Pair? pair, + Strict? strict, + Box? box, + Special? special, + Soft? soft, + Fade? fade, + Ratio? ratio, + Counter? counter, + int? narrowed, + }) { + final _this = (this as LookupTheme); + + return LookupTheme( + curve: curve ?? _this.curve, + settings: settings ?? _this.settings, + optionalSettings: optionalSettings ?? _this.optionalSettings, + flags: flags ?? _this.flags, + clamped: clamped ?? _this.clamped, + unrelated: unrelated ?? _this.unrelated, + mode: mode ?? _this.mode, + pair: pair ?? _this.pair, + strict: strict ?? _this.strict, + box: box ?? _this.box, + special: special ?? _this.special, + soft: soft ?? _this.soft, + fade: fade ?? _this.fade, + ratio: ratio ?? _this.ratio, + counter: counter ?? _this.counter, + narrowed: narrowed ?? _this.narrowed, + ); + } + + LookupTheme merge(LookupTheme? other) { + final _this = (this as LookupTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + curve: other.curve, + settings: _this.settings.merge(other.settings), + optionalSettings: _this.optionalSettings == null + ? other.optionalSettings + : other.optionalSettings == null + ? _this.optionalSettings + : _this.optionalSettings!.merge(other.optionalSettings!), + flags: other.flags, + clamped: other.clamped, + unrelated: other.unrelated, + mode: other.mode, + pair: other.pair, + strict: other.strict, + box: _this.box.merge(other.box), + special: _this.special == null + ? other.special + : other.special == null + ? _this.special + : (_this.special!.merge(other.special!) as Special?), + soft: other.soft, + fade: other.fade, + ratio: other.ratio, + counter: other.counter, + narrowed: other.narrowed, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as LookupTheme); + final _other = (other as LookupTheme); + + return _other.curve == _this.curve && + _other.settings == _this.settings && + _other.optionalSettings == _this.optionalSettings && + _other.flags == _this.flags && + _other.clamped == _this.clamped && + _other.unrelated == _this.unrelated && + _other.mode == _this.mode && + _other.pair == _this.pair && + _other.strict == _this.strict && + _other.box == _this.box && + _other.special == _this.special && + _other.soft == _this.soft && + _other.fade == _this.fade && + _other.ratio == _this.ratio && + _other.counter == _this.counter && + _other.narrowed == _this.narrowed; + } + + @override + int get hashCode { + final _this = (this as LookupTheme); + + return Object.hash( + runtimeType, + _this.curve, + _this.settings, + _this.optionalSettings, + _this.flags, + _this.clamped, + _this.unrelated, + _this.mode, + _this.pair, + _this.strict, + _this.box, + _this.special, + _this.soft, + _this.fade, + _this.ratio, + _this.counter, + _this.narrowed, + ); + } +} diff --git a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart similarity index 82% rename from packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart index 44f4dad..c7dad4c 100644 --- a/packages/theme_extensions_builder/test/theme_gen/goldens/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme.g.theme.dart @@ -39,19 +39,22 @@ mixin _$WidgetStatePropertyTheme { b.optionalColor, t, Color.lerp, - )!, + ), optionalWidth: WidgetStateProperty.lerp( a.optionalWidth, b.optionalWidth, t, lerpDouble$, - )!, + ), optionalDuration: WidgetStateProperty.lerp( a.optionalDuration, b.optionalDuration, t, lerpDuration$, - )!, + ), + label: t < 0.5 ? a.label : b.label, + side: t < 0.5 ? a.side : b.side, + nested: t < 0.5 ? a.nested : b.nested, ); } @@ -62,6 +65,9 @@ mixin _$WidgetStatePropertyTheme { WidgetStateProperty? optionalColor, WidgetStateProperty? optionalWidth, WidgetStateProperty? optionalDuration, + WidgetStateProperty? label, + WidgetStateProperty? side, + WidgetStateProperty?>? nested, }) { final _this = (this as WidgetStatePropertyTheme); @@ -72,6 +78,9 @@ mixin _$WidgetStatePropertyTheme { optionalColor: optionalColor ?? _this.optionalColor, optionalWidth: optionalWidth ?? _this.optionalWidth, optionalDuration: optionalDuration ?? _this.optionalDuration, + label: label ?? _this.label, + side: side ?? _this.side, + nested: nested ?? _this.nested, ); } @@ -93,6 +102,9 @@ mixin _$WidgetStatePropertyTheme { optionalColor: other.optionalColor, optionalWidth: other.optionalWidth, optionalDuration: other.optionalDuration, + label: other.label, + side: other.side, + nested: other.nested, ); } @@ -114,7 +126,10 @@ mixin _$WidgetStatePropertyTheme { _other.duration == _this.duration && _other.optionalColor == _this.optionalColor && _other.optionalWidth == _this.optionalWidth && - _other.optionalDuration == _this.optionalDuration; + _other.optionalDuration == _this.optionalDuration && + _other.label == _this.label && + _other.side == _this.side && + _other.nested == _this.nested; } @override @@ -129,6 +144,9 @@ mixin _$WidgetStatePropertyTheme { _this.optionalColor, _this.optionalWidth, _this.optionalDuration, + _this.label, + _this.side, + _this.nested, ); } } diff --git a/packages/theme_extensions_builder/test/theme_extensions/goldens/widget_state_property_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/goldens/widget_state_property_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/goldens/widget_state_property_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart b/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart new file mode 100644 index 0000000..978aeab --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/inherited_theme.dart @@ -0,0 +1,83 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'inherited_theme.g.theme.dart'; + +/// Satisfied by [InheritedTheme] itself. It carries a concrete field on +/// purpose: an `implements` clause brings no state the generated constructor +/// call could pass, so the field must not be collected. +abstract class HasVersion { + final version = 0; +} + +/// Declares the fields [InheritedTheme] inherits. +class BaseTheme { + const BaseTheme({required this.inherited, required this.replaced}); + + final int inherited; + final String? replaced; +} + +/// Theme covering the field collection rules: an inherited field is kept, a +/// private one is not a valid named argument so it is left out, `@ignore` on a +/// redeclaration also drops the inherited declaration, and an interface +/// contributes nothing. +@ShouldGenerateFile( + 'goldens/inherited_theme.g.theme.dart', + partOfCurrent: true, +) +@themeGen +final class InheritedTheme extends BaseTheme + with _$InheritedTheme + implements HasVersion { + InheritedTheme({required super.inherited}) : super(replaced: null); + + final _hidden = 0; + + @ignore + @override + // Redeclaring the inherited field is the point of this fixture. + // ignore: overridden_fields + final String? replaced = null; + + @override + int get version => _hidden; + + @override + bool get canMerge => true; + + static InheritedTheme? lerp( + InheritedTheme? a, + InheritedTheme? b, + double t, + ) => _$InheritedTheme.lerp(a, b, t); +} + +/// Declares a field whose type is the class' own type parameter. +class Slot { + const Slot({required this.value}); + + final T value; +} + +/// Theme inheriting a field from a generic superclass: the field is declared +/// as `T value`, and the generated code has to write it as the `num` the +/// `extends` clause fixes `T` to. +@ShouldGenerateFile( + 'goldens/generic_inherited_theme.g.theme.dart', + partOfCurrent: true, +) +@themeGen +final class GenericInheritedTheme extends Slot + with _$GenericInheritedTheme { + const GenericInheritedTheme({required super.value}); + + @override + bool get canMerge => true; + + static GenericInheritedTheme? lerp( + GenericInheritedTheme? a, + GenericInheritedTheme? b, + double t, + ) => _$GenericInheritedTheme.lerp(a, b, t); +} diff --git a/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart new file mode 100644 index 0000000..a3e2509 --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/inherited_theme.g.theme.dart @@ -0,0 +1,140 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'inherited_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$InheritedTheme { + bool get canMerge => true; + + static InheritedTheme? lerp(InheritedTheme? a, InheritedTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return InheritedTheme(inherited: t < 0.5 ? a.inherited : b.inherited); + } + + InheritedTheme copyWith({int? inherited}) { + final _this = (this as InheritedTheme); + + return InheritedTheme(inherited: inherited ?? _this.inherited); + } + + InheritedTheme merge(InheritedTheme? other) { + final _this = (this as InheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(inherited: other.inherited); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as InheritedTheme); + final _other = (other as InheritedTheme); + + return _other.inherited == _this.inherited; + } + + @override + int get hashCode { + final _this = (this as InheritedTheme); + + return Object.hash(runtimeType, _this.inherited); + } +} + +mixin _$GenericInheritedTheme { + bool get canMerge => true; + + static GenericInheritedTheme? lerp( + GenericInheritedTheme? a, + GenericInheritedTheme? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return GenericInheritedTheme(value: t < 0.5 ? a.value : b.value); + } + + GenericInheritedTheme copyWith({num? value}) { + final _this = (this as GenericInheritedTheme); + + return GenericInheritedTheme(value: value ?? _this.value); + } + + GenericInheritedTheme merge(GenericInheritedTheme? other) { + final _this = (this as GenericInheritedTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(value: other.value); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as GenericInheritedTheme); + final _other = (other as GenericInheritedTheme); + + return _other.value == _this.value; + } + + @override + int get hashCode { + final _this = (this as GenericInheritedTheme); + + return Object.hash(runtimeType, _this.value); + } +} diff --git a/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart b/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart new file mode 100644 index 0000000..3d2a7a7 --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/invalid_theme.dart @@ -0,0 +1,178 @@ +/// Classes the generator refuses, with the error it reports for each. +/// +/// This file is excluded from `generate_for` in `build.yaml`, so the failing +/// generation is only exercised by the test. None of the classes mix +/// in the generated mixin for the same reason; the mixin check runs last, so +/// every other class is stopped by its own error first. +library; + +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'flutter_stubs.dart'; + +/// `WidgetStateProperty.lerp` needs a lerp function with nullable parameters, +/// so the generic of a `WidgetStateProperty` field has to be nullable. +@ShouldThrow( + 'WidgetStateProperty must have a nullable generic type, because ' + 'WidgetStateProperty.lerp requires a lerp function with nullable ' + 'parameters. Found: WidgetStateProperty', + todo: 'Change the type of color to WidgetStateProperty', +) +@themeGen +final class NonNullableWidgetStatePropertyTheme { + const NonNullableWidgetStatePropertyTheme({required this.color}); + + final WidgetStateProperty color; +} + +/// The `constructor` option names a constructor that does not exist. +@ShouldThrow( + '`MissingNamedConstructorTheme` has no constructor named `_internal`.', + todo: + 'Declare `MissingNamedConstructorTheme._internal({...})`, or point ' + '`constructor:` at an existing constructor.', +) +@ThemeGen(constructor: '_internal') +final class MissingNamedConstructorTheme { + const MissingNamedConstructorTheme({required this.color}); + + final Color color; +} + +/// Without a `constructor` option the unnamed constructor is called, and this +/// class only has a named one. +@ShouldThrow( + '`MissingUnnamedConstructorTheme` has no unnamed constructor, which the ' + 'generated code calls.', + todo: + 'Declare `MissingUnnamedConstructorTheme({...})`, or point ' + '`constructor:` at the constructor to use.', +) +@themeGen +final class MissingUnnamedConstructorTheme { + const MissingUnnamedConstructorTheme.named({required this.color}); + + final Color color; +} + +/// Every field is passed to the constructor by name, so a field the +/// constructor does not take cannot be generated for. +@ShouldThrow( + 'The constructor `MissingParameterTheme` has no named parameters for the ' + 'fields `width`, `height`, which the generated code passes to it.', + todo: + 'Add `this.width` and the others to `MissingParameterTheme`, or mark ' + 'the fields with `@ignore`.', +) +@themeGen +final class MissingParameterTheme { + MissingParameterTheme({required this.color}) : width = 0, height = 0; + + final Color color; + final double width; + final double height; +} + +/// A positional parameter is not a named one. +@ShouldThrow( + 'The constructor `PositionalParameterTheme` has no named parameter for the ' + 'field `color`, which the generated code passes to it.', + todo: + 'Add `this.color` to `PositionalParameterTheme`, or mark the field ' + 'with `@ignore`.', +) +@themeGen +final class PositionalParameterTheme { + const PositionalParameterTheme(this.color); + + final Color color; +} + +/// `@ignore` takes the field out of the generated constructor call, so its +/// parameter has to be optional. +@ShouldThrow( + 'The constructor `IgnoredRequiredParameterTheme` requires `label`, which ' + 'is not among the fields the generated code passes to it.', + todo: + 'Make `label` optional, or make it a field the generated code passes: ' + 'declare it as `this.label`, without `@ignore` on the field.', +) +@themeGen +final class IgnoredRequiredParameterTheme { + const IgnoredRequiredParameterTheme({ + required this.color, + required this.label, + }); + + final Color color; + + @ignore + final String label; +} + +/// A required parameter that is not a field cannot be filled in either; +/// positional ones never are. +@ShouldThrow( + 'The constructor `ExtraParameterTheme` requires `scale`, `label`, which ' + 'are not among the fields the generated code passes to it.', + todo: + 'Make `scale` and the others optional, or make them fields the ' + 'generated code passes: declare them as `this.scale`, without ' + '`@ignore` on the field.', +) +@themeGen +final class ExtraParameterTheme { + ExtraParameterTheme( + double scale, { + required this.color, + required String label, + }) : assert(scale >= 0, 'scale must not be negative'), + assert(label.isNotEmpty, 'label must not be empty'); + + final Color color; +} + +/// The generated mixin names the class without type arguments. +@ShouldThrow( + '`GenericTheme` is generic, and the generated mixin cannot be: it ' + 'instantiates `GenericTheme` without type arguments.', + todo: + 'Remove the type parameters from `GenericTheme`, or write its theme ' + 'methods by hand.', +) +@themeGen +final class GenericTheme { + const GenericTheme({required this.value}); + + final T value; +} + +/// A field is a getter, and the mixin's `merge` is a method of the same name. +@ShouldThrow( + r'The generated mixin `_$ReservedFieldTheme` declares `merge`, so ' + '`ReservedFieldTheme` cannot have a field of that name.', + todo: 'Rename the field `merge`.', +) +@themeGen +final class ReservedFieldTheme { + const ReservedFieldTheme({required this.merge}); + + final int merge; +} + +/// Everything else is in order, so the missing `with` clause is what stops +/// this one. +@ShouldThrow( + '`MissingMixinTheme` does not apply the generated mixin ' + r'`_$MissingMixinTheme`, which holds the generated methods.', + todo: + r'Add `with _$MissingMixinTheme` to the declaration of ' + '`MissingMixinTheme`.', +) +@themeGen +final class MissingMixinTheme { + const MissingMixinTheme({required this.color}); + + final Color color; +} diff --git a/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart new file mode 100644 index 0000000..6b056f5 --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/invalid_theme_extension.dart @@ -0,0 +1,148 @@ +/// Theme extensions the generator refuses, with the error it reports for +/// each. +/// +/// This file is excluded from `generate_for` in `build.yaml`, so the failing +/// generation is only exercised by the test. None of the classes mix +/// in the generated mixin for the same reason; the mixin check runs last, so +/// every other class is stopped by its own error first. +library; + +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'flutter_stubs.dart'; + +/// The generated mixin is declared `on ThemeExtension`. +@ShouldThrow( + '`NotAThemeExtension` must extend `ThemeExtension` to ' + 'be annotated with `@ThemeExtensions`.', + todo: + 'Declare it as `class NotAThemeExtension extends ' + r'ThemeExtension with _$NotAThemeExtension`, or ' + 'use `@ThemeGen` for a plain class.', +) +@themeExtensions +final class NotAThemeExtension { + const NotAThemeExtension({required this.color}); + + final Color color; +} + +/// A valid theme extension that [WrongTypeArgument] borrows its type +/// argument from. +final class OtherExtension extends ThemeExtension { + const OtherExtension(); +} + +/// Extending `ThemeExtension` of another class is as wrong as not extending +/// it at all. +@ShouldThrow( + '`WrongTypeArgument` must extend `ThemeExtension` to be ' + 'annotated with `@ThemeExtensions`.', + todo: + 'Declare it as `class WrongTypeArgument extends ' + r'ThemeExtension with _$WrongTypeArgument`, or use ' + '`@ThemeGen` for a plain class.', +) +@themeExtensions +final class WrongTypeArgument extends ThemeExtension { + const WrongTypeArgument({required this.color}); + + final Color color; +} + +/// The accessor name is written into the generated extension as is. +@ShouldThrow( + '`my theme` is not a valid Dart identifier, so it cannot be used as ' + '`contextAccessorName`.', + todo: + r'Use letters, digits, `_` and `$` only, and do not start with a ' + 'digit.', +) +@ThemeExtensions(contextAccessorName: 'my theme') +final class BadAccessorName extends ThemeExtension { + const BadAccessorName({required this.color}); + + final Color color; +} + +/// The named constructor is the one checked against the fields. +@ShouldThrow( + 'The constructor `MissingParameterExtension._internal` has no named ' + 'parameter for the field `width`, which the generated code passes to it.', + todo: + 'Add `this.width` to `MissingParameterExtension._internal`, or mark ' + 'the field with `@ignore`.', +) +@ThemeExtensions(constructor: '_internal') +final class MissingParameterExtension + extends ThemeExtension { + MissingParameterExtension._internal({required this.color}) : width = 0; + + final Color color; + final double width; +} + +/// A reserved word matches the identifier pattern but cannot name a getter. +@ShouldThrow( + '`class` is a reserved word, so it cannot be used as `contextAccessorName`.', + todo: 'Use a name that is not a Dart keyword.', +) +@ThemeExtensions(contextAccessorName: 'class') +final class ReservedWordAccessor extends ThemeExtension { + const ReservedWordAccessor({required this.color}); + + final Color color; +} + +/// The mixin is declared `on ThemeExtension` with no type arguments to +/// pass on. +@ShouldThrow( + '`GenericExtension` is generic, and the generated mixin cannot be: it ' + 'instantiates `GenericExtension` without type arguments.', + todo: + 'Remove the type parameters from `GenericExtension`, or write its theme ' + 'methods by hand.', +) +@themeExtensions +final class GenericExtension extends ThemeExtension> { + const GenericExtension({required this.value}); + + final T value; +} + +/// The mixin's `lerp` is an instance method here, which a field cannot +/// override. `ThemeExtension` declares the same method, so the analyzer +/// objects to the field on its own; the generator still names the mixin so +/// that a `@ThemeGen` field of the same name reads the same. +@ShouldThrow( + r'The generated mixin `_$ReservedFieldExtension` declares `lerp`, so ' + '`ReservedFieldExtension` cannot have a field of that name.', + todo: 'Rename the field `lerp`.', +) +@themeExtensions +final class ReservedFieldExtension + extends ThemeExtension { + const ReservedFieldExtension({required this.lerp}); + + // The conflict is the point of this fixture. + // ignore: conflicting_field_and_method, annotate_overrides + final double lerp; +} + +/// Everything else is in order, so the missing `with` clause is what stops +/// this one. +@ShouldThrow( + '`MissingMixinExtension` does not apply the generated mixin ' + r'`_$MissingMixinExtension`, which holds the generated methods.', + todo: + r'Add `with _$MissingMixinExtension` to the declaration of ' + '`MissingMixinExtension`.', +) +@themeExtensions +final class MissingMixinExtension + extends ThemeExtension { + const MissingMixinExtension({required this.color}); + + final Color color; +} diff --git a/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart b/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart new file mode 100644 index 0000000..e22bfda --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/lookup_theme.dart @@ -0,0 +1,285 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'lookup_theme.g.theme.dart'; + +/// A plain class that happens to declare an unrelated `lerp` method. +class Curve { + const Curve(this.value); + + final double value; + + double lerp(double t) => value * t; +} + +/// A plain class with an instance `merge` method that also takes an +/// optional argument. +class Settings { + const Settings(this.value); + + final int value; + + Settings merge(Settings other, {bool deep = false}) => + Settings(value + other.value); +} + +/// A plain class that happens to declare an unrelated `merge` method. +class Flags { + const Flags(this.value); + + final int value; + + int merge() => value; +} + +/// A plain class whose `lerp` cannot be called with positional arguments only. +class Clamped { + const Clamped(this.value); + + final double value; + + static Clamped? lerp( + Clamped? a, + Clamped? b, + double t, { + required bool clamp, + }) => clamp ? a : b; +} + +/// A plain class with a static `merge` that has nothing to do with the class. +class Unrelated { + const Unrelated(this.value); + + final int value; + + static double merge(double a, double b) => a + b; +} + +/// A plain class with a four parameter `lerp` whose last parameter is not a +/// lerp function. +class Mode { + const Mode(this.value); + + final int value; + + static Mode? lerp(Mode? a, Mode? b, double t, int mode) => a; +} + +/// A plain class with a `WidgetStateProperty` shaped `lerp` but no generic to +/// interpolate. +class Pair { + const Pair(this.value); + + final int value; + + static Pair? lerp( + Pair? a, + Pair? b, + double t, + int? Function(int?, int?, double) lerpValue, + ) => a; +} + +/// A plain class whose `merge` cannot be called with positional arguments +/// only. +class Strict { + const Strict(this.value); + + final int value; + + Strict merge(Strict other, {required bool deep}) => other; +} + +/// Generic class whose `lerp` and `merge` are declared with the class' own +/// type parameter, so they only match once the type arguments are substituted. +class Box { + const Box(this.value); + + final T value; + + Box lerp(Box other, double t) => other; + + Box merge(Box other) => other; +} + +/// Declares the `lerp` and `merge` that [Special] inherits, both returning +/// this supertype. +class Animatable { + const Animatable(this.value); + + final int value; + + Animatable? lerp(Animatable other, double t) => other; + + Animatable merge(Animatable other) => other; +} + +/// Uses the inherited `lerp` and `merge`, whose results have to be cast back. +class Special extends Animatable { + const Special(super.value); +} + +/// An instance `lerp` with an optional result on a type used non-nullably, +/// whose null the generated code has to check away. +class Soft { + const Soft(this.value); + + final double value; + + Soft? lerp(Soft other, double t) => Soft(value + (other.value - value) * t); +} + +/// A `lerp` returning something unrelated to the class it is declared on. +class Fade { + const Fade(this.value); + + final double value; + + double? lerp(Fade? other, double t) => value; +} + +/// A `merge` whose result cannot stand in for the class. +class Counter { + const Counter(this.value); + + final int value; + + int merge(Counter other) => value + other.value; +} + +/// A static `lerp` whose result cannot stand in for the class. +class Ratio { + const Ratio(this.value); + + final double value; + + static double? lerp(Ratio? a, Ratio? b, double t) => a?.value; +} + +/// Base class declaring a field that [LookupTheme]'s superclass narrows. +class Base { + const Base({required this.narrowed}); + + final num narrowed; +} + +/// Narrows [Base.narrowed] to `int`, which is the declaration the generated +/// code has to use. +class Middle extends Base { + const Middle({required this.narrowed}) : super(narrowed: narrowed); + + @override + // Narrowing the inherited field is the point of this fixture. + // ignore: overridden_fields + final int narrowed; +} + +const _lerpFallback = 'switches over at t = 0.5 instead of being interpolated.'; + +const _mergeFallback = 'is overwritten instead of being merged.'; + +const _curveWarning = + 'The `lerp` method of Curve has an unsupported signature, so the field ' + '`curve` $_lerpFallback'; + +const _flagsWarning = + 'The `merge` method of Flags has an unsupported signature, so the field ' + '`flags` $_mergeFallback'; + +const _clampedWarning = + 'The `lerp` method of Clamped has an unsupported signature, so the field ' + '`clamped` $_lerpFallback'; + +const _unrelatedWarning = + 'The `merge` method of Unrelated has an unsupported signature, so the ' + 'field `unrelated` $_mergeFallback'; + +const _modeWarning = + 'The `lerp` method of Mode has an unsupported signature, so the field ' + '`mode` $_lerpFallback'; + +const _pairWarning = + 'The `lerp` method of Pair has an unsupported signature, so the field ' + '`pair` $_lerpFallback'; + +const _strictWarning = + 'The `merge` method of Strict has an unsupported signature, so the field ' + '`strict` $_mergeFallback'; + +const _fadeWarning = + 'The `lerp` method of Fade has an unsupported signature, so the field ' + '`fade` $_lerpFallback'; + +const _ratioWarning = + 'The `lerp` method of Ratio has an unsupported signature, so the field ' + '`ratio` $_lerpFallback'; + +const _counterWarning = + 'The `merge` method of Counter has an unsupported signature, so the field ' + '`counter` $_mergeFallback'; + +/// Theme whose field types are inspected by method lookup rather than by an +/// annotation: only [Settings] offers a signature the generator can call. +@ShouldGenerateFile( + 'goldens/lookup_theme.g.theme.dart', + partOfCurrent: true, + expectedLogItems: [ + _curveWarning, + _flagsWarning, + _clampedWarning, + _unrelatedWarning, + _modeWarning, + _pairWarning, + _strictWarning, + _fadeWarning, + _ratioWarning, + _counterWarning, + ], +) +@themeGen +final class LookupTheme extends Middle with _$LookupTheme { + const LookupTheme({ + required this.curve, + required this.settings, + required this.optionalSettings, + required this.flags, + required this.clamped, + required this.unrelated, + required this.mode, + required this.pair, + required this.strict, + required this.box, + required this.special, + required this.soft, + required this.fade, + required this.ratio, + required this.counter, + required super.narrowed, + }); + + /// Static fields are left out of the generated code, so they are not + /// inspected either. + static const unused = Curve(0); + + final Curve curve; + final Settings settings; + final Settings? optionalSettings; + final Flags flags; + final Clamped clamped; + final Unrelated unrelated; + final Mode mode; + final Pair pair; + final Strict strict; + final Box box; + final Special? special; + final Soft soft; + final Fade? fade; + final Ratio ratio; + final Counter counter; + + @override + bool get canMerge => true; + + static LookupTheme? lerp(LookupTheme? a, LookupTheme? b, double t) => + _$LookupTheme.lerp(a, b, t); +} diff --git a/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart new file mode 100644 index 0000000..e32e5ba --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/lookup_theme.g.theme.dart @@ -0,0 +1,186 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'lookup_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$LookupTheme { + bool get canMerge => true; + + static LookupTheme? lerp(LookupTheme? a, LookupTheme? b, double t) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return LookupTheme( + curve: t < 0.5 ? a.curve : b.curve, + settings: t < 0.5 ? a.settings : b.settings, + optionalSettings: t < 0.5 ? a.optionalSettings : b.optionalSettings, + flags: t < 0.5 ? a.flags : b.flags, + clamped: t < 0.5 ? a.clamped : b.clamped, + unrelated: t < 0.5 ? a.unrelated : b.unrelated, + mode: t < 0.5 ? a.mode : b.mode, + pair: t < 0.5 ? a.pair : b.pair, + strict: t < 0.5 ? a.strict : b.strict, + box: a.box.lerp(b.box, t), + special: a.special == null || b.special == null + ? t < 0.5 + ? a.special + : b.special + : (a.special!.lerp(b.special!, t) as Special?), + soft: a.soft.lerp(b.soft, t)!, + fade: t < 0.5 ? a.fade : b.fade, + ratio: t < 0.5 ? a.ratio : b.ratio, + counter: t < 0.5 ? a.counter : b.counter, + narrowed: t < 0.5 ? a.narrowed : b.narrowed, + ); + } + + LookupTheme copyWith({ + Curve? curve, + Settings? settings, + Settings? optionalSettings, + Flags? flags, + Clamped? clamped, + Unrelated? unrelated, + Mode? mode, + Pair? pair, + Strict? strict, + Box? box, + Special? special, + Soft? soft, + Fade? fade, + Ratio? ratio, + Counter? counter, + int? narrowed, + }) { + final _this = (this as LookupTheme); + + return LookupTheme( + curve: curve ?? _this.curve, + settings: settings ?? _this.settings, + optionalSettings: optionalSettings ?? _this.optionalSettings, + flags: flags ?? _this.flags, + clamped: clamped ?? _this.clamped, + unrelated: unrelated ?? _this.unrelated, + mode: mode ?? _this.mode, + pair: pair ?? _this.pair, + strict: strict ?? _this.strict, + box: box ?? _this.box, + special: special ?? _this.special, + soft: soft ?? _this.soft, + fade: fade ?? _this.fade, + ratio: ratio ?? _this.ratio, + counter: counter ?? _this.counter, + narrowed: narrowed ?? _this.narrowed, + ); + } + + LookupTheme merge(LookupTheme? other) { + final _this = (this as LookupTheme); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + curve: other.curve, + settings: _this.settings.merge(other.settings), + optionalSettings: _this.optionalSettings == null + ? other.optionalSettings + : other.optionalSettings == null + ? _this.optionalSettings + : _this.optionalSettings!.merge(other.optionalSettings!), + flags: other.flags, + clamped: other.clamped, + unrelated: other.unrelated, + mode: other.mode, + pair: other.pair, + strict: other.strict, + box: _this.box.merge(other.box), + special: _this.special == null + ? other.special + : other.special == null + ? _this.special + : (_this.special!.merge(other.special!) as Special?), + soft: other.soft, + fade: other.fade, + ratio: other.ratio, + counter: other.counter, + narrowed: other.narrowed, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as LookupTheme); + final _other = (other as LookupTheme); + + return _other.curve == _this.curve && + _other.settings == _this.settings && + _other.optionalSettings == _this.optionalSettings && + _other.flags == _this.flags && + _other.clamped == _this.clamped && + _other.unrelated == _this.unrelated && + _other.mode == _this.mode && + _other.pair == _this.pair && + _other.strict == _this.strict && + _other.box == _this.box && + _other.special == _this.special && + _other.soft == _this.soft && + _other.fade == _this.fade && + _other.ratio == _this.ratio && + _other.counter == _this.counter && + _other.narrowed == _this.narrowed; + } + + @override + int get hashCode { + final _this = (this as LookupTheme); + + return Object.hash( + runtimeType, + _this.curve, + _this.settings, + _this.optionalSettings, + _this.flags, + _this.clamped, + _this.unrelated, + _this.mode, + _this.pair, + _this.strict, + _this.box, + _this.special, + _this.soft, + _this.fade, + _this.ratio, + _this.counter, + _this.narrowed, + ); + } +} diff --git a/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart new file mode 100644 index 0000000..914e0ac --- /dev/null +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.dart @@ -0,0 +1,76 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'flutter_stubs.dart'; + +part 'widget_state_property_theme.g.theme.dart'; + +const _fallback = 'switches over at t = 0.5 instead of being interpolated.'; + +const _labelWarning = + 'WidgetStateProperty cannot be interpolated: `String` has no ' + 'static `String? lerp(String?, String?, double)` for ' + '`WidgetStateProperty.lerp` to call, so the field `label` $_fallback'; + +const _sideWarning = + 'WidgetStateProperty cannot be interpolated: `BorderSide` ' + 'has no static `BorderSide? lerp(BorderSide?, BorderSide?, double)` for ' + '`WidgetStateProperty.lerp` to call, so the field `side` $_fallback'; + +const _nestedWarning = + 'WidgetStateProperty?> cannot be ' + 'interpolated: `WidgetStateProperty` has no static ' + '`WidgetStateProperty? lerp(WidgetStateProperty?, ' + 'WidgetStateProperty?, double)` for `WidgetStateProperty.lerp` to ' + 'call, so the field `nested` $_fallback'; + +/// Theme covering the `WidgetStateProperty` shapes: `double` and `Duration` +/// generics have their own lerp functions, anything else needs a static +/// `lerp` on the generic that accepts nulls, and a generic without one is +/// reported once, whatever `lerp` it does declare. +@ShouldGenerateFile( + 'goldens/widget_state_property_theme.g.theme.dart', + partOfCurrent: true, + expectedLogItems: [_labelWarning, _sideWarning, _nestedWarning], +) +@themeGen +final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { + const WidgetStatePropertyTheme({ + required this.color, + required this.width, + required this.duration, + required this.optionalColor, + required this.optionalWidth, + required this.optionalDuration, + required this.label, + required this.side, + required this.nested, + }); + + final WidgetStateProperty color; + final WidgetStateProperty width; + final WidgetStateProperty duration; + + final WidgetStateProperty? optionalColor; + final WidgetStateProperty? optionalWidth; + final WidgetStateProperty? optionalDuration; + + /// `String` has no static lerp, so this one cannot be interpolated. + final WidgetStateProperty label; + + /// `BorderSide.lerp` takes no nulls, so it cannot be passed on either. + final WidgetStateProperty side; + + /// The inner generic is not nullable, which is a fallback here rather than + /// the error it is on a field: the message would point at the wrong type. + final WidgetStateProperty?> nested; + + @override + bool get canMerge => true; + + static WidgetStatePropertyTheme? lerp( + WidgetStatePropertyTheme? a, + WidgetStatePropertyTheme? b, + double t, + ) => _$WidgetStatePropertyTheme.lerp(a, b, t); +} diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart similarity index 83% rename from packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart index 6880fb9..cf14760 100644 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.g.theme.dart +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme.g.theme.dart @@ -48,19 +48,22 @@ mixin _$WidgetStatePropertyTheme { b.optionalColor, t, Color.lerp, - )!, + ), optionalWidth: WidgetStateProperty.lerp( a.optionalWidth, b.optionalWidth, t, lerpDouble$, - )!, + ), optionalDuration: WidgetStateProperty.lerp( a.optionalDuration, b.optionalDuration, t, lerpDuration$, - )!, + ), + label: t < 0.5 ? a.label : b.label, + side: t < 0.5 ? a.side : b.side, + nested: t < 0.5 ? a.nested : b.nested, ); } @@ -71,6 +74,9 @@ mixin _$WidgetStatePropertyTheme { WidgetStateProperty? optionalColor, WidgetStateProperty? optionalWidth, WidgetStateProperty? optionalDuration, + WidgetStateProperty? label, + WidgetStateProperty? side, + WidgetStateProperty?>? nested, }) { final _this = (this as WidgetStatePropertyTheme); @@ -81,6 +87,9 @@ mixin _$WidgetStatePropertyTheme { optionalColor: optionalColor ?? _this.optionalColor, optionalWidth: optionalWidth ?? _this.optionalWidth, optionalDuration: optionalDuration ?? _this.optionalDuration, + label: label ?? _this.label, + side: side ?? _this.side, + nested: nested ?? _this.nested, ); } @@ -102,6 +111,9 @@ mixin _$WidgetStatePropertyTheme { optionalColor: other.optionalColor, optionalWidth: other.optionalWidth, optionalDuration: other.optionalDuration, + label: other.label, + side: other.side, + nested: other.nested, ); } @@ -123,7 +135,10 @@ mixin _$WidgetStatePropertyTheme { _other.duration == _this.duration && _other.optionalColor == _this.optionalColor && _other.optionalWidth == _this.optionalWidth && - _other.optionalDuration == _this.optionalDuration; + _other.optionalDuration == _this.optionalDuration && + _other.label == _this.label && + _other.side == _this.side && + _other.nested == _this.nested; } @override @@ -138,6 +153,9 @@ mixin _$WidgetStatePropertyTheme { _this.optionalColor, _this.optionalWidth, _this.optionalDuration, + _this.label, + _this.side, + _this.nested, ); } } diff --git a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.dart similarity index 97% rename from packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.dart index 8974475..d6d5188 100644 --- a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.dart +++ b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.dart @@ -1,7 +1,7 @@ import 'package:source_gen_test/source_gen_test.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'mock.dart'; +import 'flutter_stubs.dart'; part 'widget_state_property_theme_extension.g.theme.dart'; diff --git a/packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.g.theme.dart similarity index 100% rename from packages/theme_extensions_builder/test/theme_extensions/widget_state_property_theme_extension.g.theme.dart rename to packages/theme_extensions_builder/test/fixtures/widget_state_property_theme_extension.g.theme.dart diff --git a/packages/theme_extensions_builder/test/generator/code_builder_test.dart b/packages/theme_extensions_builder/test/generator/code_builder_test.dart new file mode 100644 index 0000000..c41312d --- /dev/null +++ b/packages/theme_extensions_builder/test/generator/code_builder_test.dart @@ -0,0 +1,347 @@ +import 'package:test/test.dart'; +import 'package:theme_extensions_builder/src/common/symbols/field_info.dart'; +import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; +import 'package:theme_extensions_builder/src/common/symbols/merge_info.dart'; +import 'package:theme_extensions_builder/src/config/config.dart'; +import 'package:theme_extensions_builder/src/generator/theme_extensions/code_builder.dart'; +import 'package:theme_extensions_builder/src/generator/theme_gen/code_builder.dart'; + +/// Code paths that cannot be reached through the golden fixtures, either +/// because they need more fields than a readable fixture can hold, or because +/// they need a shape the stub classes don't provide. +void main() { + group('hashCode strategy', () { + test('no fields use runtimeType.hashCode', () { + final code = _generate(const []); + + expect(code, contains('return runtimeType.hashCode;')); + }); + + test('19 fields use Object.hash', () { + final code = _generate(_fields(19)); + + expect(code, contains('Object.hash(')); + expect(code, isNot(contains('Object.hashAll('))); + }); + + test('20 fields use Object.hashAll', () { + final code = _generate(_fields(20)); + + expect(code, contains('Object.hashAll([')); + expect(code, isNot(contains('Object.hash('))); + }); + }); + + group('constructor', () { + test('a const constructor is invoked with const without fields', () { + final code = _generate(const []); + + expect(code, contains('return const Theme();')); + }); + + test('a non-const constructor is invoked without const', () { + final code = _generate(const [], constConstructor: false); + + expect(code, contains('return Theme();')); + expect(code, isNot(contains('const Theme()'))); + }); + + test('a const constructor is invoked without const with fields', () { + final code = _generate([_field('value')]); + + expect(code, isNot(contains('const Theme('))); + }); + + test('a named constructor is used for every instantiation', () { + final code = _generate([_field('value')], constructor: '_internal'); + + expect(code, contains('Theme._internal(value: value ?? _this.value)')); + expect( + code, + contains('Theme._internal(value: t < 0.5 ? a.value : b.value)'), + ); + }); + }); + + group('lerp', () { + test('canMerge field takes the value of b', () { + final code = _generate([ + _field('canMerge', typeName: 'bool'), + ]); + + expect(code, contains('canMerge: b.canMerge')); + }); + + test('instance lerp with optional result on a non-nullable field', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const InstanceLerp(optionalResult: true, needsCast: true), + ), + ]); + + expect(code, contains('(a.value.lerp(b.value, t) as Lerpable)')); + }); + + test('instance lerp with optional result is null checked', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const InstanceLerp(optionalResult: true), + ), + ]); + + expect(code, contains('value: a.value.lerp(b.value, t)!')); + }); + + test('a result that already has the field type is not cast', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp(optionalResult: true), + ), + ]); + + expect(code, contains('a.value!.lerp(b.value!, t)')); + expect(code, isNot(contains('as Lerpable?'))); + }); + + test('instance lerp on a nullable field keeps the endpoints', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp(optionalResult: false, needsCast: true), + ), + ]); + + expect( + code, + contains( + 'a.value == null || b.value == null ? ' + 't < 0.5 ? a.value : b.value : ' + '(a.value!.lerp(b.value!, t) as Lerpable?)', + ), + ); + }); + + test('static lerp with a nullable result is null checked', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const StaticLerp( + optionalResult: true, + isNullableParameter: false, + ), + ), + ]); + + expect(code, contains('Lerpable.lerp(a.value, b.value, t)!')); + }); + + test('static lerp with a non-nullable result is not null checked', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const StaticLerp( + optionalResult: false, + isNullableParameter: true, + ), + ), + ]); + + expect(code, contains('value: Lerpable.lerp(a.value, b.value, t)')); + expect(code, isNot(contains('Lerpable.lerp(a.value, b.value, t)!'))); + }); + + test('static lerp taking non-nullable arguments is guarded', () { + final code = _generate([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const StaticLerp( + optionalResult: false, + isNullableParameter: false, + ), + ), + ]); + + expect( + code, + contains( + 'a.value == null || b.value == null ? ' + 't < 0.5 ? a.value : b.value : ' + 'Lerpable.lerp(a.value!, b.value!, t)', + ), + ); + }); + + test('the same guard is emitted for a theme extension', () { + final code = _generateExtension([ + _field( + 'value', + typeName: 'Lerpable', + isNullable: true, + lerp: const InstanceLerp(optionalResult: false, needsCast: true), + ), + ]); + + expect( + code, + contains( + '_this.value == null || other.value == null ? ' + 't < 0.5 ? _this.value : other.value : ' + '(_this.value!.lerp(other.value!, t) as Lerpable?)', + ), + ); + }); + + test('a theme extension does not cast a result of the field type', () { + final code = _generateExtension([ + _field( + 'value', + typeName: 'Lerpable', + lerp: const InstanceLerp(optionalResult: false), + ), + ]); + + expect(code, contains('value: _this.value.lerp(other.value, t)')); + expect(code, isNot(contains('as Lerpable'))); + }); + + test('a static call receiver drops the type arguments', () { + final code = _generate([ + _field( + 'value', + typeName: 'Box', + lerp: const StaticLerp( + optionalResult: true, + isNullableParameter: true, + ), + ), + ]); + + expect(code, contains('Box.lerp(a.value, b.value, t)!')); + expect(code, contains('Box? value')); + }); + + test('a canMerge field of a theme extension is not special', () { + final code = _generateExtension([ + _field('canMerge', typeName: 'bool'), + ]); + + expect( + code, + contains('canMerge: t < 0.5 ? _this.canMerge : other.canMerge'), + ); + }); + }); + + group('merge', () { + test('a static merge on a nullable field is guarded', () { + final code = _generate([ + _field( + 'value', + typeName: 'Mergeable', + isNullable: true, + merge: const StaticMerge(), + ), + ]); + + expect( + code, + contains( + 'value: _this.value == null ? other.value : ' + 'other.value == null ? _this.value : ' + 'Mergeable.merge(_this.value!, other.value!)', + ), + ); + }); + + test('an instance merge returning a supertype is cast back', () { + final code = _generate([ + _field( + 'value', + typeName: 'Mergeable', + merge: const InstanceMerge(needsCast: true), + ), + ]); + + expect( + code, + contains('value: (_this.value.merge(other.value) as Mergeable)'), + ); + }); + }); +} + +/// Generates the mixin for [fields] and normalizes the emitter output. +/// +/// The code builder emits unformatted code, so whitespace and the trailing +/// commas code_builder adds before a closing paren are collapsed to keep the +/// expectations readable. +String _generate( + List fields, { + String? constructor, + bool constConstructor = true, +}) { + final code = const ThemeGenCodeBuilder().generate( + ThemeGenConfig( + fields: fields, + className: 'Theme', + constructor: constructor, + constConstructor: constConstructor, + ), + ); + + return _normalize(code); +} + +/// Collapses whitespace and the trailing commas code_builder adds before a +/// closing paren, so the expectations stay readable. +String _normalize(String code) => + code.replaceAll(RegExp(r',\s*\)'), ')').replaceAll(RegExp(r'\s+'), ' '); + +/// Generates the mixin for a theme extension and normalizes the output the +/// same way [_generate] does. +String _generateExtension(List fields) => _normalize( + const ThemeExtensionsCodeBuilder().generate( + ThemeExtensionsConfig( + fields: fields, + className: 'Theme', + constructor: null, + buildContextExtension: false, + contextAccessorName: null, + themeExtensionMixinName: r'_$Theme', + constConstructor: true, + ), + ), +); + +List _fields(int count) => [ + for (var i = 0; i < count; i++) _field('field$i'), +]; + +FieldInfo _field( + String name, { + String typeName = 'int', + bool isNullable = false, + LerpInfo lerp = const NoLerp(), + MergeInfo merge = const NoMerge(), +}) => FieldInfo( + name: name, + typeName: typeName, + isNullable: isNullable, + isDouble: false, + isDuration: false, + merge: merge, + lerp: lerp, +); diff --git a/packages/theme_extensions_builder/test/generator/invalid_test.dart b/packages/theme_extensions_builder/test/generator/invalid_test.dart new file mode 100644 index 0000000..c93fa11 --- /dev/null +++ b/packages/theme_extensions_builder/test/generator/invalid_test.dart @@ -0,0 +1,30 @@ +import 'package:source_gen_test/source_gen_test.dart'; +import 'package:test/test.dart'; +import 'package:theme_extensions_builder/src/generator/theme_extensions/generator.dart'; +import 'package:theme_extensions_builder/src/generator/theme_gen/generator.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +Future main() async { + initializeBuildLogTracking(); + + final themeGenReader = await initializeLibraryReaderForDirectory( + 'test/fixtures', + 'invalid_theme.dart', + ); + + group('Invalid ThemeGen', () { + testAnnotatedElements(themeGenReader, const ThemeGenGenerator()); + }); + + final themeExtensionsReader = await initializeLibraryReaderForDirectory( + 'test/fixtures', + 'invalid_theme_extension.dart', + ); + + group('Invalid ThemeExtensions', () { + testAnnotatedElements( + themeExtensionsReader, + const ThemeExtensionsGenerator(), + ); + }); +} diff --git a/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart b/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart index f50db95..9047440 100644 --- a/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart +++ b/packages/theme_extensions_builder/test/generator/theme_extensions_test.dart @@ -9,7 +9,7 @@ Future main() async { const generator = ThemeExtensionsGenerator(); final emptyReader = await initializeLibraryReaderForDirectory( - 'test/theme_extensions', + 'test/fixtures', 'empty_theme_extension.dart', ); @@ -18,7 +18,7 @@ Future main() async { }); final complexReader = await initializeLibraryReaderForDirectory( - 'test/theme_extensions', + 'test/fixtures', 'complex_theme_extension.dart', ); group('Complex', () { @@ -26,7 +26,7 @@ Future main() async { }); final wspReader = await initializeLibraryReaderForDirectory( - 'test/theme_extensions', + 'test/fixtures', 'widget_state_property_theme_extension.dart', ); group('WidgetStateProperty', () { diff --git a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart index 3db934e..7724b3e 100644 --- a/packages/theme_extensions_builder/test/generator/theme_gen_test.dart +++ b/packages/theme_extensions_builder/test/generator/theme_gen_test.dart @@ -9,7 +9,7 @@ Future main() async { const generator = ThemeGenGenerator(); final emptyReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'empty_theme.dart', ); @@ -18,7 +18,7 @@ Future main() async { }); final complexReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'complex_theme.dart', ); @@ -27,11 +27,29 @@ Future main() async { }); final wspReader = await initializeLibraryReaderForDirectory( - 'test/theme_gen', + 'test/fixtures', 'widget_state_property_theme.dart', ); group('WidgetStateProperty', () { testAnnotatedElements(wspReader, generator); }); + + final lookupReader = await initializeLibraryReaderForDirectory( + 'test/fixtures', + 'lookup_theme.dart', + ); + + group('MethodLookup', () { + testAnnotatedElements(lookupReader, generator); + }); + + final inheritedReader = await initializeLibraryReaderForDirectory( + 'test/fixtures', + 'inherited_theme.dart', + ); + + group('Inheritance', () { + testAnnotatedElements(inheritedReader, generator); + }); } diff --git a/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart b/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart deleted file mode 100644 index 23b1d8f..0000000 --- a/packages/theme_extensions_builder/test/runtime/fields_visitor_config_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:test/test.dart'; -import 'package:theme_extensions_builder/src/common/fields_visitor_config.dart'; - -void main() { - group('FieldsVisitorConfig', () { - test('default config has all lookups enabled', () { - const config = FieldsVisitorConfig(); - expect(config.includeLerpLookup, isTrue); - expect(config.includeMergeLookup, isTrue); - }); - - test('custom config with both lookups disabled', () { - const config = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - expect(config.includeLerpLookup, isFalse); - expect(config.includeMergeLookup, isFalse); - }); - - test('custom config with only lerp lookup enabled', () { - const config = FieldsVisitorConfig(includeMergeLookup: false); - expect(config.includeLerpLookup, isTrue); - expect(config.includeMergeLookup, isFalse); - }); - - test('custom config with only merge lookup enabled', () { - const config = FieldsVisitorConfig(includeLerpLookup: false); - expect(config.includeLerpLookup, isFalse); - expect(config.includeMergeLookup, isTrue); - }); - - test('equality works correctly', () { - const config1 = FieldsVisitorConfig(); - const config2 = FieldsVisitorConfig(); - const config3 = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - - expect(config1, equals(config2)); - expect(config1, isNot(equals(config3))); - }); - - test('hashCode is consistent for equal configs', () { - const config1 = FieldsVisitorConfig(); - const config2 = FieldsVisitorConfig(); - const config3 = FieldsVisitorConfig(); - - // Equal configs must have equal hashCodes - expect(config1.hashCode, equals(config2.hashCode)); - expect(config1.hashCode, equals(config3.hashCode)); - }); - - test('hashCode is deterministic', () { - const config = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - - // Same config should always return the same hashCode - expect(config.hashCode, equals(config.hashCode)); - - // Different instance with same values should have same hashCode - const config2 = FieldsVisitorConfig( - includeLerpLookup: false, - includeMergeLookup: false, - ); - expect(config.hashCode, equals(config2.hashCode)); - }); - - test('toString provides readable output', () { - const config = FieldsVisitorConfig(includeLerpLookup: false); - - final string = config.toString(); - expect(string, contains('FieldsVisitorConfig')); - expect(string, contains('includeLerpLookup: false')); - expect(string, contains('includeMergeLookup: true')); - }); - }); -} diff --git a/packages/theme_extensions_builder/test/runtime/symbols_test.dart b/packages/theme_extensions_builder/test/runtime/symbols_test.dart index f6a556f..6455d72 100644 --- a/packages/theme_extensions_builder/test/runtime/symbols_test.dart +++ b/packages/theme_extensions_builder/test/runtime/symbols_test.dart @@ -1,192 +1,135 @@ +// Several tests build values without `const` on purpose: two identical const +// expressions are canonicalized into the same object, which would make the +// equality checks trivially true. +// ignore_for_file: prefer_const_constructors + import 'package:test/test.dart'; import 'package:theme_extensions_builder/src/common/symbols/field_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/lerp_info.dart'; import 'package:theme_extensions_builder/src/common/symbols/merge_info.dart'; -import 'package:theme_extensions_builder/src/common/symbols/parameter_info.dart' - show ParameterInfo; void main() { - group('ParameterInfo', () { - test('creates ParameterInfo with required properties', () { - const arg = ParameterInfo( - name: 'value', - type: 'String', - isNullable: true, - ); - - expect(arg.name, 'value'); - expect(arg.type, 'String'); - expect(arg.isNullable, true); - }); - - test('equality works correctly', () { - const arg1 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - const arg2 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - const arg3 = ParameterInfo(name: 'b', type: 'int', isNullable: false); - - expect(arg1, equals(arg2)); - expect(arg1, isNot(equals(arg3))); - }); - - test('hashCode works correctly', () { - const arg1 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - const arg2 = ParameterInfo(name: 'a', type: 'int', isNullable: false); - - expect(arg1.hashCode, equals(arg2.hashCode)); - }); - - test('toString returns readable format', () { - const arg = ParameterInfo(name: 'test', type: 'double', isNullable: true); - expect( - arg.toString(), - 'ParameterInfo(name: test, type: double, isNullable: true)', - ); - }); - }); - group('StaticLerp', () { test('creates StaticLerp with properties', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); + const lerp = StaticLerp(optionalResult: true, isNullableParameter: true); expect(lerp.optionalResult, true); - expect(lerp.args.length, 3); + expect(lerp.isNullableParameter, true); }); - test('isNullableSignature returns true when all conditions met', () { - const lerp = StaticLerp( + test('equality works correctly', () { + final lerp1 = StaticLerp(optionalResult: true, isNullableParameter: true); + final lerp2 = StaticLerp(optionalResult: true, isNullableParameter: true); + final lerp3 = StaticLerp( optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], + isNullableParameter: false, ); - - expect(lerp.isNullableSignature, true); - }); - - test('isNullableSignature returns false when optionalResult is false', () { - const lerp = StaticLerp( + final lerp4 = StaticLerp( optionalResult: false, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: true), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], + isNullableParameter: true, ); - expect(lerp.isNullableSignature, false); - }); - - test('isNullableSignature returns false when first arg not nullable', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'Color', isNullable: false), - ParameterInfo(name: 'b', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); - - expect(lerp.isNullableSignature, false); + expect(lerp1, equals(lerp2)); + expect(lerp1.hashCode, equals(lerp2.hashCode)); + expect(lerp1, isNot(equals(lerp3))); + expect(lerp1, isNot(equals(lerp4))); }); - test('isNullableSignature handles empty args safely', () { - const lerp = StaticLerp(optionalResult: true, args: []); + test('toString returns correct format', () { + const lerp = StaticLerp(optionalResult: true, isNullableParameter: false); - expect(lerp.isNullableSignature, false); + expect( + lerp.toString(), + 'StaticLerp(optionalResult: true, isNullableParameter: false)', + ); }); + }); - test('isNullableSignature handles single arg safely', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'Color', isNullable: true)], - ); + group('InstanceLerp', () { + test('creates InstanceLerp with properties', () { + const lerp = InstanceLerp(optionalResult: true); - expect(lerp.isNullableSignature, false); + expect(lerp.optionalResult, true); + expect(lerp.needsCast, false); }); test('equality works correctly', () { - const lerp1 = StaticLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); - const lerp2 = StaticLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); - const lerp3 = StaticLerp( - optionalResult: false, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); + final lerp1 = InstanceLerp(optionalResult: true); + final lerp2 = InstanceLerp(optionalResult: true); + final lerp3 = InstanceLerp(optionalResult: false); expect(lerp1, equals(lerp2)); + expect(lerp1.hashCode, equals(lerp2.hashCode)); expect(lerp1, isNot(equals(lerp3))); }); - }); - group('InstanceLerp', () { - test('creates InstanceLerp with properties', () { - const lerp = InstanceLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: false), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); + test('needsCast takes part in equality', () { + final plain = InstanceLerp(optionalResult: true); + final cast = InstanceLerp(optionalResult: true, needsCast: true); - expect(lerp.optionalResult, true); - expect(lerp.args.length, 2); + expect(plain, isNot(equals(cast))); + expect(plain.hashCode, isNot(equals(cast.hashCode))); }); - test('isNullableSignature returns true when all conditions met', () { - const lerp = InstanceLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], + test('toString returns correct format', () { + const lerp = InstanceLerp(optionalResult: false); + + expect( + lerp.toString(), + 'InstanceLerp(optionalResult: false, needsCast: false)', ); + }); + }); + + group('WidgetStatePropertyLerp', () { + WidgetStatePropertyLerp build({ + String genericType = 'Color', + bool genericIsDouble = false, + bool genericIsDuration = false, + }) => WidgetStatePropertyLerp( + baseTypeName: 'WidgetStateProperty', + genericType: genericType, + genericIsDouble: genericIsDouble, + genericIsDuration: genericIsDuration, + ); - expect(lerp.isNullableSignature, true); + test('reports the generic type', () { + expect(build().genericIsDouble, isFalse); + expect(build().genericIsDuration, isFalse); + expect(build(genericIsDouble: true).genericIsDouble, isTrue); + expect(build(genericIsDuration: true).genericIsDuration, isTrue); }); - test('isNullableSignature returns false when optionalResult is false', () { - const lerp = InstanceLerp( - optionalResult: false, - args: [ - ParameterInfo(name: 'other', type: 'Color', isNullable: true), - ParameterInfo(name: 't', type: 'double', isNullable: false), - ], - ); + test('the inner lerp receiver drops the type arguments', () { + expect(build().genericBaseTypeName, 'Color'); + expect(build(genericType: 'Box').genericBaseTypeName, 'Box'); + }); - expect(lerp.isNullableSignature, false); + test('equality works correctly', () { + expect(build(), equals(build())); + expect(build().hashCode, equals(build().hashCode)); + expect(build(), isNot(equals(build(genericType: 'double')))); }); - test('isNullableSignature handles empty args safely', () { - const lerp = InstanceLerp(optionalResult: true, args: []); + test('the element checks take part in equality', () { + // A user type called `Duration` shares genericType with the real one. + final byName = build(genericType: 'Duration'); + final byElement = build(genericType: 'Duration', genericIsDuration: true); - expect(lerp.isNullableSignature, false); + expect(byName, isNot(equals(byElement))); + expect(byName.hashCode, isNot(equals(byElement.hashCode))); + expect(build(), isNot(equals(build(genericIsDouble: true)))); }); - test('equality works correctly', () { - const lerp1 = InstanceLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], - ); - const lerp2 = InstanceLerp( - optionalResult: true, - args: [ParameterInfo(name: 'a', type: 'int', isNullable: false)], + test('toString returns correct format', () { + expect( + build().toString(), + 'WidgetStatePropertyLerp(' + 'baseTypeName: WidgetStateProperty, ' + 'genericType: Color, ' + 'genericIsDouble: false, ' + 'genericIsDuration: false)', ); - - expect(lerp1, equals(lerp2)); }); }); @@ -197,10 +140,11 @@ void main() { }); test('equality works correctly', () { - const lerp1 = NoLerp(); - const lerp2 = NoLerp(); + final lerp1 = NoLerp(); + final lerp2 = NoLerp(); expect(lerp1, equals(lerp2)); + expect(lerp1.hashCode, equals(lerp2.hashCode)); }); test('toString returns correct format', () { @@ -211,24 +155,31 @@ void main() { group('MergeInfo', () { test('NoMerge equality works', () { - const merge1 = NoMerge(); - const merge2 = NoMerge(); + final merge1 = NoMerge(); + final merge2 = NoMerge(); expect(merge1, equals(merge2)); + expect(merge1.hashCode, equals(merge2.hashCode)); }); test('StaticMerge equality works', () { - const merge1 = StaticMerge(); - const merge2 = StaticMerge(); + final merge1 = StaticMerge(); + final merge2 = StaticMerge(); expect(merge1, equals(merge2)); + expect(merge1.hashCode, equals(merge2.hashCode)); }); test('InstanceMerge equality works', () { - const merge1 = InstanceMerge(); - const merge2 = InstanceMerge(); + final merge1 = InstanceMerge(); + final merge2 = InstanceMerge(); + const merge3 = InstanceMerge(isNullableParameter: false); + const merge4 = InstanceMerge(needsCast: true); expect(merge1, equals(merge2)); + expect(merge1.hashCode, equals(merge2.hashCode)); + expect(merge1, isNot(equals(merge3))); + expect(merge1, isNot(equals(merge4))); }); test('different merge methods are not equal', () { @@ -248,22 +199,34 @@ void main() { expect(noMerge.toString(), 'NoMerge()'); expect(staticMerge.toString(), 'StaticMerge()'); - expect(instanceMerge.toString(), 'InstanceMerge()'); + expect( + instanceMerge.toString(), + 'InstanceMerge(isNullableParameter: true, needsCast: false)', + ); }); }); group('FieldInfo', () { + FieldInfo build({ + String name = 'value', + String typeName = 'int', + bool isNullable = false, + bool isDouble = false, + bool isDuration = false, + MergeInfo merge = const NoMerge(), + LerpInfo lerp = const NoLerp(), + }) => FieldInfo( + name: name, + typeName: typeName, + isNullable: isNullable, + isDouble: isDouble, + isDuration: isDuration, + merge: merge, + lerp: lerp, + ); + test('creates FieldInfo with all properties', () { - const field = FieldInfo( - name: 'color', - typeName: 'Color', - isNullable: true, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); + final field = build(name: 'color', typeName: 'Color', isNullable: true); expect(field.name, 'color'); expect(field.typeName, 'Color'); @@ -272,204 +235,51 @@ void main() { expect(field.isDuration, false); expect(field.merge, isA()); expect(field.lerp, isA()); - expect(field.isStatic, false); }); - test('equality works correctly with same properties', () { - const field1 = FieldInfo( - name: 'value', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'value', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field1, equals(field2)); + test('the static call receiver drops the type arguments', () { + expect(build(typeName: 'Box').baseTypeName, 'Box'); + expect(build(typeName: 'Color').baseTypeName, 'Color'); }); - test('equality returns false with different properties', () { - const field1 = FieldInfo( - name: 'value', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'other', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field1, isNot(equals(field2))); + test('equality works correctly with same properties', () { + expect(build(), equals(build())); + expect(build().hashCode, equals(build().hashCode)); }); - test('hashCode is consistent', () { - const field1 = FieldInfo( - name: 'test', - typeName: 'String', - isNullable: true, - isDouble: false, - isDuration: false, - merge: StaticMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'test', - typeName: 'String', - isNullable: true, - isDouble: false, - isDuration: false, - merge: StaticMerge(), - lerp: NoLerp(), - isStatic: false, + test('equality returns false with different properties', () { + expect(build(), isNot(equals(build(name: 'other')))); + expect(build(), isNot(equals(build(typeName: 'double')))); + expect(build(), isNot(equals(build(isNullable: true)))); + expect(build(), isNot(equals(build(isDouble: true)))); + expect(build(), isNot(equals(build(isDuration: true)))); + expect(build(), isNot(equals(build(merge: const StaticMerge())))); + expect( + build(), + isNot( + equals( + build( + lerp: const StaticLerp( + optionalResult: true, + isNullableParameter: true, + ), + ), + ), + ), ); - - expect(field1.hashCode, equals(field2.hashCode)); }); test('toString returns readable format', () { - const field = FieldInfo( - name: 'duration', - typeName: 'Duration', - isNullable: false, - isDouble: false, - isDuration: true, - merge: InstanceMerge(), - lerp: StaticLerp(optionalResult: false, args: []), - isStatic: false, - ); - - final string = field.toString(); - expect(string, contains('duration')); - expect(string, contains('Duration')); - expect(string, contains('isDuration: true')); - }); - - test('works with different lerp methods', () { - const field1 = FieldInfo( - name: 'x', - typeName: 'double', - isNullable: false, - isDouble: true, - isDuration: false, - merge: NoMerge(), - lerp: StaticLerp(optionalResult: false, args: []), - isStatic: false, - ); - - const field2 = FieldInfo( - name: 'x', - typeName: 'double', - isNullable: false, - isDouble: true, - isDuration: false, - merge: NoMerge(), - lerp: InstanceLerp(optionalResult: false, args: []), - isStatic: false, - ); - - expect(field1, isNot(equals(field2))); - }); - }); - - group('Edge cases and boundaries', () { - test('StaticLerp with exactly 2 args works', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'int', isNullable: true), - ParameterInfo(name: 'b', type: 'int', isNullable: true), - ], - ); - - expect(lerp.isNullableSignature, true); - }); - - test('StaticLerp with 3+ args checks first two', () { - const lerp = StaticLerp( - optionalResult: true, - args: [ - ParameterInfo(name: 'a', type: 'int', isNullable: true), - ParameterInfo(name: 'b', type: 'int', isNullable: true), - ParameterInfo(name: 'c', type: 'double', isNullable: false), - ParameterInfo(name: 'd', type: 'String', isNullable: true), - ], - ); - - expect(lerp.isNullableSignature, true); - }); - - test('FieldInfo with isDouble true', () { - const field = FieldInfo( - name: 'opacity', - typeName: 'double', - isNullable: false, - isDouble: true, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field.isDouble, true); - expect(field.isDuration, false); - }); - - test('FieldInfo with isDuration true', () { - const field = FieldInfo( - name: 'timeout', - typeName: 'Duration', - isNullable: false, - isDouble: false, - isDuration: true, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: false, - ); - - expect(field.isDouble, false); - expect(field.isDuration, true); - }); - - test('FieldInfo with isStatic true', () { - const field = FieldInfo( - name: 'constant', - typeName: 'int', - isNullable: false, - isDouble: false, - isDuration: false, - merge: NoMerge(), - lerp: NoLerp(), - isStatic: true, + expect( + build().toString(), + 'FieldInfo(name: value, ' + 'typeName: int, ' + 'isNullable: false, ' + 'isDouble: false, ' + 'isDuration: false, ' + 'merge: NoMerge(), ' + 'lerp: NoLerp())', ); - - expect(field.isStatic, true); }); }); } diff --git a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart index 225d9b7..41206d4 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_extensions_complex_test.dart @@ -1,9 +1,9 @@ import 'package:test/test.dart'; -import '../theme_extensions/complex_theme_extension.dart'; -import '../theme_extensions/empty_theme.dart'; -import '../theme_extensions/empty_theme_extension.dart'; -import '../theme_extensions/mock.dart'; +import '../fixtures/complex_theme_extension.dart'; +import '../fixtures/empty_theme.dart'; +import '../fixtures/empty_theme_extension.dart'; +import '../fixtures/flutter_stubs.dart'; void main() { group('ComplexThemeExtensionNoContext', () { @@ -275,9 +275,10 @@ void main() { optionalThemeExtension: EmptyThemeExtension(), ); - final copied = - theme.copyWith(requiredInt: 999, requiredString: 'updated') - as ComplexThemeExtension; + final copied = theme.copyWith( + requiredInt: 999, + requiredString: 'updated', + ) as ComplexThemeExtension; expect(copied.requiredInt, equals(999)); expect(copied.requiredString, equals('updated')); @@ -306,9 +307,10 @@ void main() { optionalThemeExtension: EmptyThemeExtension(), ); - final copied = - theme.copyWith(optionalInt: 500, optionalString: 'new') - as ComplexThemeExtension; + final copied = theme.copyWith( + optionalInt: 500, + optionalString: 'new', + ) as ComplexThemeExtension; expect(copied.optionalInt, equals(500)); expect(copied.optionalString, equals('new')); diff --git a/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart b/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart index f2aa177..42ab1d4 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_extensions_empty_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import '../theme_extensions/empty_theme_extension.dart'; +import '../fixtures/empty_theme_extension.dart'; void main() { group('EmptyThemeExtension', () { diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart index 86216f1..f8de78e 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_complex_test.dart @@ -1,9 +1,9 @@ import 'package:test/test.dart'; -import '../theme_gen/complex_theme.dart'; -import '../theme_gen/empty_theme.dart'; -import '../theme_gen/empty_theme_extension.dart'; -import '../theme_gen/mock.dart'; +import '../fixtures/complex_theme.dart'; +import '../fixtures/empty_theme.dart'; +import '../fixtures/empty_theme_extension.dart'; +import '../fixtures/flutter_stubs.dart'; void main() { group('ComplexTheme', () { @@ -242,7 +242,7 @@ void main() { }); test( - 'lerps instance lerp with optional result when first value is null', + 'keeps the endpoints when the first instance lerp value is null', () { // Create themes where one has null for the lerpable field const themeWithNull = ComplexTheme( @@ -289,11 +289,32 @@ void main() { optionalLerpableWithOptionalResult: LerpableWithOptionalResult(8), ); - final result = ComplexTheme.lerp(themeWithNull, themeWithValue, 0.5); - - expect(result, isNotNull); - // When lerp is called on null with ?.lerp, it returns null - expect(result!.optionalLerpableWithOptionalResult, isNull); + // An instance lerp cannot run on a null receiver, so the value the + // timeline is closest to is taken instead. The endpoints still hold. + expect( + ComplexTheme.lerp( + themeWithNull, + themeWithValue, + 0, + )!.optionalLerpableWithOptionalResult, + isNull, + ); + expect( + ComplexTheme.lerp( + themeWithNull, + themeWithValue, + 1, + )!.optionalLerpableWithOptionalResult, + same(themeWithValue.optionalLerpableWithOptionalResult), + ); + expect( + ComplexTheme.lerp( + themeWithNull, + themeWithValue, + 0.5, + )!.optionalLerpableWithOptionalResult, + same(themeWithValue.optionalLerpableWithOptionalResult), + ); }, ); }); diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart index 2aadbeb..1353f48 100644 --- a/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_empty_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import '../theme_extensions/empty_theme.dart'; +import '../fixtures/empty_theme.dart'; void main() { group('EmptyTheme - with const constructor', () { @@ -121,136 +121,136 @@ void main() { }); }); - group('EmptyThemeWithoutConstConstructor - without const constructor', () { + group('EmptyThemeNonConst - without const constructor', () { test('can be instantiated', () { - final theme = EmptyThemeWithoutConstConstructor(); - expect(theme, isA()); + final theme = EmptyThemeNonConst(); + expect(theme, isA()); }); test('canMerge returns true', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); expect(theme.canMerge, isTrue); }); group('lerp', () { test('returns equal instance when lerping', () { - final a = EmptyThemeWithoutConstConstructor(); - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, b, 0.5); + final a = EmptyThemeNonConst(); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, b, 0.5); expect(result, equals(a)); expect(identical(a, b), isFalse); expect(identical(result, a), isFalse); expect(identical(result, b), isFalse); - expect(result, isA()); + expect(result, isA()); }); test('returns null when both are null', () { - final result = EmptyThemeWithoutConstConstructor.lerp(null, null, 0.5); + final result = EmptyThemeNonConst.lerp(null, null, 0.5); expect(result, isNull); }); test('returns null when a is null and t != 1.0', () { - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(null, b, 0.5); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(null, b, 0.5); expect(result, isNull); }); test('returns b when a is null and t == 1.0', () { - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(null, b, 1); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(null, b, 1); expect(result, equals(b)); expect(identical(result, b), isTrue); }); test('returns null when b is null and t != 0.0', () { - final a = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, null, 0.5); + final a = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, null, 0.5); expect(result, isNull); }); test('returns a when b is null and t == 0.0', () { - final a = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, null, 0); + final a = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, null, 0); expect(result, equals(a)); expect(identical(result, a), isTrue); }); test('returns new instance when both are not null', () { - final a = EmptyThemeWithoutConstConstructor(); - final b = EmptyThemeWithoutConstConstructor(); - final result = EmptyThemeWithoutConstConstructor.lerp(a, b, 0.5); + final a = EmptyThemeNonConst(); + final b = EmptyThemeNonConst(); + final result = EmptyThemeNonConst.lerp(a, b, 0.5); expect(result, isNotNull); expect(identical(a, b), isFalse); expect(identical(result, a), isFalse); expect(identical(result, b), isFalse); - expect(result, isA()); + expect(result, isA()); }); test('lerp with various t values', () { - final a = EmptyThemeWithoutConstConstructor(); - final b = EmptyThemeWithoutConstConstructor(); - - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0.25), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0.5), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 0.75), isNotNull); - expect(EmptyThemeWithoutConstConstructor.lerp(a, b, 1), isNotNull); + final a = EmptyThemeNonConst(); + final b = EmptyThemeNonConst(); + + expect(EmptyThemeNonConst.lerp(a, b, 0), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 0.25), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 0.5), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 0.75), isNotNull); + expect(EmptyThemeNonConst.lerp(a, b, 1), isNotNull); }); }); group('copyWith', () { test('returns new instance', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final copied = theme.copyWith(); expect(copied, equals(theme)); expect(identical(copied, theme), isFalse); }); test('creates independent copy', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final copied = theme.copyWith(); - expect(copied, isA()); + expect(copied, isA()); expect(copied, isNot(same(theme))); }); }); group('merge', () { test('returns this when other is null', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final merged = theme.merge(null); expect(identical(merged, theme), isTrue); }); test('returns this when identical', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); final merged = theme.merge(theme); expect(identical(merged, theme), isTrue); }); test('returns merged instance when other can merge', () { - final theme = EmptyThemeWithoutConstConstructor(); - final other = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); + final other = EmptyThemeNonConst(); final merged = theme.merge(other); - expect(merged, isA()); + expect(merged, isA()); }); }); group('equality', () { test('two instances are equal', () { - final theme1 = EmptyThemeWithoutConstConstructor(); - final theme2 = EmptyThemeWithoutConstConstructor(); + final theme1 = EmptyThemeNonConst(); + final theme2 = EmptyThemeNonConst(); expect(theme1, equals(theme2)); expect(theme1.hashCode, equals(theme2.hashCode)); expect(identical(theme1, theme2), isFalse); }); test('not equal to different type', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); expect(theme == Object(), isFalse); }); test('not equal to EmptyTheme', () { - final theme = EmptyThemeWithoutConstConstructor(); + final theme = EmptyThemeNonConst(); const otherTheme = EmptyTheme(); // == operator between different types // ignore: unrelated_type_equality_checks diff --git a/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart new file mode 100644 index 0000000..842c4ce --- /dev/null +++ b/packages/theme_extensions_builder/test/runtime/theme_gen_lookup_test.dart @@ -0,0 +1,128 @@ +import 'package:test/test.dart'; + +import '../fixtures/lookup_theme.dart'; + +void main() { + const a = LookupTheme( + curve: Curve(0), + settings: Settings(1), + optionalSettings: Settings(1), + flags: Flags(1), + clamped: Clamped(1), + unrelated: Unrelated(1), + mode: Mode(1), + pair: Pair(1), + strict: Strict(1), + box: Box(1), + special: Special(1), + soft: Soft(1), + fade: Fade(1), + ratio: Ratio(1), + counter: Counter(1), + narrowed: 1, + ); + + const b = LookupTheme( + curve: Curve(10), + settings: Settings(2), + optionalSettings: Settings(2), + flags: Flags(2), + clamped: Clamped(2), + unrelated: Unrelated(2), + mode: Mode(2), + pair: Pair(2), + strict: Strict(2), + box: Box(2), + special: Special(2), + soft: Soft(3), + fade: Fade(2), + ratio: Ratio(2), + counter: Counter(2), + narrowed: 2, + ); + + group('LookupTheme', () { + test('a type with an unrelated lerp method falls back to a switch', () { + expect(LookupTheme.lerp(a, b, 0.4)!.curve, same(a.curve)); + expect(LookupTheme.lerp(a, b, 0.6)!.curve, same(b.curve)); + }); + + test('a type with an unrelated merge method takes the other value', () { + expect(a.merge(b).flags, same(b.flags)); + }); + + test('an instance merge method is called for both nullabilities', () { + final merged = a.merge(b); + + expect(merged.settings.value, 3); + expect(merged.optionalSettings!.value, 3); + }); + + test('an uncallable lerp or merge signature is ignored', () { + expect(LookupTheme.lerp(a, b, 0.4)!.clamped, same(a.clamped)); + expect(LookupTheme.lerp(a, b, 0.4)!.mode, same(a.mode)); + expect(LookupTheme.lerp(a, b, 0.4)!.pair, same(a.pair)); + expect(a.merge(b).unrelated, same(b.unrelated)); + expect(a.merge(b).strict, same(b.strict)); + expect(a.merge(b).counter, same(b.counter)); + }); + + test('a generic type resolves its methods through the instantiation', () { + // Box.lerp returns `other`, which the t < 0.5 fallback would not do + // at 0.4. + expect(LookupTheme.lerp(a, b, 0.4)!.box, same(b.box)); + }); + + test('an inherited lerp returning a supertype is cast back', () { + // Animatable.lerp also returns `other`, so the fallback would hand back + // a.special here. The cast is what keeps the result a Special. + final lerped = LookupTheme.lerp(a, b, 0.4)!.special; + + expect(lerped, same(b.special)); + expect(lerped, isA()); + expect(a.merge(b).special, isA()); + }); + + test('an instance lerp with an optional result is null checked', () { + expect(LookupTheme.lerp(a, b, 0.5)!.soft.value, 2); + }); + + test('a lerp returning an unrelated type is ignored', () { + expect(LookupTheme.lerp(a, b, 0.4)!.fade, same(a.fade)); + }); + + test('a field narrowed by a superclass keeps the narrowed type', () { + expect(a.copyWith(narrowed: 7).narrowed, 7); + }); + + test('a null field on either side skips the merge method', () { + const withoutSettings = LookupTheme( + curve: Curve(0), + settings: Settings(1), + optionalSettings: null, + flags: Flags(1), + clamped: Clamped(1), + unrelated: Unrelated(1), + mode: Mode(1), + pair: Pair(1), + strict: Strict(1), + box: Box(1), + special: Special(1), + soft: Soft(1), + fade: Fade(1), + ratio: Ratio(1), + counter: Counter(1), + narrowed: 1, + ); + + expect( + withoutSettings.merge(b).optionalSettings, + same(b.optionalSettings), + ); + expect( + a.merge(withoutSettings).optionalSettings, + same(a.optionalSettings), + ); + }); + }); +} diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart b/packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart deleted file mode 100644 index 67c80d3..0000000 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -part 'empty_theme.g.theme.dart'; - -@themeGen -final class EmptyTheme with _$EmptyTheme { - const EmptyTheme(); - - @override - bool get canMerge => true; - - static EmptyTheme? lerp(EmptyTheme? a, EmptyTheme? b, double t) => - _$EmptyTheme.lerp(a, b, t); -} - -@themeGen -final class EmptyThemeWithoutConstConstructor - with _$EmptyThemeWithoutConstConstructor { - EmptyThemeWithoutConstConstructor(); - - @override - bool get canMerge => true; - - static EmptyThemeWithoutConstConstructor? lerp( - EmptyThemeWithoutConstConstructor? a, - EmptyThemeWithoutConstConstructor? b, - double t, - ) => _$EmptyThemeWithoutConstConstructor.lerp(a, b, t); -} diff --git a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart b/packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart deleted file mode 100644 index d0d704a..0000000 --- a/packages/theme_extensions_builder/test/theme_extensions/empty_theme.g.theme.dart +++ /dev/null @@ -1,128 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, unused_element - -part of 'empty_theme.dart'; - -// ************************************************************************** -// ThemeGenGenerator -// ************************************************************************** - -mixin _$EmptyTheme { - bool get canMerge => true; - - static EmptyTheme? lerp(EmptyTheme? a, EmptyTheme? b, double t) { - if (identical(a, b)) { - return a; - } - - if (a == null) { - return t == 1.0 ? b : null; - } - - if (b == null) { - return t == 0.0 ? a : null; - } - - return const EmptyTheme(); - } - - EmptyTheme copyWith() { - return const EmptyTheme(); - } - - EmptyTheme merge(EmptyTheme? other) { - final _this = (this as EmptyTheme); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - return true; - } - - @override - int get hashCode { - return runtimeType.hashCode; - } -} - -mixin _$EmptyThemeWithoutConstConstructor { - bool get canMerge => true; - - static EmptyThemeWithoutConstConstructor? lerp( - EmptyThemeWithoutConstConstructor? a, - EmptyThemeWithoutConstConstructor? b, - double t, - ) { - if (identical(a, b)) { - return a; - } - - if (a == null) { - return t == 1.0 ? b : null; - } - - if (b == null) { - return t == 0.0 ? a : null; - } - - return EmptyThemeWithoutConstConstructor(); - } - - EmptyThemeWithoutConstConstructor copyWith() { - return EmptyThemeWithoutConstConstructor(); - } - - EmptyThemeWithoutConstConstructor merge( - EmptyThemeWithoutConstConstructor? other, - ) { - final _this = (this as EmptyThemeWithoutConstConstructor); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - return true; - } - - @override - int get hashCode { - return runtimeType.hashCode; - } -} diff --git a/packages/theme_extensions_builder/test/theme_extensions/mock.dart b/packages/theme_extensions_builder/test/theme_extensions/mock.dart deleted file mode 120000 index 15c95ff..0000000 --- a/packages/theme_extensions_builder/test/theme_extensions/mock.dart +++ /dev/null @@ -1 +0,0 @@ -../mock/mock.dart \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart deleted file mode 100644 index b987459..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -import 'mock.dart'; - -part 'empty_theme_extension.g.theme.dart'; - -@themeExtensions -final class EmptyThemeExtension extends ThemeExtension - with _$EmptyThemeExtension { - const EmptyThemeExtension(); -} diff --git a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart b/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart deleted file mode 100644 index fb9aca5..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/empty_theme_extension.g.theme.dart +++ /dev/null @@ -1,52 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, unused_element - -part of 'empty_theme_extension.dart'; - -// ************************************************************************** -// ThemeExtensionsGenerator -// ************************************************************************** - -mixin _$EmptyThemeExtension on ThemeExtension { - @override - ThemeExtension copyWith() { - return const EmptyThemeExtension(); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! EmptyThemeExtension) { - return this; - } - - return const EmptyThemeExtension(); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - return true; - } - - @override - int get hashCode { - return runtimeType.hashCode; - } -} - -extension EmptyThemeExtensionBuildContext on BuildContext { - EmptyThemeExtension get emptyTheme => - Theme.of(this).extension()!; -} diff --git a/packages/theme_extensions_builder/test/theme_gen/mock.dart b/packages/theme_extensions_builder/test/theme_gen/mock.dart deleted file mode 120000 index 15c95ff..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/mock.dart +++ /dev/null @@ -1 +0,0 @@ -../mock/mock.dart \ No newline at end of file diff --git a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart b/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart deleted file mode 100644 index 157e93a..0000000 --- a/packages/theme_extensions_builder/test/theme_gen/widget_state_property_theme.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:source_gen_test/source_gen_test.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -import 'mock.dart'; - -part 'widget_state_property_theme.g.theme.dart'; - -/// Empty Theme - testing edge case with no fields -@ShouldGenerateFile( - 'goldens/widget_state_property_theme.g.theme.dart', - partOfCurrent: true, -) -@themeGen -final class WidgetStatePropertyTheme with _$WidgetStatePropertyTheme { - const WidgetStatePropertyTheme({ - required this.color, - required this.width, - required this.duration, - required this.optionalColor, - required this.optionalWidth, - required this.optionalDuration, - }); - - final WidgetStateProperty color; - final WidgetStateProperty width; - final WidgetStateProperty duration; - - final WidgetStateProperty optionalColor; - final WidgetStateProperty optionalWidth; - final WidgetStateProperty optionalDuration; - - @override - bool get canMerge => true; - - static WidgetStatePropertyTheme? lerp( - WidgetStatePropertyTheme? a, - WidgetStatePropertyTheme? b, - double t, - ) => _$WidgetStatePropertyTheme.lerp(a, b, t); -} diff --git a/packages/theme_extensions_builder_annotation/CHANGELOG.md b/packages/theme_extensions_builder_annotation/CHANGELOG.md index f902214..fba878f 100644 --- a/packages/theme_extensions_builder_annotation/CHANGELOG.md +++ b/packages/theme_extensions_builder_annotation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 7.5.0 + +- **Updated**: Dart SDK constraint to ">=3.13.0 <4.0.0". No API changes; released alongside `theme_extensions_builder` 7.5.0. + ## 7.4.0 - *Updated*: Dependencies. diff --git a/packages/theme_extensions_builder_annotation/README.md b/packages/theme_extensions_builder_annotation/README.md index ad1de4b..5795694 100644 --- a/packages/theme_extensions_builder_annotation/README.md +++ b/packages/theme_extensions_builder_annotation/README.md @@ -21,7 +21,7 @@ Or manually in `pubspec.yaml`: ```yaml dependencies: - theme_extensions_builder_annotation: ^7.4.0 + theme_extensions_builder_annotation: ^7.5.0 ``` **Note**: You also need to add `theme_extensions_builder` as a dev dependency. See the [theme_extensions_builder documentation](https://pub.dev/packages/theme_extensions_builder) for complete setup instructions. diff --git a/packages/theme_extensions_builder_annotation/example/example.md b/packages/theme_extensions_builder_annotation/example/example.md new file mode 100644 index 0000000..e17a12a --- /dev/null +++ b/packages/theme_extensions_builder_annotation/example/example.md @@ -0,0 +1,64 @@ +# Example + +The annotations do nothing on their own: `theme_extensions_builder` reads them +and writes the `copyWith`, `lerp`, `merge`, `==` and `hashCode` members into a +`.g.theme.dart` part file. + +## A theme extension + +```dart +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'app_theme.g.theme.dart'; + +@themeExtensions +class AppTheme extends ThemeExtension with _$AppTheme { + const AppTheme({ + required this.primaryColor, + required this.spacing, + this.borderRadius, + }); + + final Color primaryColor; + final double spacing; + final BorderRadius? borderRadius; +} + +// Generated alongside the mixin: +// final theme = context.appTheme; +``` + +## A plain theme data class + +```dart +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'button_theme_data.g.theme.dart'; + +@themeGen +class ButtonThemeData with _$ButtonThemeData { + const ButtonThemeData({ + required this.backgroundColor, + this.elevation = 2.0, + this.debugLabel = '', + }); + + final Color backgroundColor; + final double elevation; + + /// Left out of every generated member. + @ignore + final String debugLabel; + + static ButtonThemeData? lerp( + ButtonThemeData? a, + ButtonThemeData? b, + double t, + ) => _$ButtonThemeData.lerp(a, b, t); +} +``` + +See the [package README](https://github.com/pro100andrey/theme_extensions_builder/blob/main/packages/theme_extensions_builder/README.md) +for the generator setup and the full list of options. diff --git a/packages/theme_extensions_builder_annotation/pubspec.yaml b/packages/theme_extensions_builder_annotation/pubspec.yaml index b590f74..741a08c 100644 --- a/packages/theme_extensions_builder_annotation/pubspec.yaml +++ b/packages/theme_extensions_builder_annotation/pubspec.yaml @@ -8,7 +8,7 @@ issue_tracker: https://github.com/pro100andrey/theme_extensions_builder/issues homepage: https://github.com/pro100andrey/theme_extensions_builder documentation: https://github.com/pro100andrey/theme_extensions_builder/blob/main/packages/theme_extensions_builder/README.md -version: 7.4.0 +version: 7.5.0 topics: - theme @@ -25,11 +25,13 @@ platforms: windows: environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" + +resolution: workspace dependencies: meta: ^1.16.0 dev_dependencies: - pro_lints: ^6.1.0 + pro_lints: ^6.2.0 test: ^1.31.1 diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..336375f --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,13 @@ +name: theme_extensions_builder_workspace +description: > + Development workspace for theme_extensions_builder. Not published; run + `flutter pub get` here once to resolve every package. +publish_to: none + +environment: + sdk: ">=3.13.0 <4.0.0" + +workspace: + - packages/theme_extensions_builder + - packages/theme_extensions_builder_annotation + - packages/theme_extensions_builder/example diff --git a/scripts/prepare_push.sh b/scripts/prepare_push.sh index 9331666..b59bd05 100755 --- a/scripts/prepare_push.sh +++ b/scripts/prepare_push.sh @@ -49,14 +49,13 @@ function log_warning() { echo -e "${COLOR_YELLOW}[!]${COLOR_RESET} $1" } -function pub_update() { - local package_path=$1 - log_begin "Running 'dart pub update' in $package_path" - if dart pub update --directory "$package_path"; then - log_success "pub update completed for $package_path" +function pub_get() { + log_begin "Running 'flutter pub get' for the workspace" + if flutter pub get; then + log_success "Workspace resolved" return 0 else - log_error "pub update failed for $package_path" + log_error "pub get failed" return 1 fi } @@ -142,7 +141,6 @@ function process_package() { log_info "Processing" "$package_name" local steps=( - "pub_update" "dart_format" "dart_fix" "dart_analyze" @@ -166,9 +164,12 @@ function main() { cd "$PROJECT_ROOT" + # One resolution for every package: they form a pub workspace. + pub_get || return 1 + local dirs=( - "packages/theme_extensions_builder" "packages/theme_extensions_builder_annotation" + "packages/theme_extensions_builder" "packages/theme_extensions_builder/example" )