diff --git a/lib/core/motion/querya_fade_slide.dart b/lib/core/motion/querya_fade_slide.dart new file mode 100644 index 00000000..c7d19086 --- /dev/null +++ b/lib/core/motion/querya_fade_slide.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; +import 'querya_spring.dart'; + +/// Fades and optionally slides [child] when the keyed child changes. +/// +/// Uses a short spring-like curve when [QueryaSpring.springsEnabled], otherwise +/// duration tokens. Prefer wrapping content with a stable [Key] on [child]. +class QueryaFadeSlide extends StatelessWidget { + const QueryaFadeSlide({ + super.key, + required this.child, + this.offset = const Offset(0, 0.02), + this.alignment = Alignment.center, + }); + + final Widget child; + + /// Fractional slide for the incoming child (of parent size). + final Offset offset; + final Alignment alignment; + + @override + Widget build(BuildContext context) { + final useSpring = QueryaSpring.springsEnabled(context); + final duration = context.motionDuration( + useSpring ? QueryaMotion.standard : QueryaMotion.fast, + ); + final curve = context.motionCurve( + useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, + ); + + return AnimatedSwitcher( + duration: duration, + switchInCurve: curve, + switchOutCurve: context.motionCurve(QueryaMotion.exit), + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: alignment, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + transitionBuilder: (child, animation) { + final slide = Tween(begin: offset, end: Offset.zero) + .animate(animation); + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: slide, + child: child, + ), + ); + }, + child: child, + ); + } +} diff --git a/lib/core/motion/querya_hover_surface.dart b/lib/core/motion/querya_hover_surface.dart new file mode 100644 index 00000000..40c3b1c1 --- /dev/null +++ b/lib/core/motion/querya_hover_surface.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; + +/// Unified hover background / border using motion tokens (Responsive chrome). +class QueryaHoverSurface extends StatefulWidget { + const QueryaHoverSurface({ + super.key, + required this.child, + this.borderRadius, + this.padding, + this.hoveredColor, + this.idleColor = Colors.transparent, + this.onTap, + this.mouseCursor, + }); + + final Widget child; + final BorderRadius? borderRadius; + final EdgeInsetsGeometry? padding; + final Color? hoveredColor; + final Color idleColor; + final VoidCallback? onTap; + final MouseCursor? mouseCursor; + + @override + State createState() => _QueryaHoverSurfaceState(); +} + +class _QueryaHoverSurfaceState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final hovered = widget.hoveredColor ?? + scheme.onSurface.withValues(alpha: 0.06); + final duration = context.motionDuration(QueryaMotion.fast); + final curve = context.motionCurve(QueryaMotion.enter); + + Widget content = AnimatedContainer( + duration: duration, + curve: curve, + padding: widget.padding, + decoration: BoxDecoration( + color: _hovered ? hovered : widget.idleColor, + borderRadius: widget.borderRadius, + ), + child: widget.child, + ); + + if (widget.onTap != null) { + content = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: content, + ); + } + + return MouseRegion( + cursor: widget.mouseCursor ?? + (widget.onTap != null + ? SystemMouseCursors.click + : SystemMouseCursors.basic), + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: content, + ); + } +} diff --git a/lib/core/motion/querya_motion_context.dart b/lib/core/motion/querya_motion_context.dart index 49e1da63..bbab3c07 100644 --- a/lib/core/motion/querya_motion_context.dart +++ b/lib/core/motion/querya_motion_context.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; +import 'querya_spring.dart'; extension QueryaMotionContext on BuildContext { /// Token duration after OS / in-app reduced-motion rules. @@ -9,4 +10,7 @@ extension QueryaMotionContext on BuildContext { /// Token curve after OS / in-app reduced-motion rules. Curve motionCurve(Curve token) => QueryaMotion.effectiveCurve(this, token); + + /// Whether interactive surfaces should use spring physics (Full only). + bool get motionSpringsEnabled => QueryaSpring.springsEnabled(this); } diff --git a/lib/core/motion/querya_spring.dart b/lib/core/motion/querya_spring.dart new file mode 100644 index 00000000..278d35fb --- /dev/null +++ b/lib/core/motion/querya_spring.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; + +import 'querya_motion_scope.dart'; + +/// Spring presets for Fluid UI (interruptible / redirectable motion). +/// +/// Tuned toward critically damped motion (~Apple Response 0.3–0.5s feel). +/// Use with [SpringSimulation] / [AnimationController.animateWith], not fixed +/// [Duration] curves, when [springsEnabled] is true. +abstract final class QueryaSpring { + /// Snappy panels / dialogs / tab indicator (~0.3s Response feel). + static const SpringDescription snappy = SpringDescription( + mass: 1, + stiffness: 400, + damping: 40, // ≈ 2 * sqrt(stiffness * mass) — critically damped + ); + + /// Softer sheet / sidebar settle (~0.5s Response feel). + static const SpringDescription gentle = SpringDescription( + mass: 1, + stiffness: 180, + damping: 26.83, + ); + + /// Slight underdamped bounce for flicks / inertial settles. + static const SpringDescription bouncy = SpringDescription( + mass: 1, + stiffness: 300, + damping: 20, + ); + + /// Whether interactive surfaces should use springs (Full motion only). + /// + /// Reduced / Off / OS `disableAnimations` fall back to duration tokens or + /// instant transitions. + static bool springsEnabled(BuildContext context) { + if (MediaQuery.disableAnimationsOf(context)) return false; + final level = QueryaMotionScope.maybeOf(context); + return level == null || level == QueryaMotionLevel.full; + } + + /// Builds a [SpringSimulation] from [start] toward [end]. + /// + /// Pass [velocity] from the previous simulation / gesture for handoff + /// (redirectable / interruptible). + static SpringSimulation simulation({ + required SpringDescription description, + required double start, + required double end, + double velocity = 0, + }) { + return SpringSimulation(description, start, end, velocity); + } +} diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart new file mode 100644 index 00000000..e6342721 --- /dev/null +++ b/lib/core/motion/querya_spring_controller.dart @@ -0,0 +1,112 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/scheduler.dart'; + +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]. +class QueryaSpringController extends ChangeNotifier { + QueryaSpringController({ + required TickerProvider vsync, + double value = 0, + this.spring = QueryaSpring.snappy, + this.useSprings = true, + }) : _value = value, + _target = value { + _ticker = vsync.createTicker(_onTick); + } + + SpringDescription spring; + bool useSprings; + + late final Ticker _ticker; + double _value; + double _velocity = 0; + double _target; + SpringSimulation? _simulation; + Duration? _simulationStart; + + double get value => _value; + double get velocity => _velocity; + double get target => _target; + bool get isAnimating => _ticker.isActive; + + /// Instantly sets value (and clears velocity). + void jumpTo(double value) { + _ticker.stop(); + _simulation = null; + _simulationStart = null; + _velocity = 0; + _target = value; + if (_value == value) return; + _value = value; + notifyListeners(); + } + + /// Animates toward [target], inheriting current velocity when redirecting. + void animateTo(double target, {double? velocity}) { + _target = target; + final startVelocity = velocity ?? _velocity; + + if (!useSprings) { + jumpTo(target); + return; + } + + if ((_value - target).abs() < 0.0001 && startVelocity.abs() < 0.0001) { + jumpTo(target); + return; + } + + _simulation = QueryaSpring.simulation( + description: spring, + start: _value, + end: target, + velocity: startVelocity, + ); + _simulationStart = null; + if (!_ticker.isActive) { + _ticker.start(); + } + } + + void _onTick(Duration elapsed) { + final simulation = _simulation; + if (simulation == null) { + _ticker.stop(); + return; + } + + _simulationStart ??= elapsed; + final t = (elapsed - _simulationStart!).inMicroseconds / 1e6; + final next = simulation.x(t); + _velocity = simulation.dx(t); + + final settled = simulation.isDone(t) || + ((next - _target).abs() < 0.0005 && _velocity.abs() < 0.01); + + if (settled) { + _value = _target; + _velocity = 0; + _simulation = null; + _simulationStart = null; + _ticker.stop(); + notifyListeners(); + return; + } + + _value = next; + notifyListeners(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } +} diff --git a/lib/core/motion/querya_stagger.dart b/lib/core/motion/querya_stagger.dart new file mode 100644 index 00000000..7b698b47 --- /dev/null +++ b/lib/core/motion/querya_stagger.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; + +/// Staggered fade-in for a fixed list — **first paint only**, capped at [maxStaggered]. +/// +/// Do not wrap virtualized / scrolling grids; use for history lists, recent +/// connections, etc. +class QueryaStagger extends StatefulWidget { + const QueryaStagger({ + super.key, + required this.children, + this.maxStaggered = 8, + this.step = const Duration(milliseconds: 30), + }); + + final List children; + final int maxStaggered; + final Duration step; + + @override + State createState() => _QueryaStaggerState(); +} + +class _QueryaStaggerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + bool _played = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_played) return; + _played = true; + final n = widget.children.length.clamp(0, widget.maxStaggered); + if (n == 0) return; + + final base = context.motionDuration(QueryaMotion.fast); + if (base == QueryaMotion.instant) { + _controller.value = 1; + return; + } + + final total = base + widget.step * n; + _controller.duration = total; + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final count = widget.children.length; + if (count == 0) return const SizedBox.shrink(); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < count; i++) + Opacity( + opacity: _opacityFor(i, count), + child: widget.children[i], + ), + ], + ); + }, + ); + } + + double _opacityFor(int index, int count) { + if (_controller.duration == null || + _controller.duration == Duration.zero) { + return 1; + } + if (index >= widget.maxStaggered) return 1; + + final totalMs = _controller.duration!.inMilliseconds; + if (totalMs <= 0) return 1; + + final stepMs = widget.step.inMilliseconds; + final start = (stepMs * index) / totalMs; + final end = (start + 0.35).clamp(0.0, 1.0); + final t = _controller.value; + if (t <= start) return 0; + if (t >= end) return 1; + return ((t - start) / (end - start)).clamp(0.0, 1.0); + } +} diff --git a/lib/core/motion/querya_switching_body.dart b/lib/core/motion/querya_switching_body.dart new file mode 100644 index 00000000..025f1490 --- /dev/null +++ b/lib/core/motion/querya_switching_body.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; +import 'querya_spring.dart'; + +/// Keep-alive indexed stack with opacity (+ optional slide) transitions. +/// +/// Off-screen children stay mounted (SQL editor state, etc.). Prefer this over +/// hard `if` swaps for empty↔workspace and similar shell morphs. +class QueryaSwitchingBody extends StatelessWidget { + const QueryaSwitchingBody({ + super.key, + required this.index, + required this.children, + this.slide = const Offset(0.015, 0), + }); + + final int index; + final List children; + + /// Incoming slide (fraction of size). Zero disables slide. + final Offset slide; + + @override + Widget build(BuildContext context) { + assert(children.isNotEmpty, 'QueryaSwitchingBody requires children'); + final safeIndex = index.clamp(0, children.length - 1); + final useSpring = QueryaSpring.springsEnabled(context); + final duration = context.motionDuration( + useSpring ? QueryaMotion.standard : QueryaMotion.fast, + ); + final inCurve = context.motionCurve( + useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, + ); + final outCurve = context.motionCurve(QueryaMotion.exit); + + return Stack( + fit: StackFit.expand, + children: [ + for (var i = 0; i < children.length; i++) + Positioned.fill( + child: _SwitchingLayer( + active: i == safeIndex, + duration: duration, + inCurve: inCurve, + outCurve: outCurve, + slide: slide, + child: children[i], + ), + ), + ], + ); + } +} + +class _SwitchingLayer extends StatelessWidget { + const _SwitchingLayer({ + required this.active, + required this.duration, + required this.inCurve, + required this.outCurve, + required this.slide, + required this.child, + }); + + final bool active; + final Duration duration; + final Curve inCurve; + final Curve outCurve; + final Offset slide; + final Widget child; + + @override + Widget build(BuildContext context) { + final curve = active ? inCurve : outCurve; + Widget layer = AnimatedOpacity( + opacity: active ? 1 : 0, + duration: duration, + curve: curve, + child: child, + ); + + if (slide != Offset.zero) { + layer = AnimatedSlide( + offset: active ? Offset.zero : slide, + duration: duration, + curve: curve, + child: layer, + ); + } + + return IgnorePointer( + ignoring: !active, + child: ExcludeFocus( + excluding: !active, + child: ExcludeSemantics( + excluding: !active, + child: layer, + ), + ), + ); + } +} diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 19ef9a87..b3c40058 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material show + Align, Alignment, Container, EdgeInsets, @@ -7,10 +8,12 @@ import 'package:flutter/material.dart' as material Center, Icon, Icons, + MainAxisSize, SizedBox, Widget, Column; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -126,24 +129,48 @@ class WorkspacePanel extends StatefulWidget { } class _WorkspacePanelState extends State { + /// Keeps the last connected workspace mounted so empty↔active can cross-fade. + material.Widget? _cachedActiveBody; + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final activeConn = widget.activeConnection; + + final empty = material.Align( + alignment: material.Alignment.topCenter, + child: WorkspaceEmptyHero( + onNewConnection: widget.onRequestNewConnection ?? () {}, + onNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, + onOpenSqlite: widget.onRequestOpenSqlite, + onOpenConnection: widget.onOpenConnection, + ), + ); + + final material.Widget activeBody; if (activeConn == null) { - return material.Container( - color: theme.colorScheme.background, - alignment: material.Alignment.topCenter, - child: WorkspaceEmptyHero( - onNewConnection: widget.onRequestNewConnection ?? () {}, - onNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, - onOpenSqlite: widget.onRequestOpenSqlite, - onOpenConnection: widget.onOpenConnection, - ), - ); + activeBody = _cachedActiveBody ?? const material.SizedBox.expand(); + } else { + activeBody = _buildActiveConnectionBody(theme, activeConn); + _cachedActiveBody = activeBody; } + return material.Container( + color: theme.colorScheme.background, + child: QueryaSwitchingBody( + index: activeConn == null ? 0 : 1, + children: [ + empty, + material.SizedBox.expand(child: activeBody), + ], + ), + ); + } + + material.Widget _buildActiveConnectionBody( + ThemeData theme, + ConnectionRow activeConn, + ) { material.Widget? driverWorkspace; switch (activeConn.type) { case 'postgresql': @@ -262,33 +289,27 @@ class _WorkspacePanelState extends State { } if (driverWorkspace != null) { - return material.Container( - color: theme.colorScheme.background, - child: material.SizedBox.expand(child: driverWorkspace), - ); + return driverWorkspace; } - return material.Container( - color: theme.colorScheme.background, - child: material.Center( - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisSize: MainAxisSize.min, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 36, - color: theme.colorScheme.mutedForeground, - ), - const material.SizedBox(height: 12), - const Text('Unsupported connection type').semiBold(), - const material.SizedBox(height: 6), - Text( - 'No workspace is registered for “${activeConn.type}”.', - ).muted().small(), - ], - ), + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 36, + color: theme.colorScheme.mutedForeground, + ), + const material.SizedBox(height: 12), + const Text('Unsupported connection type').semiBold(), + const material.SizedBox(height: 6), + Text( + 'No workspace is registered for “${activeConn.type}”.', + ).muted().small(), + ], ), ), ); diff --git a/test/core/motion/querya_fade_slide_test.dart b/test/core/motion/querya_fade_slide_test.dart new file mode 100644 index 00000000..112d03f1 --- /dev/null +++ b/test/core/motion/querya_fade_slide_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; + +void main() { + Widget wrap( + Widget child, { + QueryaMotionLevel level = QueryaMotionLevel.full, + }) { + return MaterialApp( + home: QueryaMotionScope( + level: level, + child: Scaffold(body: Center(child: child)), + ), + ); + } + + testWidgets('switches keyed children through AnimatedSwitcher', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('one', key: ValueKey('one')), + ), + ), + ); + expect(find.text('one'), findsOneWidget); + expect(find.byType(AnimatedSwitcher), findsOneWidget); + + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('two', key: ValueKey('two')), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('two'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('one'), findsNothing); + expect(find.text('two'), findsOneWidget); + }); + + testWidgets('keeps outgoing child briefly during transition', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('one', key: ValueKey('one')), + ), + ), + ); + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('two', key: ValueKey('two')), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 20)); + expect(find.text('one'), findsOneWidget); + expect(find.text('two'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.text('one'), findsNothing); + }); + + testWidgets('instant when motion off', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('a', key: ValueKey('a')), + ), + level: QueryaMotionLevel.off, + ), + ); + + final switcher = + tester.widget(find.byType(AnimatedSwitcher)); + expect(switcher.duration, QueryaMotion.instant); + + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('b', key: ValueKey('b')), + ), + level: QueryaMotionLevel.off, + ), + ); + await tester.pump(); + expect(find.text('b'), findsOneWidget); + expect(find.text('a'), findsNothing); + }); + + testWidgets('uses standard duration when springs enabled (full)', + (tester) async { + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('x', key: ValueKey('x')), + ), + ), + ); + final switcher = + tester.widget(find.byType(AnimatedSwitcher)); + expect(switcher.duration, QueryaMotion.standard); + expect(switcher.switchInCurve, QueryaMotion.emphasized); + }); + + testWidgets('uses fast duration when reduced (no springs)', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('x', key: ValueKey('x')), + ), + level: QueryaMotionLevel.reduced, + ), + ); + final switcher = + tester.widget(find.byType(AnimatedSwitcher)); + expect( + switcher.duration, + QueryaMotion.effectiveDuration( + tester.element(find.byType(QueryaFadeSlide)), + QueryaMotion.fast, + ), + ); + expect(switcher.switchInCurve, QueryaMotion.enter); + }); + + testWidgets('instant when OS disables animations', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Scaffold( + body: Center( + child: QueryaFadeSlide( + child: Text('x', key: ValueKey('x')), + ), + ), + ), + ), + ), + ), + ); + final switcher = + tester.widget(find.byType(AnimatedSwitcher)); + expect(switcher.duration, QueryaMotion.instant); + }); + + testWidgets('same key does not rebuild as a switch', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('same', key: ValueKey('k')), + ), + ), + ); + await tester.pumpWidget( + wrap( + const QueryaFadeSlide( + child: Text('same', key: ValueKey('k')), + ), + ), + ); + await tester.pump(); + expect(find.text('same'), findsOneWidget); + expect(find.byType(FadeTransition), findsWidgets); + }); +} diff --git a/test/core/motion/querya_hover_surface_test.dart b/test/core/motion/querya_hover_surface_test.dart new file mode 100644 index 00000000..e0afb04b --- /dev/null +++ b/test/core/motion/querya_hover_surface_test.dart @@ -0,0 +1,158 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_hover_surface.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; + +void main() { + Widget wrap( + Widget child, { + QueryaMotionLevel level = QueryaMotionLevel.full, + }) { + return MaterialApp( + home: QueryaMotionScope( + level: level, + child: Scaffold(body: Center(child: child)), + ), + ); + } + + Color? containerColor(WidgetTester tester) { + final animated = + tester.widget(find.byType(AnimatedContainer)); + final decoration = animated.decoration as BoxDecoration?; + return decoration?.color; + } + + testWidgets('starts with idle color', (tester) async { + const idle = Color(0x00000000); + await tester.pumpWidget( + wrap( + const QueryaHoverSurface( + idleColor: idle, + hoveredColor: Color(0xFF112233), + child: SizedBox(width: 80, height: 40, child: Text('row')), + ), + ), + ); + expect(containerColor(tester), idle); + }); + + testWidgets('applies hovered color on mouse enter and clears on exit', + (tester) async { + const idle = Color(0x00000000); + const hovered = Color(0xFF112233); + await tester.pumpWidget( + wrap( + const QueryaHoverSurface( + idleColor: idle, + hoveredColor: hovered, + child: SizedBox(width: 80, height: 40, child: Text('row')), + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + + await gesture.moveTo(tester.getCenter(find.text('row'))); + await tester.pump(); + expect(containerColor(tester), hovered); + + await gesture.moveTo(const Offset(0, 0)); + await tester.pump(); + expect(containerColor(tester), idle); + }); + + testWidgets('onTap fires', (tester) async { + var taps = 0; + await tester.pumpWidget( + wrap( + QueryaHoverSurface( + onTap: () => taps++, + child: const SizedBox(width: 80, height: 40, child: Text('tap')), + ), + ), + ); + await tester.tap(find.text('tap')); + expect(taps, 1); + }); + + testWidgets('AnimatedContainer uses fast motion duration', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaHoverSurface( + child: SizedBox(width: 40, height: 20), + ), + ), + ); + final animated = + tester.widget(find.byType(AnimatedContainer)); + expect(animated.duration, QueryaMotion.fast); + expect(animated.curve, QueryaMotion.enter); + }); + + testWidgets('respects motion off (instant hover transition)', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaHoverSurface( + child: SizedBox(width: 40, height: 20, child: Text('h')), + ), + level: QueryaMotionLevel.off, + ), + ); + final animated = + tester.widget(find.byType(AnimatedContainer)); + expect(animated.duration, QueryaMotion.instant); + }); + + testWidgets('uses theme fallback hovered color when unset', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), + ), + home: const QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Scaffold( + body: Center( + child: QueryaHoverSurface( + child: SizedBox(width: 40, height: 20, child: Text('theme')), + ), + ), + ), + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.text('theme'))); + await tester.pump(); + + final color = containerColor(tester); + expect(color, isNotNull); + expect(color, isNot(Colors.transparent)); + }); + + testWidgets('click cursor when onTap provided', (tester) async { + await tester.pumpWidget( + wrap( + QueryaHoverSurface( + onTap: () {}, + child: const SizedBox(width: 40, height: 20, child: Text('c')), + ), + ), + ); + final region = tester.widget( + find.descendant( + of: find.byType(QueryaHoverSurface), + matching: find.byType(MouseRegion), + ), + ); + expect(region.cursor, SystemMouseCursors.click); + }); +} diff --git a/test/core/motion/querya_spring_test.dart b/test/core/motion/querya_spring_test.dart new file mode 100644 index 00000000..0cbb61fc --- /dev/null +++ b/test/core/motion/querya_spring_test.dart @@ -0,0 +1,331 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; + +void main() { + group('QueryaSpring presets', () { + test('snappy is approximately critically damped', () { + const s = QueryaSpring.snappy; + final critical = 2 * math.sqrt(s.mass * s.stiffness); + expect(s.damping, closeTo(critical, 0.01)); + }); + + test('gentle is approximately critically damped', () { + const s = QueryaSpring.gentle; + final critical = 2 * math.sqrt(s.mass * s.stiffness); + expect(s.damping, closeTo(critical, 0.05)); + }); + + test('bouncy is underdamped relative to critical', () { + const s = QueryaSpring.bouncy; + final critical = 2 * math.sqrt(s.mass * s.stiffness); + expect(s.damping, lessThan(critical)); + }); + + test('simulation moves toward end', () { + final sim = QueryaSpring.simulation( + description: QueryaSpring.snappy, + start: 0, + end: 1, + velocity: 0, + ); + expect(sim.x(0), closeTo(0, 0.001)); + expect(sim.x(1), closeTo(1, 0.05)); + expect(sim.isDone(2), isTrue); + }); + + test('simulation with positive velocity leaves start quickly', () { + final sim = QueryaSpring.simulation( + description: QueryaSpring.bouncy, + start: 0, + end: 1, + velocity: 5, + ); + expect(sim.x(0.05), greaterThan(0)); + }); + }); + + group('QueryaSpring.springsEnabled', () { + testWidgets('true when motion is full', (tester) async { + late bool enabled; + await tester.pumpWidget( + MaterialApp( + home: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Builder( + builder: (context) { + enabled = QueryaSpring.springsEnabled(context); + expect(context.motionSpringsEnabled, enabled); + return const SizedBox(); + }, + ), + ), + ), + ); + expect(enabled, isTrue); + }); + + testWidgets('true when scope is absent (defaults to full)', (tester) async { + late bool enabled; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + enabled = QueryaSpring.springsEnabled(context); + return const SizedBox(); + }, + ), + ), + ); + expect(enabled, isTrue); + }); + + testWidgets('false when motion is reduced', (tester) async { + late bool enabled; + await tester.pumpWidget( + MaterialApp( + home: QueryaMotionScope( + level: QueryaMotionLevel.reduced, + child: Builder( + builder: (context) { + enabled = QueryaSpring.springsEnabled(context); + return const SizedBox(); + }, + ), + ), + ), + ); + expect(enabled, isFalse); + }); + + testWidgets('false when motion is off', (tester) async { + late bool enabled; + await tester.pumpWidget( + MaterialApp( + home: QueryaMotionScope( + level: QueryaMotionLevel.off, + child: Builder( + builder: (context) { + enabled = QueryaSpring.springsEnabled(context); + return const SizedBox(); + }, + ), + ), + ), + ); + expect(enabled, isFalse); + }); + + testWidgets('false when OS disables animations', (tester) async { + late bool enabled; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Builder( + builder: (context) { + enabled = QueryaSpring.springsEnabled(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + expect(enabled, isFalse); + }); + }); + + group('QueryaSpringController', () { + testWidgets('retarget preserves continuity (no jump to end)', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost(onCreated: (c) => controller = c), + ), + ); + + controller.animateTo(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + final mid = controller.value; + expect(mid, greaterThan(0)); + expect(mid, lessThan(1)); + + controller.animateTo(0); + await tester.pump(); + final afterRedirect = controller.value; + expect(afterRedirect, closeTo(mid, 0.15)); + + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(controller.value, closeTo(0, 0.01)); + expect(controller.isAnimating, isFalse); + expect(controller.velocity, 0); + }); + + testWidgets('multiple redirects settle on final target', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost(onCreated: (c) => controller = c), + ), + ); + + controller.animateTo(1); + await tester.pump(const Duration(milliseconds: 20)); + controller.animateTo(0.2); + await tester.pump(const Duration(milliseconds: 20)); + controller.animateTo(1); + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(controller.value, closeTo(1, 0.02)); + expect(controller.target, 1); + }); + + testWidgets('jumpTo cancels animation', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost(onCreated: (c) => controller = c), + ), + ); + + controller.animateTo(1); + await tester.pump(const Duration(milliseconds: 16)); + expect(controller.isAnimating, isTrue); + controller.jumpTo(0.5); + expect(controller.value, 0.5); + expect(controller.isAnimating, isFalse); + expect(controller.velocity, 0); + }); + + testWidgets('animateTo same value with zero velocity settles immediately', + (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost(onCreated: (c) => controller = c), + ), + ); + controller.jumpTo(0.3); + controller.animateTo(0.3); + expect(controller.value, 0.3); + expect(controller.isAnimating, isFalse); + }); + + testWidgets('explicit velocity handoff is accepted', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost(onCreated: (c) => controller = c), + ), + ); + controller.jumpTo(0); + controller.animateTo(1, velocity: 3); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(controller.value, greaterThan(0)); + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(controller.value, closeTo(1, 0.02)); + }); + + testWidgets('snaps when springs disabled', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + useSprings: false, + 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; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + onCreated: (c) { + controller = c; + controller.addListener(() => notifications++); + }, + ), + ), + ); + + controller.animateTo(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 32)); + expect(notifications, greaterThan(0)); + await tester.pumpAndSettle(const Duration(seconds: 2)); + }); + + testWidgets('custom spring description is used', (tester) async { + late QueryaSpringController controller; + await tester.pumpWidget( + MaterialApp( + home: _SpringHost( + spring: QueryaSpring.gentle, + onCreated: (c) => controller = c, + ), + ), + ); + expect(identical(controller.spring, QueryaSpring.gentle), isTrue); + controller.animateTo(1); + await tester.pumpAndSettle(const Duration(seconds: 3)); + expect(controller.value, closeTo(1, 0.02)); + }); + }); +} + +class _SpringHost extends StatefulWidget { + const _SpringHost({ + required this.onCreated, + this.useSprings = true, + this.spring = QueryaSpring.snappy, + }); + + final ValueChanged onCreated; + final bool useSprings; + final SpringDescription spring; + + @override + State<_SpringHost> createState() => _SpringHostState(); +} + +class _SpringHostState extends State<_SpringHost> + with SingleTickerProviderStateMixin { + late final QueryaSpringController _controller; + + @override + void initState() { + super.initState(); + _controller = QueryaSpringController( + vsync: this, + useSprings: widget.useSprings, + spring: widget.spring, + ); + widget.onCreated(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox(); +} diff --git a/test/core/motion/querya_stagger_test.dart b/test/core/motion/querya_stagger_test.dart new file mode 100644 index 00000000..e21be0ed --- /dev/null +++ b/test/core/motion/querya_stagger_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; +import 'package:querya_desktop/core/motion/querya_stagger.dart'; + +void main() { + Widget wrap( + Widget child, { + QueryaMotionLevel level = QueryaMotionLevel.full, + }) { + return MaterialApp( + home: QueryaMotionScope( + level: level, + child: Scaffold(body: child), + ), + ); + } + + List texts(int n) => [ + for (var i = 0; i < n; i++) Text('item-$i', key: ValueKey('item-$i')), + ]; + + double opacityOf(WidgetTester tester, String text) { + final opacityWidget = tester.widget( + find.ancestor(of: find.text(text), matching: find.byType(Opacity)).first, + ); + return opacityWidget.opacity; + } + + testWidgets('empty children render SizedBox.shrink', (tester) async { + await tester.pumpWidget(wrap(const QueryaStagger(children: []))); + expect(find.byType(QueryaStagger), findsOneWidget); + expect(find.byType(Column), findsNothing); + expect(find.textContaining('item-'), findsNothing); + }); + + testWidgets('first paint staggers then reaches full opacity', (tester) async { + await tester.pumpWidget(wrap(QueryaStagger(children: texts(4)))); + + await tester.pump(const Duration(milliseconds: 40)); + final early = opacityOf(tester, 'item-0'); + final late = opacityOf(tester, 'item-3'); + expect(early, greaterThanOrEqualTo(late)); + + await tester.pumpAndSettle(); + for (var i = 0; i < 4; i++) { + expect(opacityOf(tester, 'item-$i'), 1.0); + } + }); + + testWidgets('items beyond maxStaggered start fully opaque', (tester) async { + await tester.pumpWidget( + wrap( + QueryaStagger( + maxStaggered: 2, + children: texts(4), + ), + ), + ); + await tester.pump(); + expect(opacityOf(tester, 'item-2'), 1.0); + expect(opacityOf(tester, 'item-3'), 1.0); + }); + + testWidgets('motion off skips stagger (all opaque immediately)', + (tester) async { + await tester.pumpWidget( + wrap( + QueryaStagger(children: texts(3)), + level: QueryaMotionLevel.off, + ), + ); + await tester.pump(); + for (var i = 0; i < 3; i++) { + expect(opacityOf(tester, 'item-$i'), 1.0); + } + }); + + testWidgets('plays only once across rebuilds', (tester) async { + await tester.pumpWidget(wrap(QueryaStagger(children: texts(2)))); + await tester.pumpAndSettle(); + + await tester.pumpWidget(wrap(QueryaStagger(children: texts(2)))); + await tester.pump(); + expect(opacityOf(tester, 'item-0'), 1.0); + expect(opacityOf(tester, 'item-1'), 1.0); + }); + + testWidgets('custom step affects stagger order timing', (tester) async { + await tester.pumpWidget( + wrap( + QueryaStagger( + step: const Duration(milliseconds: 80), + children: texts(3), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 30)); + expect(opacityOf(tester, 'item-0'), greaterThan(0)); + expect(opacityOf(tester, 'item-2'), 0); + await tester.pumpAndSettle(); + expect(opacityOf(tester, 'item-2'), 1.0); + }); + + testWidgets('OS disableAnimations skips stagger', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: QueryaMotionScope( + level: QueryaMotionLevel.full, + child: Scaffold( + body: QueryaStagger(children: texts(3)), + ), + ), + ), + ), + ); + await tester.pump(); + for (var i = 0; i < 3; i++) { + expect(opacityOf(tester, 'item-$i'), 1.0); + } + }); +} diff --git a/test/core/motion/querya_switching_body_test.dart b/test/core/motion/querya_switching_body_test.dart new file mode 100644 index 00000000..07e19353 --- /dev/null +++ b/test/core/motion/querya_switching_body_test.dart @@ -0,0 +1,302 @@ +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_scope.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; + +void main() { + Widget wrap( + Widget child, { + QueryaMotionLevel level = QueryaMotionLevel.full, + }) { + return MaterialApp( + home: QueryaMotionScope( + level: level, + child: Scaffold(body: child), + ), + ); + } + + testWidgets('keeps inactive child mounted and excludes focus', (tester) async { + final focusA = FocusNode(); + final focusB = FocusNode(); + addTearDown(focusA.dispose); + addTearDown(focusB.dispose); + + await tester.pumpWidget( + wrap( + QueryaSwitchingBody( + index: 0, + children: [ + TextField(key: const Key('a'), focusNode: focusA), + TextField(key: const Key('b'), focusNode: focusB), + ], + ), + ), + ); + + expect(find.byKey(const Key('a')), findsOneWidget); + expect(find.byKey(const Key('b')), findsOneWidget); + + focusB.requestFocus(); + await tester.pump(); + expect(focusB.hasFocus, isFalse); + + await tester.pumpWidget( + wrap( + QueryaSwitchingBody( + index: 1, + children: [ + TextField(key: const Key('a'), focusNode: focusA), + TextField(key: const Key('b'), focusNode: focusB), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + focusB.requestFocus(); + await tester.pump(); + expect(focusB.hasFocus, isTrue); + }); + + testWidgets('preserves StatefulWidget state across index switches', + (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + children: [ + _CounterPane(key: Key('pane-a'), label: 'A'), + _CounterPane(key: Key('pane-b'), label: 'B'), + ], + ), + ), + ); + + await tester.tap(find.text('A:0')); + await tester.pump(); + expect(find.text('A:1'), findsOneWidget); + + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 1, + children: [ + _CounterPane(key: Key('pane-a'), label: 'A'), + _CounterPane(key: Key('pane-b'), label: 'B'), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('B:0'), findsOneWidget); + + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + children: [ + _CounterPane(key: Key('pane-a'), label: 'A'), + _CounterPane(key: Key('pane-b'), label: 'B'), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('A:1'), findsOneWidget); + }); + + testWidgets('inactive layer ignores pointer events', (tester) async { + var tapsA = 0; + var tapsB = 0; + + await tester.pumpWidget( + wrap( + QueryaSwitchingBody( + index: 0, + children: [ + GestureDetector( + key: const Key('a'), + onTap: () => tapsA++, + child: const SizedBox.expand(child: ColoredBox(color: Colors.red)), + ), + GestureDetector( + key: const Key('b'), + onTap: () => tapsB++, + child: + const SizedBox.expand(child: ColoredBox(color: Colors.blue)), + ), + ], + ), + ), + ); + + await tester.tap(find.byKey(const Key('a'))); + expect(tapsA, 1); + expect(tapsB, 0); + + // Center still hits active layer only. + await tester.tapAt(tester.getCenter(find.byType(QueryaSwitchingBody))); + expect(tapsA, 2); + expect(tapsB, 0); + }); + + testWidgets('clamps out-of-range index', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 99, + children: [ + Text('only', key: Key('only')), + Text('other', key: Key('other')), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final opacities = tester.widgetList( + find.byType(AnimatedOpacity), + ); + expect(opacities.length, 2); + expect(opacities.last.opacity, 1.0); + expect(opacities.first.opacity, 0.0); + }); + + testWidgets('uses AnimatedSlide when slide is non-zero', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + slide: Offset(0.02, 0), + children: [ + Text('a'), + Text('b'), + ], + ), + ), + ); + expect(find.byType(AnimatedSlide), findsNWidgets(2)); + }); + + testWidgets('skips AnimatedSlide when slide is zero', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + slide: Offset.zero, + children: [ + Text('a'), + Text('b'), + ], + ), + ), + ); + expect(find.byType(AnimatedSlide), findsNothing); + expect(find.byType(AnimatedOpacity), findsNWidgets(2)); + }); + + testWidgets('full motion uses standard duration', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + children: [Text('a'), Text('b')], + ), + ), + ); + final opacity = tester.widget( + find.byType(AnimatedOpacity).first, + ); + expect(opacity.duration, QueryaMotion.standard); + }); + + testWidgets('motion off uses instant duration', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + children: [Text('a'), Text('b')], + ), + level: QueryaMotionLevel.off, + ), + ); + final opacity = tester.widget( + find.byType(AnimatedOpacity).first, + ); + expect(opacity.duration, QueryaMotion.instant); + }); + + testWidgets('reduced motion disables springs path (fast halved)', + (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + children: [Text('a'), Text('b')], + ), + level: QueryaMotionLevel.reduced, + ), + ); + final opacity = tester.widget( + find.byType(AnimatedOpacity).first, + ); + expect( + opacity.duration, + QueryaMotion.effectiveDuration( + tester.element(find.byType(QueryaSwitchingBody)), + QueryaMotion.fast, + ), + ); + }); + + testWidgets('ExcludeSemantics excludes inactive child', (tester) async { + await tester.pumpWidget( + wrap( + const QueryaSwitchingBody( + index: 0, + children: [ + Text('active'), + Text('inactive'), + ], + ), + ), + ); + + final excludes = tester + .widgetList( + find.descendant( + of: find.byType(QueryaSwitchingBody), + matching: find.byType(ExcludeSemantics), + ), + ) + .toList(); + expect(excludes.length, 2); + expect(excludes.first.excluding, isFalse); + expect(excludes.last.excluding, isTrue); + }); +} + +class _CounterPane extends StatefulWidget { + const _CounterPane({super.key, required this.label}); + + final String label; + + @override + State<_CounterPane> createState() => _CounterPaneState(); +} + +class _CounterPaneState extends State<_CounterPane> { + int _count = 0; + + @override + Widget build(BuildContext context) { + return Center( + child: TextButton( + onPressed: () => setState(() => _count++), + child: Text('${widget.label}:$_count'), + ), + ); + } +} diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index b7fb646a..8d10a60a 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -97,5 +98,47 @@ void main() { expect(find.text('Query History'), findsNothing); expect(find.textContaining('Coming in a future release'), findsNothing); }); + + testWidgets('empty↔connected morph uses QueryaSwitchingBody', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(), + ), + ), + ); + expect(find.byType(QueryaSwitchingBody), findsOneWidget); + + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: stubSplitWorkspaceConnection, + ), + ), + ), + ); + await tester.pump(); + expect(find.byType(QueryaSwitchingBody), findsOneWidget); + expect(find.text('Unsupported connection type'), findsOneWidget); + + // Back to empty — keep-alive stack stays mounted. + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(QueryaSwitchingBody), findsOneWidget); + }); }); }