diff --git a/lib/core/motion/querya_motion.dart b/lib/core/motion/querya_motion.dart new file mode 100644 index 00000000..d7064e3c --- /dev/null +++ b/lib/core/motion/querya_motion.dart @@ -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; + } +} diff --git a/lib/core/motion/querya_motion_context.dart b/lib/core/motion/querya_motion_context.dart new file mode 100644 index 00000000..49e1da63 --- /dev/null +++ b/lib/core/motion/querya_motion_context.dart @@ -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); +} diff --git a/lib/core/motion/querya_motion_scope.dart b/lib/core/motion/querya_motion_scope.dart new file mode 100644 index 00000000..54991d73 --- /dev/null +++ b/lib/core/motion/querya_motion_scope.dart @@ -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() + ?.level; + } + + static QueryaMotionLevel of(BuildContext context) { + return maybeOf(context) ?? QueryaMotionLevel.full; + } + + @override + bool updateShouldNotify(QueryaMotionScope oldWidget) => + level != oldWidget.level; +} diff --git a/test/core/motion/querya_motion_test.dart b/test/core/motion/querya_motion_test.dart new file mode 100644 index 00000000..a7d91d0c --- /dev/null +++ b/test/core/motion/querya_motion_test.dart @@ -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 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 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(); + }, + ), + ), + ), + ); + } +}