From 3de4986ce327ffe0fc4a3a9daa870de61735ad59 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Jul 2026 19:32:50 +0300 Subject: [PATCH 1/2] feat(ui): gate theme cross-fade by motion level (#342) Wire Shadcn theme animation duration/curve through QueryaThemeMotion, lerp QueryaTheme tokens via AnimatedQueryaTheme, and honor the preference toggle only when motion is Full/Reduced. Co-authored-by: Cursor --- lib/app/app.dart | 31 ++++- lib/core/motion/querya_theme_motion.dart | 60 +++++++++ lib/core/theme/animated_querya_theme.dart | 54 ++++++++ lib/core/theme/theme_controller.dart | 5 +- .../preferences_appearance_section.dart | 3 +- .../core/motion/querya_theme_motion_test.dart | 101 +++++++++++++++ .../theme/animated_querya_theme_test.dart | 121 ++++++++++++++++++ .../shadcn_theme_animation_wiring_test.dart | 47 +++++++ .../shadcn_flutter/lib/src/shadcn_app.dart | 27 +++- 9 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 lib/core/motion/querya_theme_motion.dart create mode 100644 lib/core/theme/animated_querya_theme.dart create mode 100644 test/core/motion/querya_theme_motion_test.dart create mode 100644 test/core/theme/animated_querya_theme_test.dart create mode 100644 test/core/theme/shadcn_theme_animation_wiring_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 83e0201d..3fec8d08 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -2,7 +2,8 @@ import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/motion/querya_motion_controller.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; -import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/motion/querya_theme_motion.dart'; +import 'package:querya_desktop/core/theme/animated_querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -32,6 +33,26 @@ class QueryaApp extends StatelessWidget { listenable: motionController, builder: (context, _) { final motionLevel = motionController.level; + final disableAnimations = + MediaQuery.maybeOf(context)?.disableAnimations ?? + WidgetsBinding.instance.platformDispatcher + .accessibilityFeatures.disableAnimations; + final themeDuration = QueryaThemeMotion.duration( + preferenceEnabled: themeController.themeAnimationEnabled, + level: motionLevel, + disableAnimations: disableAnimations, + ); + final themeCurve = QueryaThemeMotion.curve( + preferenceEnabled: themeController.themeAnimationEnabled, + level: motionLevel, + disableAnimations: disableAnimations, + ); + final themeAnimEnabled = QueryaThemeMotion.enabled( + preferenceEnabled: themeController.themeAnimationEnabled, + level: motionLevel, + disableAnimations: disableAnimations, + ); + return ShadcnApp( title: 'Querya', theme: themeController.lightShadcnTheme, @@ -39,7 +60,9 @@ class QueryaApp extends StatelessWidget { themeMode: themeController.themeMode, materialTheme: themeController.materialThemeFor(colorScheme), debugShowCheckedModeBanner: false, - enableThemeAnimation: themeController.themeAnimationEnabled, + enableThemeAnimation: themeAnimEnabled, + themeAnimationDuration: themeDuration, + themeAnimationCurve: themeCurve, enableScrollInterception: false, // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. builder: (context, child) { @@ -55,8 +78,10 @@ class QueryaApp extends StatelessWidget { scale, ), ), - child: QueryaThemeScope( + child: AnimatedQueryaTheme( data: queryaTheme, + duration: themeDuration, + curve: themeCurve, child: QueryaMotionScope( level: motionLevel, child: child ?? const SizedBox.shrink(), diff --git a/lib/core/motion/querya_theme_motion.dart b/lib/core/motion/querya_theme_motion.dart new file mode 100644 index 00000000..5e737c70 --- /dev/null +++ b/lib/core/motion/querya_theme_motion.dart @@ -0,0 +1,60 @@ +import 'package:flutter/animation.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_scope.dart'; + +/// Theme cross-fade timing gated by the Preferences toggle and motion level. +abstract final class QueryaThemeMotion { + /// Effective theme transition duration. + /// + /// Returns [QueryaMotion.instant] when the preference is off, motion is + /// [QueryaMotionLevel.off], or OS `disableAnimations` is set. Reduced halves + /// [QueryaMotion.slow]; Full uses [QueryaMotion.slow]. + static Duration duration({ + required bool preferenceEnabled, + required QueryaMotionLevel level, + bool disableAnimations = false, + }) { + if (!preferenceEnabled || + disableAnimations || + level == QueryaMotionLevel.off) { + return QueryaMotion.instant; + } + const token = QueryaMotion.slow; + if (level == QueryaMotionLevel.reduced) { + return Duration(microseconds: token.inMicroseconds ~/ 2); + } + return token; + } + + /// Curve for theme transitions (emphasized when animating). + static Curve curve({ + required bool preferenceEnabled, + required QueryaMotionLevel level, + bool disableAnimations = false, + }) { + if (duration( + preferenceEnabled: preferenceEnabled, + level: level, + disableAnimations: disableAnimations, + ) == + QueryaMotion.instant) { + return Curves.linear; + } + return QueryaMotion.emphasized; + } + + /// Whether [ShadcnAnimatedTheme] / [AnimatedQueryaTheme] should animate. + static bool enabled({ + required bool preferenceEnabled, + required QueryaMotionLevel level, + bool disableAnimations = false, + }) { + return duration( + preferenceEnabled: preferenceEnabled, + level: level, + disableAnimations: disableAnimations, + ) != + QueryaMotion.instant; + } +} diff --git a/lib/core/theme/animated_querya_theme.dart b/lib/core/theme/animated_querya_theme.dart new file mode 100644 index 00000000..25659838 --- /dev/null +++ b/lib/core/theme/animated_querya_theme.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'querya_theme.dart'; +import 'querya_theme_scope.dart'; + +/// Cross-fades [QueryaTheme] tokens (workbench / editor / scheme) over [duration]. +/// +/// When [duration] is [Duration.zero], jumps immediately (motion Off / preference). +class AnimatedQueryaTheme extends ImplicitlyAnimatedWidget { + const AnimatedQueryaTheme({ + super.key, + required this.data, + required super.duration, + super.curve, + required this.child, + }); + + final QueryaTheme data; + final Widget child; + + @override + AnimatedWidgetBaseState createState() => + _AnimatedQueryaThemeState(); +} + +class _AnimatedQueryaThemeState + extends AnimatedWidgetBaseState { + QueryaThemeTween? _data; + + @override + void forEachTween(TweenVisitor visitor) { + _data = visitor( + _data, + widget.data, + (dynamic value) => QueryaThemeTween(begin: value as QueryaTheme), + ) as QueryaThemeTween?; + } + + @override + Widget build(BuildContext context) { + return QueryaThemeScope( + data: _data!.evaluate(animation), + child: widget.child, + ); + } +} + +/// Tween that lerps [QueryaTheme] via [QueryaTheme.lerp]. +class QueryaThemeTween extends Tween { + QueryaThemeTween({super.begin, super.end}); + + @override + QueryaTheme lerp(double t) => QueryaTheme.lerp(begin!, end!, t); +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index ccb78ff9..a156a99d 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -90,7 +90,10 @@ class ThemeController extends ChangeNotifier { ThemeMode get themeMode => _themeMode; - /// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions. + /// Preference toggle for theme cross-fades. + /// + /// [QueryaApp] still gates animation with [QueryaThemeMotion] (motion level / + /// OS disableAnimations). When those disallow motion, themes snap. bool get themeAnimationEnabled => _themeAnimationEnabled; QueryaThemePreset get preset => _preset; diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 8ec618ce..1c0d93b4 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -257,7 +257,8 @@ class _PreferencesAppearanceSectionState ), const material.SizedBox(height: 4), const PreferencesHint( - 'Smooth transitions when switching dark/light or presets. Off by default for stability.', + 'Smooth transitions when switching dark/light or presets. Off by default ' + 'for stability. Also requires Motion Full or Reduced (Off snaps themes).', ), const material.SizedBox(height: 12), PreferencesFieldRow( diff --git a/test/core/motion/querya_theme_motion_test.dart b/test/core/motion/querya_theme_motion_test.dart new file mode 100644 index 00000000..debebac5 --- /dev/null +++ b/test/core/motion/querya_theme_motion_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/core/motion/querya_theme_motion.dart'; + +void main() { + group('QueryaThemeMotion.duration', () { + test('instant when preference disabled', () { + expect( + QueryaThemeMotion.duration( + preferenceEnabled: false, + level: QueryaMotionLevel.full, + ), + QueryaMotion.instant, + ); + }); + + test('instant when motion off', () { + expect( + QueryaThemeMotion.duration( + preferenceEnabled: true, + level: QueryaMotionLevel.off, + ), + QueryaMotion.instant, + ); + }); + + test('instant when OS disables animations', () { + expect( + QueryaThemeMotion.duration( + preferenceEnabled: true, + level: QueryaMotionLevel.full, + disableAnimations: true, + ), + QueryaMotion.instant, + ); + }); + + test('slow for full motion', () { + expect( + QueryaThemeMotion.duration( + preferenceEnabled: true, + level: QueryaMotionLevel.full, + ), + QueryaMotion.slow, + ); + }); + + test('halved slow for reduced motion', () { + expect( + QueryaThemeMotion.duration( + preferenceEnabled: true, + level: QueryaMotionLevel.reduced, + ), + Duration(microseconds: QueryaMotion.slow.inMicroseconds ~/ 2), + ); + }); + }); + + group('QueryaThemeMotion.curve', () { + test('linear when not animating', () { + expect( + QueryaThemeMotion.curve( + preferenceEnabled: false, + level: QueryaMotionLevel.full, + ), + Curves.linear, + ); + }); + + test('emphasized when animating', () { + expect( + QueryaThemeMotion.curve( + preferenceEnabled: true, + level: QueryaMotionLevel.full, + ), + QueryaMotion.emphasized, + ); + }); + }); + + group('QueryaThemeMotion.enabled', () { + test('true only when duration is non-zero', () { + expect( + QueryaThemeMotion.enabled( + preferenceEnabled: true, + level: QueryaMotionLevel.full, + ), + isTrue, + ); + expect( + QueryaThemeMotion.enabled( + preferenceEnabled: true, + level: QueryaMotionLevel.off, + ), + isFalse, + ); + }); + }); +} diff --git a/test/core/theme/animated_querya_theme_test.dart b/test/core/theme/animated_querya_theme_test.dart new file mode 100644 index 00000000..8a2bd286 --- /dev/null +++ b/test/core/theme/animated_querya_theme_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/theme/animated_querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +void main() { + testWidgets('snaps when duration is zero', (tester) async { + await tester.pumpWidget( + AnimatedQueryaTheme( + data: QueryaTheme.darkDefault, + duration: QueryaMotion.instant, + child: Builder( + builder: (context) { + return ColoredBox( + color: context.queryaTheme.colorScheme.background, + child: const SizedBox(width: 10, height: 10), + ); + }, + ), + ), + ); + await tester.pump(); + + expect( + contextBackground(tester), + QueryaTheme.darkDefault.colorScheme.background, + ); + + await tester.pumpWidget( + AnimatedQueryaTheme( + data: QueryaTheme.lightDefault, + duration: QueryaMotion.instant, + child: Builder( + builder: (context) { + return ColoredBox( + color: context.queryaTheme.colorScheme.background, + child: const SizedBox(width: 10, height: 10), + ); + }, + ), + ), + ); + await tester.pump(); + expect( + contextBackground(tester), + QueryaTheme.lightDefault.colorScheme.background, + ); + }); + + testWidgets('lerps mid-flight then settles', (tester) async { + await tester.pumpWidget( + AnimatedQueryaTheme( + data: QueryaTheme.darkDefault, + duration: QueryaMotion.slow, + curve: QueryaMotion.emphasized, + child: Builder( + builder: (context) { + return ColoredBox( + key: const Key('swatch'), + color: context.queryaTheme.colorScheme.background, + child: const SizedBox(width: 10, height: 10), + ); + }, + ), + ), + ); + await tester.pump(); + + await tester.pumpWidget( + AnimatedQueryaTheme( + data: QueryaTheme.lightDefault, + duration: QueryaMotion.slow, + curve: QueryaMotion.emphasized, + child: Builder( + builder: (context) { + return ColoredBox( + key: const Key('swatch'), + color: context.queryaTheme.colorScheme.background, + child: const SizedBox(width: 10, height: 10), + ); + }, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + + final mid = contextBackground(tester); + expect(mid, isNot(QueryaTheme.darkDefault.colorScheme.background)); + expect(mid, isNot(QueryaTheme.lightDefault.colorScheme.background)); + + await tester.pumpAndSettle(); + expect( + contextBackground(tester), + QueryaTheme.lightDefault.colorScheme.background, + ); + }); + + test('QueryaThemeTween lerps via QueryaTheme.lerp', () { + final tween = QueryaThemeTween( + begin: QueryaTheme.darkDefault, + end: QueryaTheme.lightDefault, + ); + final mid = tween.lerp(0.5); + expect( + mid.colorScheme.background, + Color.lerp( + QueryaTheme.darkDefault.colorScheme.background, + QueryaTheme.lightDefault.colorScheme.background, + 0.5, + ), + ); + }); +} + +Color contextBackground(WidgetTester tester) { + final box = tester.widget(find.byType(ColoredBox)); + return box.color; +} diff --git a/test/core/theme/shadcn_theme_animation_wiring_test.dart b/test/core/theme/shadcn_theme_animation_wiring_test.dart new file mode 100644 index 00000000..54d85ace --- /dev/null +++ b/test/core/theme/shadcn_theme_animation_wiring_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +void main() { + Widget wrap(Widget child) { + return Directionality( + textDirection: TextDirection.ltr, + child: child, + ); + } + + testWidgets('ShadcnLayer disables AnimatedTheme when enableThemeAnimation false', + (tester) async { + await tester.pumpWidget( + wrap( + shadcn.ShadcnLayer( + theme: shadcn.ThemeData.dark(), + enableThemeAnimation: false, + child: const SizedBox(width: 8, height: 8), + ), + ), + ); + await tester.pump(); + expect(find.byType(shadcn.AnimatedTheme), findsNothing); + }); + + testWidgets('ShadcnLayer uses provided duration and curve', (tester) async { + await tester.pumpWidget( + wrap( + shadcn.ShadcnLayer( + theme: shadcn.ThemeData.dark(), + enableThemeAnimation: true, + themeAnimationDuration: QueryaMotion.slow, + themeAnimationCurve: QueryaMotion.emphasized, + child: const SizedBox(width: 8, height: 8), + ), + ), + ); + await tester.pump(); + final animated = + tester.widget(find.byType(shadcn.AnimatedTheme)); + expect(animated.duration, QueryaMotion.slow); + expect(animated.curve, QueryaMotion.emphasized); + }); +} diff --git a/third_party/shadcn_flutter/lib/src/shadcn_app.dart b/third_party/shadcn_flutter/lib/src/shadcn_app.dart index 7dc6dc11..d24de55d 100644 --- a/third_party/shadcn_flutter/lib/src/shadcn_app.dart +++ b/third_party/shadcn_flutter/lib/src/shadcn_app.dart @@ -65,6 +65,8 @@ class ShadcnApp extends StatefulWidget { this.tooltipHandler, this.menuHandler, this.enableThemeAnimation = true, + this.themeAnimationDuration, + this.themeAnimationCurve = Curves.linear, }) : routeInformationProvider = null, routeInformationParser = null, routerDelegate = null, @@ -117,6 +119,8 @@ class ShadcnApp extends StatefulWidget { this.tooltipHandler, this.menuHandler, this.enableThemeAnimation = true, + this.themeAnimationDuration, + this.themeAnimationCurve = Curves.linear, }) : assert(routerDelegate != null || routerConfig != null), navigatorObservers = null, navigatorKey = null, @@ -272,6 +276,13 @@ class ShadcnApp extends StatefulWidget { /// Whether to animate theme changes. final bool enableThemeAnimation; + /// Duration for [ShadcnAnimatedTheme]. When null, uses [kDefaultDuration]. + /// Ignored when [enableThemeAnimation] is false (instant). + final Duration? themeAnimationDuration; + + /// Curve for theme transitions. + final Curve themeAnimationCurve; + @override State createState() => _ShadcnAppState(); } @@ -434,6 +445,8 @@ class _ShadcnAppState extends State { menuHandler: widget.menuHandler, themeMode: widget.themeMode, enableThemeAnimation: widget.enableThemeAnimation, + themeAnimationDuration: widget.themeAnimationDuration, + themeAnimationCurve: widget.themeAnimationCurve, child: child, ); } @@ -613,6 +626,12 @@ class ShadcnLayer extends StatelessWidget { /// Whether to animate theme changes. final bool enableThemeAnimation; + /// Duration for theme animation when [enableThemeAnimation] is true. + final Duration? themeAnimationDuration; + + /// Curve for theme animation. + final Curve themeAnimationCurve; + /// Creates a shadcn layer. const ShadcnLayer({ super.key, @@ -630,6 +649,8 @@ class ShadcnLayer extends StatelessWidget { this.tooltipHandler, this.menuHandler, this.enableThemeAnimation = true, + this.themeAnimationDuration, + this.themeAnimationCurve = Curves.linear, }); @override @@ -643,6 +664,9 @@ class ShadcnLayer extends StatelessWidget { platformBrightness == Brightness.dark) ? appScaling.scale(darkTheme ?? theme) : appScaling.scale(theme); + final themeDuration = enableThemeAnimation + ? (themeAnimationDuration ?? kDefaultDuration) + : Duration.zero; return OverlayManagerLayer( menuHandler: menuHandler ?? (mobileMode @@ -657,7 +681,8 @@ class ShadcnLayer extends StatelessWidget { ? const FixedTooltipOverlayHandler() : const PopoverOverlayHandler()), child: ShadcnAnimatedTheme( - duration: kDefaultDuration, + duration: themeDuration, + curve: themeAnimationCurve, data: scaledTheme, child: Builder(builder: (context) { var theme = Theme.of(context); From 3d3e294d31032d52be81336900cf3b3ad03c7748 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Jul 2026 19:34:45 +0300 Subject: [PATCH 2/2] fix(test): const ShadcnLayer fixtures for theme motion CI Satisfy prefer_const_constructors so flutter analyze exits cleanly. Co-authored-by: Cursor --- test/core/theme/shadcn_theme_animation_wiring_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/core/theme/shadcn_theme_animation_wiring_test.dart b/test/core/theme/shadcn_theme_animation_wiring_test.dart index 54d85ace..02565b6e 100644 --- a/test/core/theme/shadcn_theme_animation_wiring_test.dart +++ b/test/core/theme/shadcn_theme_animation_wiring_test.dart @@ -15,10 +15,10 @@ void main() { (tester) async { await tester.pumpWidget( wrap( - shadcn.ShadcnLayer( + const shadcn.ShadcnLayer( theme: shadcn.ThemeData.dark(), enableThemeAnimation: false, - child: const SizedBox(width: 8, height: 8), + child: SizedBox(width: 8, height: 8), ), ), ); @@ -29,12 +29,12 @@ void main() { testWidgets('ShadcnLayer uses provided duration and curve', (tester) async { await tester.pumpWidget( wrap( - shadcn.ShadcnLayer( + const shadcn.ShadcnLayer( theme: shadcn.ThemeData.dark(), enableThemeAnimation: true, themeAnimationDuration: QueryaMotion.slow, themeAnimationCurve: QueryaMotion.emphasized, - child: const SizedBox(width: 8, height: 8), + child: SizedBox(width: 8, height: 8), ), ), );