diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart index e634272..be40172 100644 --- a/lib/core/motion/querya_spring_controller.dart +++ b/lib/core/motion/querya_spring_controller.dart @@ -8,14 +8,19 @@ import 'querya_spring.dart'; /// Drives a scalar with interruptible / redirectable spring motion. /// /// Call [animateTo] to retarget; the current presentation value and velocity -/// are preserved (no brick-wall). When [useSprings] is false, snaps via -/// [jumpTo]. +/// are preserved (no brick-wall). +/// +/// When [useSprings] is false: +/// - if [cubicDuration] is non-null and non-zero → duration-token cubic (#493) +/// - otherwise → snap via [jumpTo] (Off / drag settle) class QueryaSpringController extends ChangeNotifier { QueryaSpringController({ required TickerProvider vsync, double value = 0, this.spring = QueryaSpring.snappy, this.useSprings = true, + this.cubicDuration, + this.cubicCurve = Curves.easeOutCubic, }) : _value = value, _target = value { _ticker = vsync.createTicker(_onTick); @@ -24,6 +29,10 @@ class QueryaSpringController extends ChangeNotifier { SpringDescription spring; bool useSprings; + /// Cubic fallback when springs are off (Reduced motion). Null / zero → snap. + Duration? cubicDuration; + Curve cubicCurve; + late final Ticker _ticker; double _value; double _velocity = 0; @@ -31,6 +40,10 @@ class QueryaSpringController extends ChangeNotifier { SpringSimulation? _simulation; Duration? _simulationStart; + double? _cubicFrom; + Duration? _cubicTotal; + Curve? _cubicActiveCurve; + double get value => _value; double get velocity => _velocity; double get target => _target; @@ -39,8 +52,7 @@ class QueryaSpringController extends ChangeNotifier { /// Instantly sets value (and clears velocity). void jumpTo(double value) { _ticker.stop(); - _simulation = null; - _simulationStart = null; + _clearMotion(); _velocity = 0; _target = value; if (_value == value) return; @@ -54,7 +66,12 @@ class QueryaSpringController extends ChangeNotifier { final startVelocity = velocity ?? _velocity; if (!useSprings) { - jumpTo(target); + final duration = cubicDuration; + if (duration == null || duration == Duration.zero) { + jumpTo(target); + return; + } + _startCubic(target, duration); return; } @@ -63,6 +80,9 @@ class QueryaSpringController extends ChangeNotifier { return; } + _cubicFrom = null; + _cubicTotal = null; + _cubicActiveCurve = null; _simulation = QueryaSpring.simulation( description: spring, start: _value, @@ -75,7 +95,37 @@ class QueryaSpringController extends ChangeNotifier { } } + void _startCubic(double target, Duration duration) { + if ((_value - target).abs() < 0.0001) { + jumpTo(target); + return; + } + _simulation = null; + _cubicFrom = _value; + _cubicTotal = duration; + _cubicActiveCurve = cubicCurve; + _velocity = 0; + _simulationStart = null; + if (!_ticker.isActive) { + _ticker.start(); + } + } + + void _clearMotion() { + _simulation = null; + _simulationStart = null; + _cubicFrom = null; + _cubicTotal = null; + _cubicActiveCurve = null; + } + void _onTick(Duration elapsed) { + final cubicTotal = _cubicTotal; + if (cubicTotal != null) { + _onCubicTick(elapsed, cubicTotal); + return; + } + final simulation = _simulation; if (simulation == null) { _ticker.stop(); @@ -93,8 +143,7 @@ class QueryaSpringController extends ChangeNotifier { if (settled) { _value = _target; _velocity = 0; - _simulation = null; - _simulationStart = null; + _clearMotion(); _ticker.stop(); notifyListeners(); return; @@ -104,6 +153,33 @@ class QueryaSpringController extends ChangeNotifier { notifyListeners(); } + void _onCubicTick(Duration elapsed, Duration cubicTotal) { + _simulationStart ??= elapsed; + final micros = cubicTotal.inMicroseconds; + if (micros <= 0) { + jumpTo(_target); + return; + } + final t = + (elapsed - _simulationStart!).inMicroseconds / micros; + final from = _cubicFrom ?? _value; + final curve = _cubicActiveCurve ?? cubicCurve; + + if (t >= 1) { + _value = _target; + _velocity = 0; + _clearMotion(); + _ticker.stop(); + notifyListeners(); + return; + } + + final curved = curve.transform(t.clamp(0.0, 1.0)); + _value = from + (_target - from) * curved; + _velocity = 0; + notifyListeners(); + } + @override void dispose() { _ticker.dispose(); diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 947072b..d91709b 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -9,10 +9,11 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; /// A compact, keyboard-operable tab strip using Querya's motion and theme. /// -/// Selection uses a sliding pill indicator (spring when [QueryaSpring.springsEnabled]) -/// so tab changes feel continuous / redirectable. +/// Selection uses a sliding pill indicator: spring when Full +/// ([QueryaSpring.springsEnabled]), duration-token cubic under Reduced (#493), +/// snap when Off / OS `disableAnimations`. /// -/// Spring ticks rebuild only the pill ([_TabStripIndicator]), not the tab row. +/// Spring/cubic ticks rebuild only the pill ([_TabStripIndicator]), not the tab row. class QueryaTabStrip extends material.StatefulWidget { const QueryaTabStrip({ super.key, @@ -55,6 +56,18 @@ class _QueryaTabStripState extends material.State final springs = QueryaSpring.springsEnabled(context); _indicatorLeft.useSprings = springs; _indicatorWidth.useSprings = springs; + + // Reduced: animate with halved fast token; Off / disableAnimations → snap. + Duration? cubic; + if (!springs) { + final d = context.motionDuration(QueryaMotion.fast); + if (d > Duration.zero) cubic = d; + } + final curve = context.motionCurve(QueryaMotion.enter); + _indicatorLeft.cubicDuration = cubic; + _indicatorWidth.cubicDuration = cubic; + _indicatorLeft.cubicCurve = curve; + _indicatorWidth.cubicCurve = curve; } @override diff --git a/test/core/motion/querya_spring_test.dart b/test/core/motion/querya_spring_test.dart index 0cbb61f..db2d299 100644 --- a/test/core/motion/querya_spring_test.dart +++ b/test/core/motion/querya_spring_test.dart @@ -251,6 +251,47 @@ void main() { expect(controller.isAnimating, isFalse); }); + testWidgets('cubics when springs off and cubicDuration set', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + cubicDuration: const Duration(milliseconds: 100), + onCreated: (c) => controller = c, + ), + ), + ); + + controller.animateTo(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(controller.value, greaterThan(0)); + expect(controller.value, lessThan(1)); + expect(controller.isAnimating, isTrue); + + await tester.pumpAndSettle(); + expect(controller.value, closeTo(1, 0.001)); + expect(controller.isAnimating, isFalse); + }); + + testWidgets('cubic Duration.zero snaps', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + cubicDuration: Duration.zero, + onCreated: (c) => controller = c, + ), + ), + ); + + controller.animateTo(1); + expect(controller.value, 1); + expect(controller.isAnimating, isFalse); + }); + testWidgets('notifies listeners on animate', (tester) async { late QueryaSpringController controller; var notifications = 0; @@ -295,11 +336,13 @@ class _SpringHost extends StatefulWidget { required this.onCreated, this.useSprings = true, this.spring = QueryaSpring.snappy, + this.cubicDuration, }); final ValueChanged onCreated; final bool useSprings; final SpringDescription spring; + final Duration? cubicDuration; @override State<_SpringHost> createState() => _SpringHostState(); @@ -316,6 +359,7 @@ class _SpringHostState extends State<_SpringHost> vsync: this, useSprings: widget.useSprings, spring: widget.spring, + cubicDuration: widget.cubicDuration, ); widget.onCreated(_controller); } diff --git a/test/shared/querya_tab_strip_test.dart b/test/shared/querya_tab_strip_test.dart index 6253709..3ef0399 100644 --- a/test/shared/querya_tab_strip_test.dart +++ b/test/shared/querya_tab_strip_test.dart @@ -201,6 +201,45 @@ void main() { ); }); + testWidgets('reduced motion slides indicator with cubic (not snap)', + (tester) async { + var selected = 0; + await tester.pumpWidget( + stripShell( + level: QueryaMotionLevel.reduced, + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final startLeft = indicatorOf(tester).left!; + + await tester.tap(find.bySemanticsLabel('History')); + await tester.pump(); + await tester.pump(); // post-frame sync starts cubic + await tester.pump(const Duration(milliseconds: 20)); + + final midLeft = indicatorOf(tester).left!; + expect(midLeft, greaterThan(startLeft)); + final history = + tester.getRect(find.byKey(const material.ValueKey('querya_tab_History'))); + final strip = tester.getRect(find.byType(QueryaTabStrip)); + final endLeft = history.left - strip.left; + expect(midLeft, lessThan(endLeft - 0.5)); + + await tester.pumpAndSettle(); + expect(indicatorOf(tester).left, closeTo(endLeft, 1.0)); + }); + testWidgets('redirect mid-slide settles on final selection', (tester) async { var selected = 0; await tester.pumpWidget(