Skip to content

Commit d27018e

Browse files
Merge pull request #352 from QueryaHub/issue/342-theme-motion-level
feat(ui): theme animation tied to motion level (#342)
2 parents 071432f + cf9b071 commit d27018e

9 files changed

Lines changed: 443 additions & 6 deletions

File tree

‎lib/app/app.dart‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import 'package:querya_desktop/core/layout/ui_scale.dart';
22
import 'package:querya_desktop/core/layout/ui_scale_controller.dart';
33
import 'package:querya_desktop/core/motion/querya_motion_controller.dart';
44
import 'package:querya_desktop/core/motion/querya_motion_scope.dart';
5-
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
5+
import 'package:querya_desktop/core/motion/querya_theme_motion.dart';
6+
import 'package:querya_desktop/core/theme/animated_querya_theme.dart';
67
import 'package:querya_desktop/core/theme/theme_controller.dart';
78
import 'package:shadcn_flutter/shadcn_flutter.dart';
89

@@ -32,14 +33,36 @@ class QueryaApp extends StatelessWidget {
3233
listenable: motionController,
3334
builder: (context, _) {
3435
final motionLevel = motionController.level;
36+
final disableAnimations =
37+
MediaQuery.maybeOf(context)?.disableAnimations ??
38+
WidgetsBinding.instance.platformDispatcher
39+
.accessibilityFeatures.disableAnimations;
40+
final themeDuration = QueryaThemeMotion.duration(
41+
preferenceEnabled: themeController.themeAnimationEnabled,
42+
level: motionLevel,
43+
disableAnimations: disableAnimations,
44+
);
45+
final themeCurve = QueryaThemeMotion.curve(
46+
preferenceEnabled: themeController.themeAnimationEnabled,
47+
level: motionLevel,
48+
disableAnimations: disableAnimations,
49+
);
50+
final themeAnimEnabled = QueryaThemeMotion.enabled(
51+
preferenceEnabled: themeController.themeAnimationEnabled,
52+
level: motionLevel,
53+
disableAnimations: disableAnimations,
54+
);
55+
3556
return ShadcnApp(
3657
title: 'Querya',
3758
theme: themeController.lightShadcnTheme,
3859
darkTheme: themeController.darkShadcnTheme,
3960
themeMode: themeController.themeMode,
4061
materialTheme: themeController.materialThemeFor(colorScheme),
4162
debugShowCheckedModeBanner: false,
42-
enableThemeAnimation: themeController.themeAnimationEnabled,
63+
enableThemeAnimation: themeAnimEnabled,
64+
themeAnimationDuration: themeDuration,
65+
themeAnimationCurve: themeCurve,
4366
enableScrollInterception: false,
4467
// Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens.
4568
builder: (context, child) {
@@ -55,8 +78,10 @@ class QueryaApp extends StatelessWidget {
5578
scale,
5679
),
5780
),
58-
child: QueryaThemeScope(
81+
child: AnimatedQueryaTheme(
5982
data: queryaTheme,
83+
duration: themeDuration,
84+
curve: themeCurve,
6085
child: QueryaMotionScope(
6186
level: motionLevel,
6287
child: child ?? const SizedBox.shrink(),
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'package:flutter/animation.dart';
2+
3+
import 'querya_motion.dart';
4+
import 'querya_motion_scope.dart';
5+
6+
/// Theme cross-fade timing gated by the Preferences toggle and motion level.
7+
abstract final class QueryaThemeMotion {
8+
/// Effective theme transition duration.
9+
///
10+
/// Returns [QueryaMotion.instant] when the preference is off, motion is
11+
/// [QueryaMotionLevel.off], or OS `disableAnimations` is set. Reduced halves
12+
/// [QueryaMotion.slow]; Full uses [QueryaMotion.slow].
13+
static Duration duration({
14+
required bool preferenceEnabled,
15+
required QueryaMotionLevel level,
16+
bool disableAnimations = false,
17+
}) {
18+
if (!preferenceEnabled ||
19+
disableAnimations ||
20+
level == QueryaMotionLevel.off) {
21+
return QueryaMotion.instant;
22+
}
23+
const token = QueryaMotion.slow;
24+
if (level == QueryaMotionLevel.reduced) {
25+
return Duration(microseconds: token.inMicroseconds ~/ 2);
26+
}
27+
return token;
28+
}
29+
30+
/// Curve for theme transitions (emphasized when animating).
31+
static Curve curve({
32+
required bool preferenceEnabled,
33+
required QueryaMotionLevel level,
34+
bool disableAnimations = false,
35+
}) {
36+
if (duration(
37+
preferenceEnabled: preferenceEnabled,
38+
level: level,
39+
disableAnimations: disableAnimations,
40+
) ==
41+
QueryaMotion.instant) {
42+
return Curves.linear;
43+
}
44+
return QueryaMotion.emphasized;
45+
}
46+
47+
/// Whether [ShadcnAnimatedTheme] / [AnimatedQueryaTheme] should animate.
48+
static bool enabled({
49+
required bool preferenceEnabled,
50+
required QueryaMotionLevel level,
51+
bool disableAnimations = false,
52+
}) {
53+
return duration(
54+
preferenceEnabled: preferenceEnabled,
55+
level: level,
56+
disableAnimations: disableAnimations,
57+
) !=
58+
QueryaMotion.instant;
59+
}
60+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import 'package:flutter/material.dart';
2+
3+
import 'querya_theme.dart';
4+
import 'querya_theme_scope.dart';
5+
6+
/// Cross-fades [QueryaTheme] tokens (workbench / editor / scheme) over [duration].
7+
///
8+
/// When [duration] is [Duration.zero], jumps immediately (motion Off / preference).
9+
class AnimatedQueryaTheme extends ImplicitlyAnimatedWidget {
10+
const AnimatedQueryaTheme({
11+
super.key,
12+
required this.data,
13+
required super.duration,
14+
super.curve,
15+
required this.child,
16+
});
17+
18+
final QueryaTheme data;
19+
final Widget child;
20+
21+
@override
22+
AnimatedWidgetBaseState<AnimatedQueryaTheme> createState() =>
23+
_AnimatedQueryaThemeState();
24+
}
25+
26+
class _AnimatedQueryaThemeState
27+
extends AnimatedWidgetBaseState<AnimatedQueryaTheme> {
28+
QueryaThemeTween? _data;
29+
30+
@override
31+
void forEachTween(TweenVisitor<dynamic> visitor) {
32+
_data = visitor(
33+
_data,
34+
widget.data,
35+
(dynamic value) => QueryaThemeTween(begin: value as QueryaTheme),
36+
) as QueryaThemeTween?;
37+
}
38+
39+
@override
40+
Widget build(BuildContext context) {
41+
return QueryaThemeScope(
42+
data: _data!.evaluate(animation),
43+
child: widget.child,
44+
);
45+
}
46+
}
47+
48+
/// Tween that lerps [QueryaTheme] via [QueryaTheme.lerp].
49+
class QueryaThemeTween extends Tween<QueryaTheme> {
50+
QueryaThemeTween({super.begin, super.end});
51+
52+
@override
53+
QueryaTheme lerp(double t) => QueryaTheme.lerp(begin!, end!, t);
54+
}

‎lib/core/theme/theme_controller.dart‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ class ThemeController extends ChangeNotifier {
9090

9191
ThemeMode get themeMode => _themeMode;
9292

93-
/// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions.
93+
/// Preference toggle for theme cross-fades.
94+
///
95+
/// [QueryaApp] still gates animation with [QueryaThemeMotion] (motion level /
96+
/// OS disableAnimations). When those disallow motion, themes snap.
9497
bool get themeAnimationEnabled => _themeAnimationEnabled;
9598

9699
QueryaThemePreset get preset => _preset;

‎lib/features/settings/preferences_appearance_section.dart‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ class _PreferencesAppearanceSectionState
257257
),
258258
const material.SizedBox(height: 4),
259259
const PreferencesHint(
260-
'Smooth transitions when switching dark/light or presets. Off by default for stability.',
260+
'Smooth transitions when switching dark/light or presets. Off by default '
261+
'for stability. Also requires Motion Full or Reduced (Off snaps themes).',
261262
),
262263
const material.SizedBox(height: 12),
263264
PreferencesFieldRow(
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import 'package:flutter/animation.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:querya_desktop/core/motion/querya_motion.dart';
4+
import 'package:querya_desktop/core/motion/querya_motion_scope.dart';
5+
import 'package:querya_desktop/core/motion/querya_theme_motion.dart';
6+
7+
void main() {
8+
group('QueryaThemeMotion.duration', () {
9+
test('instant when preference disabled', () {
10+
expect(
11+
QueryaThemeMotion.duration(
12+
preferenceEnabled: false,
13+
level: QueryaMotionLevel.full,
14+
),
15+
QueryaMotion.instant,
16+
);
17+
});
18+
19+
test('instant when motion off', () {
20+
expect(
21+
QueryaThemeMotion.duration(
22+
preferenceEnabled: true,
23+
level: QueryaMotionLevel.off,
24+
),
25+
QueryaMotion.instant,
26+
);
27+
});
28+
29+
test('instant when OS disables animations', () {
30+
expect(
31+
QueryaThemeMotion.duration(
32+
preferenceEnabled: true,
33+
level: QueryaMotionLevel.full,
34+
disableAnimations: true,
35+
),
36+
QueryaMotion.instant,
37+
);
38+
});
39+
40+
test('slow for full motion', () {
41+
expect(
42+
QueryaThemeMotion.duration(
43+
preferenceEnabled: true,
44+
level: QueryaMotionLevel.full,
45+
),
46+
QueryaMotion.slow,
47+
);
48+
});
49+
50+
test('halved slow for reduced motion', () {
51+
expect(
52+
QueryaThemeMotion.duration(
53+
preferenceEnabled: true,
54+
level: QueryaMotionLevel.reduced,
55+
),
56+
Duration(microseconds: QueryaMotion.slow.inMicroseconds ~/ 2),
57+
);
58+
});
59+
});
60+
61+
group('QueryaThemeMotion.curve', () {
62+
test('linear when not animating', () {
63+
expect(
64+
QueryaThemeMotion.curve(
65+
preferenceEnabled: false,
66+
level: QueryaMotionLevel.full,
67+
),
68+
Curves.linear,
69+
);
70+
});
71+
72+
test('emphasized when animating', () {
73+
expect(
74+
QueryaThemeMotion.curve(
75+
preferenceEnabled: true,
76+
level: QueryaMotionLevel.full,
77+
),
78+
QueryaMotion.emphasized,
79+
);
80+
});
81+
});
82+
83+
group('QueryaThemeMotion.enabled', () {
84+
test('true only when duration is non-zero', () {
85+
expect(
86+
QueryaThemeMotion.enabled(
87+
preferenceEnabled: true,
88+
level: QueryaMotionLevel.full,
89+
),
90+
isTrue,
91+
);
92+
expect(
93+
QueryaThemeMotion.enabled(
94+
preferenceEnabled: true,
95+
level: QueryaMotionLevel.off,
96+
),
97+
isFalse,
98+
);
99+
});
100+
});
101+
}

0 commit comments

Comments
 (0)