diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b78b76e..e4d927d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,17 +14,22 @@ "a11y", "accessibility", "analyze", + "animations", "architecture", "bloc", "bloc-test", "code-quality", "create-project", "dart", + "dart-sdk", "deep-linking", + "easing", "flutter", + "flutter-sdk", "format", "go-router", "golden-testing", + "hero", "hooks", "i18n", "internationalization", @@ -33,24 +38,25 @@ "lint", "localization", "material-3", + "material-motion", "mcp", "mobile-security", "mocktail", "monorepo", + "motion", "navigation", "owasp", + "page-transitions", "screen-reader", + "sdk-upgrade", "security", "semantics", "state-management", "testing", "theming", - "very-good-cli", "ui-package", + "very-good-cli", "wcag", - "widget-testing", - "sdk-upgrade", - "dart-sdk", - "flutter-sdk" + "widget-testing" ] } \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2f6192a..6aa9b5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,12 @@ hooks/ skills/ accessibility/SKILL.md accessibility/reference.md + animations/SKILL.md + animations/references/ + explicit-animations.md + looping-animations.md + page-transitions.md + staggered-animations.md bloc/SKILL.md bloc/reference.md create-project/SKILL.md diff --git a/README.md b/README.md index 1d8240d..a606886 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ For more details, see the [Very Good Claude Marketplace][marketplace_link]. | Skill | Description | | ----- | ----------- | | [**Create Project**](skills/create-project/SKILL.md) | Scaffold new Dart/Flutter projects from Very Good CLI templates — `flutter_app`, `dart_package`, `flutter_plugin`, `dart_cli`, `flame_game`, and more | +| [**Animations**](skills/animations/SKILL.md) | Flutter built-in animations — implicit vs explicit decision tree, Material 3 motion tokens (`Durations`, `Easing`), page transitions with GoRouter, Hero animations, staggered animations, and performance guidelines | | [**Accessibility**](skills/accessibility/SKILL.md) | WCAG 2.1 AA compliance — semantics, screen reader support, touch targets, focus management, color contrast, text scaling, and motion sensitivity | | [**Testing**](skills/testing/SKILL.md) | Unit, widget, and golden testing — `mocktail` mocking, `pumpApp` helpers, test structure & naming, coverage patterns, and `dart_test.yaml` configuration | | [**Navigation**](skills/navigation/SKILL.md) | GoRouter routing — `@TypedGoRoute` type-safe routes, deep linking, redirects, shell routes, and widget testing with `MockGoRouter` | @@ -69,6 +70,7 @@ You can also invoke skills directly as slash commands: ```bash /vgv-create-project +/vgv-animations /vgv-accessibility /vgv-bloc /vgv-internationalization diff --git a/config/cspell.json b/config/cspell.json index a7fd875..3cc699b 100644 --- a/config/cspell.json +++ b/config/cspell.json @@ -9,6 +9,7 @@ "dismissable", "elemento", "elementos", + "extralong", "formz", "freerasp", "frontmatter", @@ -28,6 +29,8 @@ "pubspec", "serialization", "stdio", + "subclassing", + "vsync", "WCAG", "widgetbook", "Widgetbook", diff --git a/skills/animations/SKILL.md b/skills/animations/SKILL.md new file mode 100644 index 0000000..bed37e2 --- /dev/null +++ b/skills/animations/SKILL.md @@ -0,0 +1,359 @@ +--- +name: vgv-animations +description: Best practices for Flutter animations using the built-in animation framework. Use when creating, modifying, or reviewing animations, transitions, motion, or animated widgets. Covers implicit animations, explicit animations, page transitions, and Material 3 motion tokens. +allowed-tools: Read,Glob,Grep +argument-hint: "[file-or-directory]" +--- + +# Animations + +Flutter animation best practices using the built-in animation framework and Material 3 motion guidelines. No third-party animation libraries (Lottie, Rive, etc.). + +## Core Standards + +Apply these standards to ALL animation work: + +- **Clarify visual intent when the request is ambiguous** — when the developer says "add an animation" or "make it smoother" without specifying property, trigger, duration, or curve, ask before writing code. If the developer provides clear specs (e.g., "300ms ease-in fade on the card when it appears"), proceed directly +- **Use the simplest animation approach that works** — follow the decision tree below; never reach for `AnimationController` when an implicit animation suffices +- **Use Material 3 motion tokens for duration and easing** — never hardcode arbitrary `Duration` or `Curve` values +- **Extract animation constants** — durations, curves, and offsets go in named constants or a centralized `AppMotion` class, not inline +- **Dispose controllers** — every `AnimationController` must be disposed in the `dispose()` method of the `State` +- **Use `SingleTickerProviderStateMixin` for one controller** — use `TickerProviderStateMixin` only when the widget owns multiple controllers +- **Keep animated subtrees small** — wrap only the widgets that change inside the animation builder, not entire widget trees +- **Never animate layout-triggering properties in a tight loop** — animating `width`/`height` on complex layouts causes expensive rebuilds; prefer `Transform` or `Opacity` which operate on the compositing layer + +--- + +## Animation Decision Tree + +Choose the simplest approach that meets the requirement: + +```text +Does the widget rebuild when the value changes? + | + YES --> Does the framework provide an AnimatedFoo widget? + | | + | YES --> Use the implicit AnimatedFoo widget + | | (AnimatedContainer, AnimatedOpacity, AnimatedAlign, etc.) + | | + | NO --> Use TweenAnimationBuilder + | + NO --> Do you need fine-grained control? + (repeat, reverse, sequence, listen to status) + | + YES --> Use AnimationController + AnimatedBuilder + | + NO --> Use TweenAnimationBuilder +``` + +**Rule of thumb:** if the animation is "set a target and let it animate there", use implicit. If the animation must play/pause/reverse/repeat on command, use explicit. + +--- + +## Material 3 Motion Tokens + +Use Flutter's built-in `Durations` and `Easing` classes — never hardcode `Duration(milliseconds: ...)` or use `Curves.*` for new code. The framework constants align with the Material 3 motion specification; refer to the Flutter `Durations` and `Easing` class documentation for the full token list. + +### Centralized Motion Constants + +Introduce an `AppMotion` class when the project uses animations across multiple features. For a single animation in the app, inline M3 tokens are sufficient. + +```dart +abstract class AppMotion { + // Standard transitions + static const Duration standardDuration = Durations.medium2; + static const Curve standardCurve = Easing.standard; + + // Page transitions + static const Duration pageDuration = Durations.medium4; + static const Curve pageEnterCurve = Easing.emphasizedDecelerate; + static const Curve pageExitCurve = Easing.emphasizedAccelerate; + + // Fades + static const Duration fadeDuration = Durations.short3; + static const Curve fadeCurve = Easing.standard; +} +``` + +--- + +## Implicit Animations + +Use implicit animations when the widget rebuilds with new target values. The framework interpolates automatically. Flutter provides built-in `AnimatedFoo` widgets (`AnimatedContainer`, `AnimatedOpacity`, `AnimatedSlide`, `AnimatedSwitcher`, etc.) — use the one that matches the property being animated. When no built-in widget exists, use `TweenAnimationBuilder`. + +--- + +## TweenAnimationBuilder + +Use `TweenAnimationBuilder` when no built-in `AnimatedFoo` widget exists for your property, but you still want implicit-style "set and forget" animation. + +```dart +TweenAnimationBuilder( + tween: Tween(begin: 0, end: isActive ? 1.0 : 0.0), + duration: Durations.medium2, + curve: Easing.standard, + builder: (context, value, child) { + return Transform.scale( + scale: 0.8 + (0.2 * value), + child: Opacity( + opacity: value, + child: child, + ), + ); + }, + child: child, // child is not rebuilt — optimization +) +``` + +The `child` parameter is critical: pass widgets that do not depend on the animated value to avoid unnecessary rebuilds. + +--- + +## Explicit Animations + +Use explicit animations when you need control over playback: play, pause, reverse, repeat, or listen to animation status. + +### AnimationController Setup + +```dart +class _MyWidgetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: Durations.medium2, + vsync: this, + ); + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Easing.standard, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _fadeAnimation, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: child, + ); + }, + child: child, // static child — not rebuilt each frame + ); + } +} +``` + +See [references/explicit-animations.md](references/explicit-animations.md) for `didUpdateWidget` patterns, constructor injection for testable controllers, and transition widget vs `AnimatedBuilder` guidance. + +### Chained Animations with Intervals + +Use `Interval` inside `CurvedAnimation` to sequence animations on a single controller: + +```dart +late final Animation _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.5, curve: Easing.standard), +); + +late final Animation _slideAnimation = Tween( + begin: const Offset(0, 0.25), + end: Offset.zero, +).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.2, 0.8, curve: Easing.emphasized), + ), +); +``` + +See [references/staggered-animations.md](references/staggered-animations.md) for full staggered entry and staggered list examples. See [references/looping-animations.md](references/looping-animations.md) for repeating and pulse animation patterns. + +--- + +## Page Transitions + +Custom page transitions integrate with GoRouter via `CustomTransitionPage` in `GoRouteData.buildPage`. + +```dart +@override +Page buildPage(BuildContext context, GoRouterState state) { + return CustomTransitionPage( + key: state.pageKey, + child: const DetailsPage(), + transitionDuration: Durations.medium4, + reverseTransitionDuration: Durations.medium4, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Easing.emphasizedDecelerate, + ), + child: child, + ); + }, + ); +} +``` + +See [references/page-transitions.md](references/page-transitions.md) for a reusable `AppPageTransitions` helper class with fade, slide-fade, and slide-up transitions, and usage with `GoRouteData`. + +### Hero Animations + +Use `Hero` for shared-element transitions between routes. The framework handles the animation automatically. + +```dart +// Source screen +Hero( + tag: 'product-image-${product.id}', + child: Image.network(product.imageUrl), +) + +// Destination screen +Hero( + tag: 'product-image-${product.id}', + child: Image.network(product.imageUrl), +) +``` + +Rules for Hero: + +- **Tags must be unique within each route** — use meaningful identifiers, not indices +- **Both source and destination must be visible during the transition** — Hero does not work with lazy lists that remove the source widget +- **Wrap only the visual element** — not the entire card or list tile + +--- + +## Performance + +### Do + +- **Animate `Transform` and `Opacity`** — these operate on the compositing layer and skip layout/paint +- **Use the `child` parameter** in `AnimatedBuilder` and `TweenAnimationBuilder` to avoid rebuilding static widgets every frame +- **Use `RepaintBoundary`** around animated widgets in complex layouts to isolate repaints + +### Do Not + +- **Do not animate `width`, `height`, or `padding` on complex layouts** — triggers expensive layout recalculations every frame +- **Do not wrap entire screens in `AnimatedBuilder`** — only wrap the subtree that changes +- **Do not create multiple `AnimationController` instances for animations that share timing** — use `Interval` on a single controller + +--- + +## Anti-Patterns + +### Hardcoded magic values + +```dart +// Bad — arbitrary values with no semantic meaning +AnimatedContainer( + duration: Duration(milliseconds: 375), + curve: Curves.easeInOutCubic, + // ... +) + +// Good — M3 tokens with clear intent +AnimatedContainer( + duration: Durations.medium2, + curve: Easing.standard, + // ... +) +``` + +### Missing controller disposal + +```dart +// Bad — memory leak +@override +void dispose() { + super.dispose(); +} + +// Good — dispose before super.dispose() +@override +void dispose() { + _controller.dispose(); + super.dispose(); +} +``` + +### Rebuilding static children every frame + +```dart +// Bad — entire subtree rebuilds 60 times/second +AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _controller.value, + child: const ExpensiveWidget(), // rebuilt every frame + ); + }, +) + +// Good — static child passed through +AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _controller.value, + child: child, + ); + }, + child: const ExpensiveWidget(), // built once +) +``` + +### Using explicit when implicit suffices + +```dart +// Bad — unnecessary complexity for a simple target-value animation +class _FadeWidgetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + // ... 20+ lines of boilerplate + +// Good — one widget, zero boilerplate +AnimatedOpacity( + duration: Durations.short3, + curve: Easing.standard, + opacity: isVisible ? 1.0 : 0.0, + child: child, +) +``` + +--- + +## Quick Reference + +| Approach | When to Use | +| ------------------------- | -------------------------------------------- | +| `AnimatedFoo` | Built-in widget exists for the property | +| `TweenAnimationBuilder` | Custom property, no playback control needed | +| `AnimationController` | Need play/pause/reverse/repeat/status | +| `Hero` | Shared-element transition between routes | +| `CustomTransitionPage` | Custom GoRouter page transition | + +| Mixin | When to Use | +| -------------------------------- | ---------------------------------- | +| `SingleTickerProviderStateMixin` | Widget owns exactly one controller | +| `TickerProviderStateMixin` | Widget owns multiple controllers | + +## Additional Resources + +- [references/explicit-animations.md](references/explicit-animations.md) — `didUpdateWidget`, testable controllers, transition widgets vs `AnimatedBuilder` +- [references/staggered-animations.md](references/staggered-animations.md) — staggered entry animations and staggered list items +- [references/page-transitions.md](references/page-transitions.md) — reusable `AppPageTransitions` helper and GoRouter integration +- [references/looping-animations.md](references/looping-animations.md) — repeating, pulsing, and continuous rotation patterns diff --git a/skills/animations/references/explicit-animations.md b/skills/animations/references/explicit-animations.md new file mode 100644 index 0000000..760c987 --- /dev/null +++ b/skills/animations/references/explicit-animations.md @@ -0,0 +1,141 @@ +# Explicit Animation Patterns + +Detailed patterns for `AnimationController`-based animations. See the main skill file for core setup and standards. + +## Responding to Widget Updates + +Use `didUpdateWidget` to start, stop, or reverse an animation when a property changes: + +```dart +class _AnimatedGlowState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: Durations.long2, + vsync: this, + ); + if (widget.isGlowing) { + _controller.repeat(reverse: true); + } + } + + @override + void didUpdateWidget(AnimatedGlow oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isGlowing != oldWidget.isGlowing) { + if (widget.isGlowing) { + _controller.repeat(reverse: true); + } else { + _controller.stop(); + _controller.reset(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + // ... +} +``` + +Do not start animations in `build()`. Use `initState` for initial playback and `didUpdateWidget` for subsequent state changes. + +## Constructor Injection for Testable Controllers + +Expose an optional controller parameter to allow tests to drive the animation directly: + +```dart +class PulsingDot extends StatefulWidget { + const PulsingDot({ + required this.isActive, + super.key, + @visibleForTesting this.controller, + }); + + final bool isActive; + + @visibleForTesting + final AnimationController? controller; + + @override + State createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + bool _ownsController = false; + + @override + void initState() { + super.initState(); + if (widget.controller != null) { + _controller = widget.controller!; + } else { + _ownsController = true; + _controller = AnimationController( + duration: Durations.long2, + vsync: this, + ); + } + } + + @override + void dispose() { + if (_ownsController) { + _controller.dispose(); + } + super.dispose(); + } + + // ... +} +``` + +Only dispose the controller if the widget created it. Tests that inject a controller are responsible for its lifecycle. + +## Transition Widgets vs AnimatedBuilder + +**Single property** — use the built-in transition widget directly. Less code, same performance: + +```dart +// Good — single property, use the transition widget +FadeTransition( + opacity: _fadeAnimation, + child: child, +) +``` + +**Multiple properties combined** — use `AnimatedBuilder` to compose them in one builder: + +```dart +// Good — multiple properties, use AnimatedBuilder +AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: SlideTransition( + position: _slideAnimation, + child: child, + ), + ); + }, + child: child, +) +``` + +| Scenario | Use | +| --------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Animate one property (opacity, position, scale, rotation) | `FadeTransition`, `SlideTransition`, `ScaleTransition`, `RotationTransition` | +| Animate multiple properties together | `AnimatedBuilder` with manual composition | + +**Avoid subclassing `AnimatedWidget`** — couples the animation to a specific widget class, making reuse harder. diff --git a/skills/animations/references/looping-animations.md b/skills/animations/references/looping-animations.md new file mode 100644 index 0000000..748e6c6 --- /dev/null +++ b/skills/animations/references/looping-animations.md @@ -0,0 +1,59 @@ +# Repeating and Looping Animations + +## Continuous Rotation (Loading Indicator) + +```dart +class _SpinnerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.rotate( + angle: _controller.value * 2 * pi, + child: child, + ); + }, + child: const Icon(Icons.refresh), + ); + } +} +``` + +## Pulse Animation (Repeat with Reverse) + +```dart +@override +void initState() { + super.initState(); + _controller = AnimationController( + duration: Durations.long2, + vsync: this, + )..repeat(reverse: true); + + _scaleAnimation = Tween(begin: 1.0, end: 1.05).animate( + CurvedAnimation( + parent: _controller, + curve: Easing.standard, + ), + ); +} +``` diff --git a/skills/animations/references/page-transitions.md b/skills/animations/references/page-transitions.md new file mode 100644 index 0000000..bede3a2 --- /dev/null +++ b/skills/animations/references/page-transitions.md @@ -0,0 +1,109 @@ +# Page Transition Patterns for GoRouter + +## Shared Transition Helper + +Create a reusable transition builder to maintain consistency across routes: + +```dart +abstract class AppPageTransitions { + static CustomTransitionPage fade({ + required LocalKey key, + required Widget child, + }) { + return CustomTransitionPage( + key: key, + child: child, + transitionDuration: Durations.medium4, + reverseTransitionDuration: Durations.medium4, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Easing.emphasizedDecelerate, + ), + child: child, + ); + }, + ); + } + + static CustomTransitionPage slideFade({ + required LocalKey key, + required Widget child, + }) { + return CustomTransitionPage( + key: key, + child: child, + transitionDuration: Durations.medium4, + reverseTransitionDuration: Durations.medium4, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Easing.emphasizedDecelerate, + ); + return FadeTransition( + opacity: curvedAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.05, 0), + end: Offset.zero, + ).animate(curvedAnimation), + child: child, + ), + ); + }, + ); + } + + static CustomTransitionPage slideUp({ + required LocalKey key, + required Widget child, + }) { + return CustomTransitionPage( + key: key, + child: child, + transitionDuration: Durations.medium4, + reverseTransitionDuration: Durations.medium4, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Easing.emphasizedDecelerate, + ); + return FadeTransition( + opacity: curvedAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(curvedAnimation), + child: child, + ), + ); + }, + ); + } +} +``` + +## Using Transitions in GoRouteData + +```dart +@TypedGoRoute( + name: 'details', + path: 'details/:id', +) +@immutable +class DetailsPageRoute extends GoRouteData { + const DetailsPageRoute({required this.id}); + + final String id; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return AppPageTransitions.slideFade( + key: state.pageKey, + child: DetailsPage(id: id), + ); + } +} +``` diff --git a/skills/animations/references/staggered-animations.md b/skills/animations/references/staggered-animations.md new file mode 100644 index 0000000..a1ad8a8 --- /dev/null +++ b/skills/animations/references/staggered-animations.md @@ -0,0 +1,173 @@ +# Staggered Animations + +Stagger multiple animations on a single controller using `Interval`. Each interval defines the fraction of the controller's duration during which the animation is active. + +## Enter Animation with Staggered Fade + Slide + Scale + +```dart +class _StaggeredEntryState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _fadeAnimation; + late final Animation _slideAnimation; + late final Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: Durations.long2, + vsync: this, + ); + + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: const Interval(0.0, 0.6, curve: Easing.standard), + ); + + _slideAnimation = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.1, 0.7, curve: Easing.emphasized), + ), + ); + + _scaleAnimation = Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation( + parent: _controller, + curve: const Interval(0.1, 0.7, curve: Easing.emphasized), + ), + ); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: SlideTransition( + position: _slideAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: child, + ), + ), + ); + }, + child: widget.child, + ); + } +} +``` + +## Staggered List Items + +Animate list items sequentially by offsetting each item's delay: + +```dart +class StaggeredListItem extends StatefulWidget { + const StaggeredListItem({ + required this.index, + required this.itemCount, + required this.animation, + required this.child, + super.key, + }); + + final int index; + final int itemCount; + final Animation animation; + final Widget child; + + @override + State createState() => _StaggeredListItemState(); +} + +class _StaggeredListItemState extends State { + late final CurvedAnimation _curvedAnimation; + + @override + void initState() { + super.initState(); + final start = (widget.index / widget.itemCount).clamp(0.0, 1.0); + final end = ((widget.index + 1) / widget.itemCount).clamp(0.0, 1.0); + _curvedAnimation = CurvedAnimation( + parent: widget.animation, + curve: Interval(start, end, curve: Easing.emphasized), + ); + } + + @override + void dispose() { + _curvedAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _curvedAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(_curvedAnimation), + child: widget.child, + ), + ); + } +} +``` + +Usage with a parent controller: + +```dart +class _StaggeredListState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: Durations.extralong2, + vsync: this, + )..forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final items = widget.items; + return Column( + children: [ + for (var i = 0; i < items.length; i++) + StaggeredListItem( + index: i, + itemCount: items.length, + animation: _controller, + child: items[i], + ), + ], + ); + } +} +``` diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md index 3926455..42cd05f 100644 --- a/skills/testing/SKILL.md +++ b/skills/testing/SKILL.md @@ -473,3 +473,4 @@ Always call `pump()` (or `pumpAndSettle()`) after every interaction — widgets - [references/matchers.md](references/matchers.md) — matchers quick reference - [references/configuration.md](references/configuration.md) — `dart_test.yaml` configuration (tags, commands, platform overrides) - [references/coverage.md](references/coverage.md) — coverage patterns and package/imports reference +- [references/animation-testing.md](references/animation-testing.md) — testing implicit/explicit animations, AnimatedSwitcher, page transitions, and injected controllers diff --git a/skills/testing/references/animation-testing.md b/skills/testing/references/animation-testing.md new file mode 100644 index 0000000..21cfe21 --- /dev/null +++ b/skills/testing/references/animation-testing.md @@ -0,0 +1,214 @@ +# Animation Testing + +Testing patterns specific to Flutter animations. See the [Animations skill](../../animations/SKILL.md) for core animation standards and the decision tree. + +## Testing Implicit Animations + +Use `pump` with specific durations to verify intermediate and final states: + +```dart +testWidgets('card fades in when visible', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: FadingCard(isVisible: false), + ), + ); + + // Verify initial state + final opacity = tester.widget( + find.byType(AnimatedOpacity), + ); + expect(opacity.opacity, 0.0); + + // Trigger animation + await tester.pumpWidget( + const MaterialApp( + home: FadingCard(isVisible: true), + ), + ); + + // Verify target is set + final updatedOpacity = tester.widget( + find.byType(AnimatedOpacity), + ); + expect(updatedOpacity.opacity, 1.0); + + // Let animation complete + await tester.pumpAndSettle(); + + // Verify final rendered state + final renderOpacity = tester.renderObject( + find.byType(AnimatedOpacity), + ); + expect(renderOpacity.opacity.value, 1.0); +}); +``` + +## Testing Explicit Animations + +Use `pump` with frame durations to verify animation progress: + +```dart +testWidgets('spinner rotates continuously', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Spinner()), + ); + + // Capture initial transform + final initialTransform = tester.widget( + find.byType(Transform), + ); + + // Advance one frame + await tester.pump(const Duration(milliseconds: 16)); + + // Verify rotation has progressed + final updatedTransform = tester.widget( + find.byType(Transform), + ); + expect(updatedTransform.transform, isNot(initialTransform.transform)); +}); +``` + +## Testing AnimatedSwitcher + +```dart +testWidgets('content cross-fades on state change', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: CounterDisplay(count: 0), + ), + ); + + expect(find.text('0'), findsOneWidget); + + await tester.pumpWidget( + const MaterialApp( + home: CounterDisplay(count: 1), + ), + ); + + // During cross-fade, both widgets exist + await tester.pump(Durations.medium2 ~/ 2); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + + // After animation completes, only new widget remains + await tester.pumpAndSettle(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); +}); +``` + +## Testing Page Transitions + +```dart +testWidgets('details page slides in from right', (tester) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const HomePage(), + ), + GoRoute( + path: '/details', + pageBuilder: (context, state) => AppPageTransitions.slideFade( + key: state.pageKey, + child: const DetailsPage(), + ), + ), + ], + ); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: router), + ); + + router.go('/details'); + await tester.pump(); + await tester.pump(Durations.medium4 ~/ 2); + + // Verify transition is in progress — details page exists but not settled + expect(find.byType(DetailsPage), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.byType(DetailsPage), findsOneWidget); + expect(find.byType(HomePage), findsNothing); +}); +``` + +## Testing with Injected Controllers + +When a widget exposes an optional `@visibleForTesting` controller parameter, inject a controller in tests to drive animations directly: + +```dart +testWidgets('pulsing dot scales when active', (tester) async { + final controller = AnimationController( + duration: Durations.long2, + vsync: const TestVSync(), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: PulsingDot( + isActive: true, + controller: controller, + ), + ), + ); + + // Drive animation to midpoint + controller.value = 0.5; + await tester.pump(); + + final transform = tester.widget(find.byType(Transform)); + // Verify scale is between start and end + expect(transform.transform.getMaxScaleOnAxis(), greaterThan(1.0)); + + // Drive to completion + controller.value = 1.0; + await tester.pump(); + + final finalTransform = tester.widget(find.byType(Transform)); + expect(finalTransform.transform.getMaxScaleOnAxis(), closeTo(1.05, 0.01)); +}); +``` + +Verify calls on the controller using `mocktail` when you need to assert `forward()`, `reverse()`, or `repeat()` were called: + +```dart +class MockAnimationController extends Mock implements AnimationController {} + +testWidgets('stops animation when isActive becomes false', (tester) async { + final controller = MockAnimationController(); + when(() => controller.repeat(reverse: true)).thenReturn(); + when(() => controller.stop()).thenReturn(); + when(() => controller.reset()).thenReturn(); + when(() => controller.value).thenReturn(0.0); + when(() => controller.status).thenReturn(AnimationStatus.dismissed); + + await tester.pumpWidget( + MaterialApp( + home: PulsingDot(isActive: true, controller: controller), + ), + ); + verify(() => controller.repeat(reverse: true)).called(1); + + await tester.pumpWidget( + MaterialApp( + home: PulsingDot(isActive: false, controller: controller), + ), + ); + verify(() => controller.stop()).called(1); + verify(() => controller.reset()).called(1); +}); +``` + +## Animation Testing Tips + +- **Use `pumpAndSettle()`** to let all animations complete — but set a timeout for infinite animations: `await tester.pumpAndSettle(const Duration(seconds: 5))` +- **Use `pump(duration)`** to advance to a specific point in an animation for intermediate state verification +- **Never test exact pixel values for transforms** — test direction and completion instead +- **For repeating animations, do not use `pumpAndSettle()`** — it will time out. Use `pump` with explicit durations instead