From fbe8d1d9234eea40d7564b37ec97c1df188d1cff Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 15 Jun 2026 13:09:48 +0300 Subject: [PATCH] feat(motion): smoother transitions for menus, tree, and tabs Add QueryaAnimatedExpand and QueryaCrossFadeStack; dropdown menu enter fade+scale, connection tree AnimatedSize expand/collapse, and cross-fade tab bodies in workspace and SQL home views. Closes #172 --- lib/core/motion/querya_animated_expand.dart | 31 ++++++++++++++ lib/core/motion/querya_cross_fade_stack.dart | 41 ++++++++++++++++++ .../connections/connections_panel.dart | 1 + .../connections/connections_panel_mongo.dart | 9 +++- .../connections/connections_panel_mysql.dart | 20 +++++++-- .../connections_panel_pg_tree.dart | 40 +++++++++++++----- ...connections_panel_postgres_connection.dart | 9 +++- .../connections/connections_panel_redis.dart | 9 +++- .../connections_panel_sidebar.dart | 6 ++- lib/features/main_screen/workspace_panel.dart | 5 ++- lib/features/mysql/mysql_workspace_home.dart | 4 +- .../postgresql/postgres_workspace_home.dart | 4 +- lib/shared/widgets/querya_dropdown.dart | 42 ++++++++++++++++++- .../motion/querya_animated_expand_test.dart | 39 +++++++++++++++++ 14 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 lib/core/motion/querya_animated_expand.dart create mode 100644 lib/core/motion/querya_cross_fade_stack.dart create mode 100644 test/core/motion/querya_animated_expand_test.dart diff --git a/lib/core/motion/querya_animated_expand.dart b/lib/core/motion/querya_animated_expand.dart new file mode 100644 index 00000000..5f837e0b --- /dev/null +++ b/lib/core/motion/querya_animated_expand.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; + +/// Animates height when [expanded] toggles (connection tree sections, etc.). +class QueryaAnimatedExpand extends StatelessWidget { + const QueryaAnimatedExpand({ + super.key, + required this.expanded, + required this.child, + this.alignment = Alignment.topCenter, + }); + + final bool expanded; + final Widget child; + final Alignment alignment; + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: context.motionDuration(QueryaMotion.standard), + curve: context.motionCurve(QueryaMotion.enter), + alignment: alignment, + clipBehavior: Clip.hardEdge, + child: expanded + ? child + : const SizedBox(width: double.infinity, height: 0), + ); + } +} diff --git a/lib/core/motion/querya_cross_fade_stack.dart b/lib/core/motion/querya_cross_fade_stack.dart new file mode 100644 index 00000000..54ec1407 --- /dev/null +++ b/lib/core/motion/querya_cross_fade_stack.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'querya_motion.dart'; +import 'querya_motion_context.dart'; + +/// Like [IndexedStack] but cross-fades the active child; off-screen children +/// stay mounted (preserves SQL editor state, etc.). +class QueryaCrossFadeStack extends StatelessWidget { + const QueryaCrossFadeStack({ + super.key, + required this.index, + required this.children, + }); + + final int index; + final List children; + + @override + Widget build(BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); + + return Stack( + fit: StackFit.expand, + children: [ + for (var i = 0; i < children.length; i++) + Positioned.fill( + child: IgnorePointer( + ignoring: index != i, + child: AnimatedOpacity( + opacity: index == i ? 1 : 0, + duration: duration, + curve: curve, + child: children[i], + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 0e8d01d9..fcaf578c 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -8,6 +8,7 @@ import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index c512fe2c..ca0a9b46 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -229,7 +229,12 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ], ), // Expanded database children - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), @@ -301,6 +306,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { }, ), ], + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index a56fc202..84b191d8 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -194,7 +194,12 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), ], ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -236,6 +241,8 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, ), ], + ), + ), ], ), ), @@ -412,7 +419,12 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { : (c, {database, schema, name, kind}) => widget.onMysqlOpenSqlWorkspace!(c), ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -541,7 +553,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ], ), ), - ], + ], + ), + ), ], ), ); diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 6c0a7804..60859e26 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -234,8 +234,9 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { onContextRefresh: widget.onRefreshDatabases, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) - lazyConnectionTreeList( + QueryaAnimatedExpand( + expanded: _expanded, + child: lazyConnectionTreeList( context: context, itemCount: widget.databases.length, itemBuilder: (context, index) { @@ -249,6 +250,7 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { ); }, ), + ), ], ), ); @@ -350,7 +352,12 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { onContextRefresh: _loadSchemas, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ _PgDbToolRow( connection: widget.connection, databaseName: widget.databaseName, @@ -397,7 +404,9 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, onRefreshSchemas: _loadSchemas, ), - ], + ], + ), + ), ], ), ); @@ -531,8 +540,9 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { onContextRefresh: widget.onRefreshSchemas, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) - lazyConnectionTreeList( + QueryaAnimatedExpand( + expanded: _expanded, + child: lazyConnectionTreeList( context: context, itemCount: widget.schemas.length, itemBuilder: (context, index) { @@ -549,6 +559,7 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { ); }, ), + ), ], ), ); @@ -672,7 +683,12 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { onContextRefresh: _loadObjects, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -825,7 +841,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { onContextRefresh: _loadObjects, ), ], - ], + ], + ), + ), ], ), ); @@ -963,8 +981,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { onContextRefresh: widget.onRefresh, onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), - if (_expanded) - lazyConnectionTreeList( + QueryaAnimatedExpand( + expanded: _expanded, + child: lazyConnectionTreeList( context: context, itemCount: widget.items.length, itemExtent: kConnectionTreeRowExtent, @@ -998,6 +1017,7 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { ); }, ), + ), ], ), ); diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index a3914b84..4509361f 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -189,7 +189,12 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), ], ), - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: @@ -231,6 +236,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { }, ), ], + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 7e3fda45..4245f5b6 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -208,7 +208,12 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { ], ), // Expanded database children — ALL 16 databases - if (_expanded) ...[ + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ if (_loading) material.Padding( padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), @@ -242,6 +247,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { onTap: () => widget.onDatabaseTap?.call(db.index), ), ], + ), + ), ], ), ), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index c56c4537..2e98ec37 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -272,8 +272,9 @@ class _FolderTileState extends State<_FolderTile> { ), ), ), - if (_expanded) - material.Padding( + QueryaAnimatedExpand( + expanded: _expanded, + child: material.Padding( padding: const material.EdgeInsets.only(left: 24), child: lazyConnectionTreeList( context: context, @@ -295,6 +296,7 @@ class _FolderTileState extends State<_FolderTile> { }, ), ), + ), ], ), ), diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 9af0f1cf..1ec7e518 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 Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.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/storage/local_db.dart'; @@ -212,7 +213,7 @@ class _WorkspacePanelState extends State { ), const Divider(height: 1), Expanded( - child: IndexedStack( + child: QueryaCrossFadeStack( index: _editorTabIndex, children: const [ QueryEditorTab(), @@ -233,7 +234,7 @@ class _WorkspacePanelState extends State { ), const Divider(height: 1), Expanded( - child: IndexedStack( + child: QueryaCrossFadeStack( index: _outputTabIndex, children: const [ ResultsTab(), diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index faa1ebe3..7a5860d4 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.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/storage/local_db.dart'; @@ -109,9 +110,8 @@ class _MysqlWorkspaceHomeState extends material.State { ), const Divider(height: 1), Expanded( - child: material.IndexedStack( + child: QueryaCrossFadeStack( index: _tab, - sizing: material.StackFit.expand, children: [ MysqlStatsView( key: ValueKey('mysql_stats_${widget.connectionRow.id}'), diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index eb62732e..e80ee431 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/motion/querya_cross_fade_stack.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/storage/local_db.dart'; @@ -152,9 +153,8 @@ class _PostgresWorkspaceHomeState extends material.State ), const Divider(height: 1), Expanded( - child: material.IndexedStack( + child: QueryaCrossFadeStack( index: _tab, - sizing: material.StackFit.expand, children: [ PostgresStatsView( key: ValueKey('pg_stats_${widget.connectionRow.id}'), diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 580640d4..1e6eb8c1 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -59,6 +59,7 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; bool _triggerHovered = false; + bool _menuOpen = false; List? _cachedMenuChildren; List>? _cachedMenuItems; T? _cachedMenuValue; @@ -229,6 +230,8 @@ class _QueryaDropdownState extends material.State> { final anchor = material.MenuAnchor( controller: _controller, + onOpen: () => setState(() => _menuOpen = true), + onClose: () => setState(() => _menuOpen = false), crossAxisUnconstrained: false, alignmentOffset: material.Offset( widget.alignmentOffset.dx, @@ -257,7 +260,16 @@ class _QueryaDropdownState extends material.State> { ), ), ), - menuChildren: menuChildren, + menuChildren: [ + _QueryaDropdownMenuEnter( + open: _menuOpen, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: menuChildren, + ), + ), + ], builder: (context, controller, child) { final trigger = _buildTrigger( context: context, @@ -280,6 +292,34 @@ class _QueryaDropdownState extends material.State> { } } +class _QueryaDropdownMenuEnter extends material.StatelessWidget { + const _QueryaDropdownMenuEnter({ + required this.open, + required this.child, + }); + + final bool open; + final material.Widget child; + + @override + material.Widget build(material.BuildContext context) { + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); + return material.AnimatedScale( + scale: open ? 1 : 0.96, + alignment: material.Alignment.topCenter, + duration: duration, + curve: curve, + child: material.AnimatedOpacity( + opacity: open ? 1 : 0, + duration: duration, + curve: curve, + child: child, + ), + ); + } +} + class _QueryaDropdownMenuItem extends material.StatefulWidget { const _QueryaDropdownMenuItem({ required this.item, diff --git a/test/core/motion/querya_animated_expand_test.dart b/test/core/motion/querya_animated_expand_test.dart new file mode 100644 index 00000000..c92a4045 --- /dev/null +++ b/test/core/motion/querya_animated_expand_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; + +void main() { + testWidgets('QueryaAnimatedExpand hides child when collapsed', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: false), + ), + ); + + expect(find.text('child'), findsNothing); + }); + + testWidgets('QueryaAnimatedExpand shows child when expanded', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: true), + ), + ); + + expect(find.text('child'), findsOneWidget); + }); +} + +class _ExpandHost extends StatelessWidget { + const _ExpandHost({required this.expanded}); + + final bool expanded; + + @override + Widget build(BuildContext context) { + return QueryaAnimatedExpand( + expanded: expanded, + child: const Text('child'), + ); + } +}