From 0b8904a05321860008fd2924daef632cb404b02e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 26 Jul 2026 19:42:51 +0300 Subject: [PATCH] feat(ui): split drag-end settle and Fluid motion guardrails (#342) Add QueryaDragSettleController for 1:1 mid-drag with spring settle on release; animate split focus rings via motion tokens; document 120 Hz Fluid checklist and no-magic-Duration review rules. Co-authored-by: Cursor --- CONTRIBUTING.md | 15 +++ docs/motion-and-high-refresh.md | 16 ++- docs/perf-baseline.md | 12 ++ lib/core/layout/querya_drag_settle.dart | 92 +++++++++++++++ lib/core/layout/querya_split_handle.dart | 22 +++- lib/core/layout/vertical_split_pane.dart | 88 ++++++++++++-- lib/features/main_screen/main_screen.dart | 78 +++++++++++-- test/core/layout/querya_drag_settle_test.dart | 92 +++++++++++++++ .../core/layout/querya_split_handle_test.dart | 107 ++++++++++++++++++ 9 files changed, 496 insertions(+), 26 deletions(-) create mode 100644 lib/core/layout/querya_drag_settle.dart create mode 100644 test/core/layout/querya_drag_settle_test.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9b216d4..627d55d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,21 @@ CI pins a **stable** Flutter version in version locally to avoid "works on my machine" drift. When bumping the pin, run `flutter test` and a release smoke build before merging. +## UI motion (review rule) + +For animated UI, **do not invent magic `Duration(...)` / raw curves** in widgets. + +- Use `QueryaMotion` tokens (`fast` / `standard` / `slow`) via + `context.motionDuration` / `context.motionCurve` (or `QueryaMotion.effective*`). +- Interactive Fluid motion: `QueryaSpring` / `QueryaSpringController` when + `QueryaSpring.springsEnabled` (Full motion only). +- Honor Preferences Motion Full / Reduced / Off and OS `disableAnimations`. +- Mid-drag layout (split panes) stays 1:1; spring settle only on drag-end. +- Do not animate virtualized grid rows on scroll. + +See [docs/motion-and-high-refresh.md](docs/motion-and-high-refresh.md) and +[docs/perf-baseline.md](docs/perf-baseline.md) (Fluid @ 120 Hz checklist). + ## Tests Widget tests that use SQLite or `path_provider` follow patterns in diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index f9cb345a..53f41973 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -137,7 +137,19 @@ Suggested order: A1 → A2 → A3 in parallel with A4; then A5; A6 closes the mi --- -## 7. References +## 7. Review rule — no magic UI durations + +When reviewing PRs that touch animation: + +1. Reject bare `Duration(milliseconds: …)` / ad-hoc `Curves.*` in product UI unless + wired through `QueryaMotion` (or documented physics constants in `QueryaSpring`). +2. Require Full / Reduced / Off + OS `disableAnimations` coverage for new transitions. +3. Split / resize: no spring or lag mid-drag; settle only on release / focus chrome. +4. Never stagger or fade virtualized result rows while scrolling. + +Checklist for 120 Hz verification: [perf-baseline.md](perf-baseline.md) § Fluid shell. + +## 8. References - Flutter engine — high refresh rate gap: `flutter/flutter#160952`, `#90675` (ProMotion scrolling), `#94508` (`CADisableMinimumFrameDurationOnPhone` default). - `refresh_rate` package (query/unlock/overlay/benchmark, all platforms): https://pub.dev/packages/refresh_rate @@ -148,7 +160,7 @@ Suggested order: A1 → A2 → A3 in parallel with A4; then A5; A6 closes the mi --- -## 8. Measured results (0.4.4) +## 9. Measured results (0.4.4) The table below shows the measured refresh rates and frame times on target monitors before and after the 0.4.4 implementation (using a profile build, measured with DevTools and `QUERYA_REFRESH_OVERLAY=true`): diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index fd54ab2b..2593c25f 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -25,3 +25,15 @@ To verify that the motion system conforms to the budget and does not cause jank 8. **Reduced Motion**: - Turn on "Reduce Motion" in your OS settings or select **Preferences → Appearance → Motion → Off** (or **Reduced** for 50% speed). - Verify that transitions complete instantly (**0 ms** for Off) or are appropriately shortened. + +## Fluid shell scenarios @ 120 Hz (0.4.11+ / #342) + +Repeat on a **120 Hz** display (budget **≤ 8.3 ms** build+raster). Prefer profile/release. + +9. **Empty ↔ connected**: open a connection from the empty hero, then disconnect back to empty — `QueryaSwitchingBody` morph; editor/workspace state should not remount-jank. +10. **Tab strip**: switch Server / SQL / History quickly — sliding pill should redirect without brick-wall jumps. +11. **Hero quick-start ↔ recent**: with and without recent connections — FadeSlide + stagger first paint only. +12. **Results modes**: idle → run (spinner) → grid; force an error — mode keys morph; scrolling the grid must not fade rows. +13. **Dialog / dropdown**: open/close `showAppDialog` and a `QueryaDropdown` — enter fade-slide, exit uses exit curve; Motion Off snaps. +14. **Theme cross-fade**: Preferences → enable Animate theme + Motion Full; switch dark/light — shadcn + `AnimatedQueryaTheme` lerp. Repeat with Motion Off (snap). +15. **Split settle**: drag the connections sidebar handle and the SQL/results vertical split with a fling — mid-drag stays 1:1; release may soft-settle. Focus the handle — ring uses motion tokens (not mid-drag animation). diff --git a/lib/core/layout/querya_drag_settle.dart b/lib/core/layout/querya_drag_settle.dart new file mode 100644 index 00000000..bcc48459 --- /dev/null +++ b/lib/core/layout/querya_drag_settle.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/scheduler.dart'; + +import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; + +/// Direct mid-drag tracking with optional spring settle on release. +/// +/// While dragging, [dragTo] jumps 1:1 (no lag). On [settle], a spring inherits +/// release [velocity] toward the current value so the pane soft-stops / +/// overshoots briefly — never applied mid-drag. +class QueryaDragSettleController extends ChangeNotifier { + QueryaDragSettleController({ + required TickerProvider vsync, + required double value, + this.spring = QueryaSpring.gentle, + this.maxSettleVelocity = 2.5, + }) : _value = value { + _spring = QueryaSpringController( + vsync: vsync, + value: value, + spring: spring, + ); + _spring.addListener(_onSpringTick); + } + + final SpringDescription spring; + + /// Cap on |velocity| passed into the settle spring (value-units / second). + final double maxSettleVelocity; + + late final QueryaSpringController _spring; + double _value; + var _dragging = false; + + double get value => _value; + bool get isSettling => !_dragging && _spring.isAnimating; + + /// Instant set (restore from settings, clamp after layout). + void jumpTo(double value) { + _dragging = false; + _spring.jumpTo(value); + if (_value == value) return; + _value = value; + notifyListeners(); + } + + /// Mid-drag update — cancels settle and tracks exactly. + void dragTo(double value) { + _dragging = true; + if (_spring.isAnimating) { + _spring.jumpTo(value); + } else { + _spring.jumpTo(value); + } + if (_value == value) return; + _value = value; + notifyListeners(); + } + + /// Drag-end settle using release velocity (same units as [value] / second). + void settle({ + required double velocity, + required bool useSprings, + }) { + _dragging = false; + _spring.useSprings = useSprings; + final v = velocity.clamp(-maxSettleVelocity, maxSettleVelocity); + if (!useSprings || v.abs() < 0.02) { + _spring.jumpTo(_value); + return; + } + _spring.jumpTo(_value); + _spring.animateTo(_value, velocity: v); + } + + void _onSpringTick() { + if (_dragging) return; + final next = _spring.value; + if ((next - _value).abs() < 0.0001) return; + _value = next; + notifyListeners(); + } + + @override + void dispose() { + _spring.removeListener(_onSpringTick); + _spring.dispose(); + super.dispose(); + } +} diff --git a/lib/core/layout/querya_split_handle.dart b/lib/core/layout/querya_split_handle.dart index 2c856709..7410a7a9 100644 --- a/lib/core/layout/querya_split_handle.dart +++ b/lib/core/layout/querya_split_handle.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Accessible split-pane handle that supports mouse dragging and arrow keys. +/// +/// Mid-drag updates are 1:1 via [onDragDelta]. [onDragEnd] receives velocity for +/// optional spring settle (callers should not animate mid-drag). class QueryaSplitHandle extends material.StatefulWidget { const QueryaSplitHandle({ super.key, @@ -20,7 +25,9 @@ class QueryaSplitHandle extends material.StatefulWidget { final String semanticsLabel; final String? semanticsValue; final double keyboardStep; - final material.VoidCallback? onDragEnd; + + /// Called when a pointer drag ends (not keyboard). Use velocity for settle. + final material.ValueChanged? onDragEnd; @override material.State createState() => _QueryaSplitHandleState(); @@ -61,6 +68,9 @@ class _QueryaSplitHandleState extends material.State { material.Widget build(material.BuildContext context) { final colors = Theme.of(context).colorScheme; final horizontal = widget.axis == material.Axis.horizontal; + final duration = context.motionDuration(QueryaMotion.fast); + final curve = context.motionCurve(QueryaMotion.enter); + return material.Focus( focusNode: _focusNode, onFocusChange: (value) => setState(() => _focused = value), @@ -85,14 +95,14 @@ class _QueryaSplitHandleState extends material.State { onHorizontalDragUpdate: horizontal ? (event) => widget.onDragDelta(event.delta.dx) : null, - onHorizontalDragEnd: - horizontal ? (_) => widget.onDragEnd?.call() : null, + onHorizontalDragEnd: horizontal ? widget.onDragEnd : null, onVerticalDragUpdate: horizontal ? null : (event) => widget.onDragDelta(event.delta.dy), - onVerticalDragEnd: - horizontal ? null : (_) => widget.onDragEnd?.call(), - child: material.Container( + onVerticalDragEnd: horizontal ? null : widget.onDragEnd, + child: material.AnimatedContainer( + duration: duration, + curve: curve, width: horizontal ? 6 : null, height: horizontal ? null : 6, decoration: material.BoxDecoration( diff --git a/lib/core/layout/vertical_split_pane.dart b/lib/core/layout/vertical_split_pane.dart index ed35089e..8b928427 100644 --- a/lib/core/layout/vertical_split_pane.dart +++ b/lib/core/layout/vertical_split_pane.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/querya_drag_settle.dart'; import 'package:querya_desktop/core/layout/querya_split_handle.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Holds top/bottom panes for [VerticalSplitPane] [ValueListenableBuilder.child]. @@ -14,7 +16,10 @@ class SplitPanePair extends StatelessWidget { } /// Vertical split whose drag updates [fraction] without rebuilding [top]/[bottom]. -class VerticalSplitPane extends StatelessWidget { +/// +/// Mid-drag is 1:1; on release a spring settle uses drag velocity when springs +/// are enabled. +class VerticalSplitPane extends StatefulWidget { const VerticalSplitPane({ super.key, required this.fraction, @@ -32,20 +37,76 @@ class VerticalSplitPane extends StatelessWidget { final double maxFraction; final Key? handleKey; + @override + State createState() => _VerticalSplitPaneState(); +} + +class _VerticalSplitPaneState extends State + with SingleTickerProviderStateMixin { + late final QueryaDragSettleController _settle; + var _syncingFromSettle = false; + + @override + void initState() { + super.initState(); + _settle = QueryaDragSettleController( + vsync: this, + value: widget.fraction.value, + ); + _settle.addListener(_onSettleChanged); + widget.fraction.addListener(_onFractionExternal); + } + + @override + void didUpdateWidget(covariant VerticalSplitPane oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.fraction != widget.fraction) { + oldWidget.fraction.removeListener(_onFractionExternal); + widget.fraction.addListener(_onFractionExternal); + _settle.jumpTo(widget.fraction.value); + } + } + + @override + void dispose() { + widget.fraction.removeListener(_onFractionExternal); + _settle.removeListener(_onSettleChanged); + _settle.dispose(); + super.dispose(); + } + + void _onFractionExternal() { + if (_syncingFromSettle) return; + if ((_settle.value - widget.fraction.value).abs() > 0.0001) { + _settle.jumpTo(widget.fraction.value); + } + } + + void _onSettleChanged() { + _syncingFromSettle = true; + widget.fraction.value = _settle.value + .clamp(widget.minFraction, widget.maxFraction) + .toDouble(); + _syncingFromSettle = false; + } + + double _clamp(double raw) => + raw.clamp(widget.minFraction, widget.maxFraction).toDouble(); + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final totalHeight = constraints.maxHeight; return ValueListenableBuilder( - valueListenable: fraction, + valueListenable: widget.fraction, builder: (context, value, panes) { final pair = panes! as SplitPanePair; final topFlex = (value * 100) .round() .clamp( - (minFraction * 100).round(), - (maxFraction * 100).round(), + (widget.minFraction * 100).round(), + (widget.maxFraction * 100).round(), ) .toInt(); final bottomFlex = 100 - topFlex; @@ -54,21 +115,32 @@ class VerticalSplitPane extends StatelessWidget { children: [ Expanded(flex: topFlex, child: pair.top), QueryaSplitHandle( - key: handleKey, + key: widget.handleKey, axis: material.Axis.vertical, semanticsLabel: 'Resize query and output panes', semanticsValue: '${(value * 100).round()}% top pane', onDragDelta: (dy) { if (totalHeight <= 0) return; - fraction.value = (fraction.value + dy / totalHeight) - .clamp(minFraction, maxFraction); + _settle.dragTo( + _clamp(_settle.value + dy / totalHeight), + ); + }, + onDragEnd: (details) { + if (totalHeight <= 0) return; + final velocity = + details.primaryVelocity ?? + details.velocity.pixelsPerSecond.dy; + _settle.settle( + velocity: velocity / totalHeight, + useSprings: QueryaSpring.springsEnabled(context), + ); }, ), Expanded(flex: bottomFlex, child: pair.bottom), ], ); }, - child: SplitPanePair(top: top, bottom: bottom), + child: SplitPanePair(top: widget.top, bottom: widget.bottom), ); }, ); diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index b441fede..abeaf84e 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -6,7 +6,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/layout/querya_drag_settle.dart'; import 'package:querya_desktop/core/layout/querya_split_handle.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; @@ -343,28 +345,77 @@ class _MainContentSplit extends StatefulWidget { State<_MainContentSplit> createState() => _MainContentSplitState(); } -class _MainContentSplitState extends State<_MainContentSplit> { +class _MainContentSplitState extends State<_MainContentSplit> + with SingleTickerProviderStateMixin { static const double _minLeftWidth = 180; static const double _maxLeftWidth = 500; static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; final ValueNotifier _leftPanelWidth = ValueNotifier(kDefaultConnectionsPanelWidth); + late final QueryaDragSettleController _widthSettle; + VoidCallback? _persistWhenSettled; + double _lastMaxWidth = 1200; @override void initState() { super.initState(); + _widthSettle = QueryaDragSettleController( + vsync: this, + value: kDefaultConnectionsPanelWidth, + maxSettleVelocity: 1200, + ); + _widthSettle.addListener(_onWidthSettle); unawaited(_restoreConnectionsPanelWidth()); } Future _restoreConnectionsPanelWidth() async { final width = await AppSettings.instance.getConnectionsPanelWidth(); if (!mounted) return; - _leftPanelWidth.value = width; + final clamped = _clampLeftWidth(width, _lastMaxWidth); + _widthSettle.jumpTo(clamped); + _leftPanelWidth.value = clamped; + } + + void _onWidthSettle() { + final clamped = _clampLeftWidth(_widthSettle.value, _lastMaxWidth); + _leftPanelWidth.value = clamped; + if (!_widthSettle.isSettling && + (clamped - _widthSettle.value).abs() > 0.5) { + _widthSettle.jumpTo(clamped); + } + } + + void _schedulePersistAfterSettle() { + final pending = _persistWhenSettled; + if (pending != null) { + _widthSettle.removeListener(pending); + } + void listener() { + if (_widthSettle.isSettling) return; + _widthSettle.removeListener(listener); + _persistWhenSettled = null; + unawaited( + AppSettings.instance.setConnectionsPanelWidth(_leftPanelWidth.value), + ); + } + + _persistWhenSettled = listener; + if (_widthSettle.isSettling) { + _widthSettle.addListener(listener); + } else { + listener(); + } } @override void dispose() { + final pending = _persistWhenSettled; + if (pending != null) { + _widthSettle.removeListener(pending); + } + _widthSettle.removeListener(_onWidthSettle); + _widthSettle.dispose(); _leftPanelWidth.dispose(); super.dispose(); } @@ -380,6 +431,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { material.Widget build(material.BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + _lastMaxWidth = constraints.maxWidth; return Row( children: [ ValueListenableBuilder( @@ -414,16 +466,22 @@ class _MainContentSplitState extends State<_MainContentSplit> { _resizeHandleWidth - _minWorkspaceWidth; if (ml <= 0) return; - _leftPanelWidth.value = _clampLeftWidth( - _leftPanelWidth.value + dx, - constraints.maxWidth, + _widthSettle.dragTo( + _clampLeftWidth( + _widthSettle.value + dx, + constraints.maxWidth, + ), ); }, - onDragEnd: () => unawaited( - AppSettings.instance.setConnectionsPanelWidth( - _leftPanelWidth.value, - ), - ), + onDragEnd: (details) { + final velocity = details.primaryVelocity ?? + details.velocity.pixelsPerSecond.dx; + _widthSettle.settle( + velocity: velocity, + useSprings: QueryaSpring.springsEnabled(context), + ); + _schedulePersistAfterSettle(); + }, ), Expanded( child: material.RepaintBoundary( diff --git a/test/core/layout/querya_drag_settle_test.dart b/test/core/layout/querya_drag_settle_test.dart new file mode 100644 index 00000000..0d48cbc7 --- /dev/null +++ b/test/core/layout/querya_drag_settle_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/querya_drag_settle.dart'; + +void main() { + testWidgets('dragTo tracks 1:1 without spring lag', (tester) async { + late QueryaDragSettleController controller; + await tester.pumpWidget( + _Host(onCreated: (c) => controller = c), + ); + + controller.dragTo(0.4); + expect(controller.value, 0.4); + expect(controller.isSettling, isFalse); + controller.dragTo(0.55); + expect(controller.value, 0.55); + }); + + testWidgets('settle with velocity soft-stops via spring', (tester) async { + late QueryaDragSettleController controller; + await tester.pumpWidget( + _Host(onCreated: (c) => controller = c), + ); + + controller.dragTo(0.5); + controller.settle(velocity: 1.2, useSprings: true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + expect(controller.isSettling, isTrue); + // Velocity pushes past the release point briefly. + expect(controller.value, isNot(0.5)); + + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(controller.value, closeTo(0.5, 0.05)); + expect(controller.isSettling, isFalse); + }); + + testWidgets('settle without springs snaps', (tester) async { + late QueryaDragSettleController controller; + await tester.pumpWidget( + _Host(onCreated: (c) => controller = c), + ); + + controller.dragTo(0.62); + controller.settle(velocity: 2, useSprings: false); + expect(controller.value, 0.62); + expect(controller.isSettling, isFalse); + }); + + testWidgets('drag mid-settle cancels animation', (tester) async { + late QueryaDragSettleController controller; + await tester.pumpWidget( + _Host(onCreated: (c) => controller = c), + ); + + controller.dragTo(0.5); + controller.settle(velocity: 1.5, useSprings: true); + await tester.pump(const Duration(milliseconds: 20)); + controller.dragTo(0.7); + expect(controller.value, 0.7); + expect(controller.isSettling, isFalse); + }); +} + +class _Host extends StatefulWidget { + const _Host({required this.onCreated}); + + final ValueChanged onCreated; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with SingleTickerProviderStateMixin { + late final QueryaDragSettleController _controller; + + @override + void initState() { + super.initState(); + _controller = QueryaDragSettleController(vsync: this, value: 0.5); + widget.onCreated(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox(); +} diff --git a/test/core/layout/querya_split_handle_test.dart b/test/core/layout/querya_split_handle_test.dart index 6398ad05..86dc0a04 100644 --- a/test/core/layout/querya_split_handle_test.dart +++ b/test/core/layout/querya_split_handle_test.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/layout/querya_split_handle.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -81,4 +83,109 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); expect(totalDelta, 0); }); + + testWidgets('focus ring uses motion-aware AnimatedContainer', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: material.SizedBox( + width: 200, + height: 200, + child: QueryaSplitHandle( + axis: material.Axis.horizontal, + semanticsLabel: 'Resize', + onDragDelta: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final animated = tester.widget( + find.byType(material.AnimatedContainer), + ); + expect(animated.duration, QueryaMotion.fast); + + await tester.tap(find.bySemanticsLabel('Resize')); + await tester.pump(); + final focused = tester.widget( + find.byType(material.AnimatedContainer), + ); + final border = focused.decoration! as material.BoxDecoration; + expect( + (border.border! as material.Border).top.color, + isNot(material.Colors.transparent), + ); + }); + + testWidgets('vertical drag-end settle can overshoot then rest (full motion)', + (tester) async { + final fraction = material.ValueNotifier(0.5); + addTearDown(fraction.dispose); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: material.SizedBox( + width: 600, + height: 400, + child: VerticalSplitPane( + fraction: fraction, + handleKey: const material.Key('handle'), + top: const material.SizedBox.expand(), + bottom: const material.SizedBox.expand(), + ), + ), + ), + ), + ); + await tester.pump(); + + final handle = find.byKey(const material.Key('handle')); + // Fling downward to impart release velocity. + await tester.fling(handle, const material.Offset(0, 80), 1200); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + // Mid-settle may leave the release point. + final mid = fraction.value; + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(fraction.value, inInclusiveRange(0.2, 0.8)); + // Either settled near mid or returned after overshoot — must be finite. + expect(fraction.value.isFinite, isTrue); + expect(mid.isFinite, isTrue); + }); + + testWidgets('motion off skips settle spring on drag end', (tester) async { + final fraction = material.ValueNotifier(0.5); + addTearDown(fraction.dispose); + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: material.SizedBox( + width: 600, + height: 400, + child: VerticalSplitPane( + fraction: fraction, + handleKey: const material.Key('handle'), + top: const material.SizedBox.expand(), + bottom: const material.SizedBox.expand(), + ), + ), + ), + ), + ); + + final handle = find.byKey(const material.Key('handle')); + await tester.drag(handle, const material.Offset(0, 40)); + await tester.pump(); + final afterDrag = fraction.value; + await tester.pump(const Duration(milliseconds: 100)); + expect(fraction.value, afterDrag); + }); }