Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions lib/core/motion/querya_motion.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';

import 'querya_motion_scope.dart';

/// Shared animation durations and curves for Querya Desktop.
///
/// Widgets should use [effectiveDuration] / [effectiveCurve] (or the
/// [BuildContext] helpers in [querya_motion_context.dart]) so OS and in-app
/// reduced-motion settings apply consistently.
abstract final class QueryaMotion {
/// No animation — used when motion is disabled.
static const Duration instant = Duration.zero;

/// Hover, small state changes.
static const Duration fast = Duration(milliseconds: 120);

/// Dialogs, menus, expand/collapse.
static const Duration standard = Duration(milliseconds: 200);

/// Emphasized transitions (theme cross-fade, large surfaces).
static const Duration slow = Duration(milliseconds: 320);

/// Elements appearing (decelerate).
static const Curve enter = Curves.easeOutCubic;

/// Elements leaving (accelerate).
static const Curve exit = Curves.easeInCubic;

/// Move or resize in place.
static const Curve standardCurve = Curves.easeInOutCubic;

/// Hero / theme transitions.
static const Curve emphasized = Curves.easeInOutCubicEmphasized;

/// Returns [token] adjusted for accessibility and [QueryaMotionScope] level.
static Duration effectiveDuration(BuildContext context, Duration token) {
if (token == instant) return instant;
if (MediaQuery.disableAnimationsOf(context)) return instant;

final level = QueryaMotionScope.maybeOf(context);
switch (level) {
case QueryaMotionLevel.off:
return instant;
case QueryaMotionLevel.reduced:
final halved = Duration(
microseconds: token.inMicroseconds ~/ 2,
);
return halved == instant ? fast : halved;
case QueryaMotionLevel.full:
case null:
return token;
}
}

/// Returns [token] unless motion is fully disabled (then [Curves.linear]).
static Curve effectiveCurve(BuildContext context, Curve token) {
if (MediaQuery.disableAnimationsOf(context)) return Curves.linear;
if (QueryaMotionScope.maybeOf(context) == QueryaMotionLevel.off) {
return Curves.linear;
}
return token;
}
}
12 changes: 12 additions & 0 deletions lib/core/motion/querya_motion_context.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';

import 'querya_motion.dart';

extension QueryaMotionContext on BuildContext {
/// Token duration after OS / in-app reduced-motion rules.
Duration motionDuration(Duration token) =>
QueryaMotion.effectiveDuration(this, token);

/// Token curve after OS / in-app reduced-motion rules.
Curve motionCurve(Curve token) => QueryaMotion.effectiveCurve(this, token);
}
37 changes: 37 additions & 0 deletions lib/core/motion/querya_motion_scope.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';

/// In-app motion intensity. Wired to Preferences in UI-A5 (#175); defaults to
/// [full] until then.
enum QueryaMotionLevel {
full,
reduced,
off,
}

/// Provides [QueryaMotionLevel] for [QueryaMotion.effectiveDuration].
///
/// Place near the app root when the Preferences toggle lands; optional until
/// then — missing scope means [QueryaMotionLevel.full].
class QueryaMotionScope extends InheritedWidget {
const QueryaMotionScope({
super.key,
required this.level,
required super.child,
});

final QueryaMotionLevel level;

static QueryaMotionLevel? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<QueryaMotionScope>()
?.level;
}

static QueryaMotionLevel of(BuildContext context) {
return maybeOf(context) ?? QueryaMotionLevel.full;
}

@override
bool updateShouldNotify(QueryaMotionScope oldWidget) =>
level != oldWidget.level;
}
194 changes: 194 additions & 0 deletions test/core/motion/querya_motion_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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/motion/querya_motion_context.dart';
import 'package:querya_desktop/core/motion/querya_motion_scope.dart';

void main() {
group('QueryaMotion tokens', () {
test('duration constants match design doc', () {
expect(QueryaMotion.instant, Duration.zero);
expect(QueryaMotion.fast, const Duration(milliseconds: 120));
expect(QueryaMotion.standard, const Duration(milliseconds: 200));
expect(QueryaMotion.slow, const Duration(milliseconds: 320));
});

test('curve constants are set', () {
expect(QueryaMotion.enter, Curves.easeOutCubic);
expect(QueryaMotion.exit, Curves.easeInCubic);
expect(QueryaMotion.standardCurve, Curves.easeInOutCubic);
expect(QueryaMotion.emphasized, Curves.easeInOutCubicEmphasized);
});
});

group('effectiveDuration', () {
testWidgets('returns token when motion is full', (tester) async {
late Duration result;

await tester.pumpWidget(
_MotionProbe(
disableAnimations: false,
level: QueryaMotionLevel.full,
onDuration: (d) => result = d,
),
);

expect(result, QueryaMotion.standard);
});

testWidgets('returns instant when OS disableAnimations is true', (
tester,
) async {
late Duration result;

await tester.pumpWidget(
_MotionProbe(
disableAnimations: true,
level: QueryaMotionLevel.full,
onDuration: (d) => result = d,
),
);

expect(result, QueryaMotion.instant);
});

testWidgets('returns instant when motion level is off', (tester) async {
late Duration result;

await tester.pumpWidget(
_MotionProbe(
disableAnimations: false,
level: QueryaMotionLevel.off,
onDuration: (d) => result = d,
),
);

expect(result, QueryaMotion.instant);
});

testWidgets('halves duration when motion level is reduced', (tester) async {
late Duration result;

await tester.pumpWidget(
_MotionProbe(
disableAnimations: false,
level: QueryaMotionLevel.reduced,
onDuration: (d) => result = d,
),
);

expect(result, const Duration(milliseconds: 100));
});

testWidgets('defaults to full when QueryaMotionScope is absent', (
tester,
) async {
late Duration result;

await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
result = context.motionDuration(QueryaMotion.fast);
return const SizedBox.shrink();
},
),
),
);

expect(result, QueryaMotion.fast);
});
});

group('effectiveCurve', () {
testWidgets('returns linear when animations disabled', (tester) async {
late Curve result;

await tester.pumpWidget(
_MotionCurveProbe(
disableAnimations: true,
level: QueryaMotionLevel.full,
onCurve: (c) => result = c,
),
);

expect(result, Curves.linear);
});

testWidgets('returns token curve when motion is full', (tester) async {
late Curve result;

await tester.pumpWidget(
_MotionCurveProbe(
disableAnimations: false,
level: QueryaMotionLevel.full,
onCurve: (c) => result = c,
),
);

expect(result, QueryaMotion.enter);
});
});
}

class _MotionProbe extends StatelessWidget {
const _MotionProbe({
required this.disableAnimations,
required this.level,
required this.onDuration,
});

final bool disableAnimations;
final QueryaMotionLevel level;
final ValueChanged<Duration> onDuration;

@override
Widget build(BuildContext context) {
return MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: QueryaMotionScope(
level: level,
child: Builder(
builder: (context) {
onDuration(
QueryaMotion.effectiveDuration(context, QueryaMotion.standard),
);
return const SizedBox.shrink();
},
),
),
),
);
}
}

class _MotionCurveProbe extends StatelessWidget {
const _MotionCurveProbe({
required this.disableAnimations,
required this.level,
required this.onCurve,
});

final bool disableAnimations;
final QueryaMotionLevel level;
final ValueChanged<Curve> onCurve;

@override
Widget build(BuildContext context) {
return MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: QueryaMotionScope(
level: level,
child: Builder(
builder: (context) {
onCurve(QueryaMotion.effectiveCurve(context, QueryaMotion.enter));
return const SizedBox.shrink();
},
),
),
),
);
}
}
Loading