Skip to content

Commit 31fcdf5

Browse files
Merge pull request #177 from QueryaHub/issue/173-motion-tokens-core
UI-A1 — Motion tokens core (QueryaMotion) (#173)
2 parents 7ec4e85 + e374f1d commit 31fcdf5

4 files changed

Lines changed: 306 additions & 0 deletions

File tree

lib/core/motion/querya_motion.dart

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import 'package:flutter/material.dart';
2+
3+
import 'querya_motion_scope.dart';
4+
5+
/// Shared animation durations and curves for Querya Desktop.
6+
///
7+
/// Widgets should use [effectiveDuration] / [effectiveCurve] (or the
8+
/// [BuildContext] helpers in [querya_motion_context.dart]) so OS and in-app
9+
/// reduced-motion settings apply consistently.
10+
abstract final class QueryaMotion {
11+
/// No animation — used when motion is disabled.
12+
static const Duration instant = Duration.zero;
13+
14+
/// Hover, small state changes.
15+
static const Duration fast = Duration(milliseconds: 120);
16+
17+
/// Dialogs, menus, expand/collapse.
18+
static const Duration standard = Duration(milliseconds: 200);
19+
20+
/// Emphasized transitions (theme cross-fade, large surfaces).
21+
static const Duration slow = Duration(milliseconds: 320);
22+
23+
/// Elements appearing (decelerate).
24+
static const Curve enter = Curves.easeOutCubic;
25+
26+
/// Elements leaving (accelerate).
27+
static const Curve exit = Curves.easeInCubic;
28+
29+
/// Move or resize in place.
30+
static const Curve standardCurve = Curves.easeInOutCubic;
31+
32+
/// Hero / theme transitions.
33+
static const Curve emphasized = Curves.easeInOutCubicEmphasized;
34+
35+
/// Returns [token] adjusted for accessibility and [QueryaMotionScope] level.
36+
static Duration effectiveDuration(BuildContext context, Duration token) {
37+
if (token == instant) return instant;
38+
if (MediaQuery.disableAnimationsOf(context)) return instant;
39+
40+
final level = QueryaMotionScope.maybeOf(context);
41+
switch (level) {
42+
case QueryaMotionLevel.off:
43+
return instant;
44+
case QueryaMotionLevel.reduced:
45+
final halved = Duration(
46+
microseconds: token.inMicroseconds ~/ 2,
47+
);
48+
return halved == instant ? fast : halved;
49+
case QueryaMotionLevel.full:
50+
case null:
51+
return token;
52+
}
53+
}
54+
55+
/// Returns [token] unless motion is fully disabled (then [Curves.linear]).
56+
static Curve effectiveCurve(BuildContext context, Curve token) {
57+
if (MediaQuery.disableAnimationsOf(context)) return Curves.linear;
58+
if (QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.off) {
59+
return Curves.linear;
60+
}
61+
return token;
62+
}
63+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import 'package:flutter/material.dart';
2+
3+
import 'querya_motion.dart';
4+
5+
extension QueryaMotionContext on BuildContext {
6+
/// Token duration after OS / in-app reduced-motion rules.
7+
Duration motionDuration(Duration token) =>
8+
QueryaMotion.effectiveDuration(this, token);
9+
10+
/// Token curve after OS / in-app reduced-motion rules.
11+
Curve motionCurve(Curve token) => QueryaMotion.effectiveCurve(this, token);
12+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import 'package:flutter/material.dart';
2+
3+
/// In-app motion intensity. Wired to Preferences in UI-A5 (#175); defaults to
4+
/// [full] until then.
5+
enum QueryaMotionLevel {
6+
full,
7+
reduced,
8+
off,
9+
}
10+
11+
/// Provides [QueryaMotionLevel] for [QueryaMotion.effectiveDuration].
12+
///
13+
/// Place near the app root when the Preferences toggle lands; optional until
14+
/// then — missing scope means [QueryaMotionLevel.full].
15+
class QueryaMotionScope extends InheritedWidget {
16+
const QueryaMotionScope({
17+
super.key,
18+
required this.level,
19+
required super.child,
20+
});
21+
22+
final QueryaMotionLevel level;
23+
24+
static QueryaMotionLevel? maybeOf(BuildContext context) {
25+
return context
26+
.dependOnInheritedWidgetOfExactType<QueryaMotionScope>()
27+
?.level;
28+
}
29+
30+
static QueryaMotionLevel of(BuildContext context) {
31+
return maybeOf(context) ?? QueryaMotionLevel.full;
32+
}
33+
34+
@override
35+
bool updateShouldNotify(QueryaMotionScope oldWidget) =>
36+
level != oldWidget.level;
37+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import 'package:flutter/material.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_context.dart';
5+
import 'package:querya_desktop/core/motion/querya_motion_scope.dart';
6+
7+
void main() {
8+
group('QueryaMotion tokens', () {
9+
test('duration constants match design doc', () {
10+
expect(QueryaMotion.instant, Duration.zero);
11+
expect(QueryaMotion.fast, const Duration(milliseconds: 120));
12+
expect(QueryaMotion.standard, const Duration(milliseconds: 200));
13+
expect(QueryaMotion.slow, const Duration(milliseconds: 320));
14+
});
15+
16+
test('curve constants are set', () {
17+
expect(QueryaMotion.enter, Curves.easeOutCubic);
18+
expect(QueryaMotion.exit, Curves.easeInCubic);
19+
expect(QueryaMotion.standardCurve, Curves.easeInOutCubic);
20+
expect(QueryaMotion.emphasized, Curves.easeInOutCubicEmphasized);
21+
});
22+
});
23+
24+
group('effectiveDuration', () {
25+
testWidgets('returns token when motion is full', (tester) async {
26+
late Duration result;
27+
28+
await tester.pumpWidget(
29+
_MotionProbe(
30+
disableAnimations: false,
31+
level: QueryaMotionLevel.full,
32+
onDuration: (d) => result = d,
33+
),
34+
);
35+
36+
expect(result, QueryaMotion.standard);
37+
});
38+
39+
testWidgets('returns instant when OS disableAnimations is true', (
40+
tester,
41+
) async {
42+
late Duration result;
43+
44+
await tester.pumpWidget(
45+
_MotionProbe(
46+
disableAnimations: true,
47+
level: QueryaMotionLevel.full,
48+
onDuration: (d) => result = d,
49+
),
50+
);
51+
52+
expect(result, QueryaMotion.instant);
53+
});
54+
55+
testWidgets('returns instant when motion level is off', (tester) async {
56+
late Duration result;
57+
58+
await tester.pumpWidget(
59+
_MotionProbe(
60+
disableAnimations: false,
61+
level: QueryaMotionLevel.off,
62+
onDuration: (d) => result = d,
63+
),
64+
);
65+
66+
expect(result, QueryaMotion.instant);
67+
});
68+
69+
testWidgets('halves duration when motion level is reduced', (tester) async {
70+
late Duration result;
71+
72+
await tester.pumpWidget(
73+
_MotionProbe(
74+
disableAnimations: false,
75+
level: QueryaMotionLevel.reduced,
76+
onDuration: (d) => result = d,
77+
),
78+
);
79+
80+
expect(result, const Duration(milliseconds: 100));
81+
});
82+
83+
testWidgets('defaults to full when QueryaMotionScope is absent', (
84+
tester,
85+
) async {
86+
late Duration result;
87+
88+
await tester.pumpWidget(
89+
MaterialApp(
90+
home: Builder(
91+
builder: (context) {
92+
result = context.motionDuration(QueryaMotion.fast);
93+
return const SizedBox.shrink();
94+
},
95+
),
96+
),
97+
);
98+
99+
expect(result, QueryaMotion.fast);
100+
});
101+
});
102+
103+
group('effectiveCurve', () {
104+
testWidgets('returns linear when animations disabled', (tester) async {
105+
late Curve result;
106+
107+
await tester.pumpWidget(
108+
_MotionCurveProbe(
109+
disableAnimations: true,
110+
level: QueryaMotionLevel.full,
111+
onCurve: (c) => result = c,
112+
),
113+
);
114+
115+
expect(result, Curves.linear);
116+
});
117+
118+
testWidgets('returns token curve when motion is full', (tester) async {
119+
late Curve result;
120+
121+
await tester.pumpWidget(
122+
_MotionCurveProbe(
123+
disableAnimations: false,
124+
level: QueryaMotionLevel.full,
125+
onCurve: (c) => result = c,
126+
),
127+
);
128+
129+
expect(result, QueryaMotion.enter);
130+
});
131+
});
132+
}
133+
134+
class _MotionProbe extends StatelessWidget {
135+
const _MotionProbe({
136+
required this.disableAnimations,
137+
required this.level,
138+
required this.onDuration,
139+
});
140+
141+
final bool disableAnimations;
142+
final QueryaMotionLevel level;
143+
final ValueChanged<Duration> onDuration;
144+
145+
@override
146+
Widget build(BuildContext context) {
147+
return MaterialApp(
148+
home: MediaQuery(
149+
data: MediaQueryData(disableAnimations: disableAnimations),
150+
child: QueryaMotionScope(
151+
level: level,
152+
child: Builder(
153+
builder: (context) {
154+
onDuration(
155+
QueryaMotion.effectiveDuration(context, QueryaMotion.standard),
156+
);
157+
return const SizedBox.shrink();
158+
},
159+
),
160+
),
161+
),
162+
);
163+
}
164+
}
165+
166+
class _MotionCurveProbe extends StatelessWidget {
167+
const _MotionCurveProbe({
168+
required this.disableAnimations,
169+
required this.level,
170+
required this.onCurve,
171+
});
172+
173+
final bool disableAnimations;
174+
final QueryaMotionLevel level;
175+
final ValueChanged<Curve> onCurve;
176+
177+
@override
178+
Widget build(BuildContext context) {
179+
return MaterialApp(
180+
home: MediaQuery(
181+
data: MediaQueryData(disableAnimations: disableAnimations),
182+
child: QueryaMotionScope(
183+
level: level,
184+
child: Builder(
185+
builder: (context) {
186+
onCurve(QueryaMotion.effectiveCurve(context, QueryaMotion.enter));
187+
return const SizedBox.shrink();
188+
},
189+
),
190+
),
191+
),
192+
);
193+
}
194+
}

0 commit comments

Comments
 (0)