From a14f472f3f7b60ab31c6dbdb90d0f10298568aa3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:21:11 +0300 Subject: [PATCH] ui(motion): unify tree expand chevron and height tokens (#480) Add QueryaMotion.treeExpand / treeExpandCurve and wire both QueryaAnimatedExpand and connection/SDUI tree chevrons to the same pair so one expand gesture no longer finishes on two clocks. Closes #480 --- docs/motion-and-high-refresh.md | 9 +++++++-- lib/core/motion/querya_animated_expand.dart | 4 ++-- lib/core/motion/querya_motion.dart | 9 ++++++++- lib/core/sdui/sdui_tree_builder.dart | 12 +++++------ .../connections_panel_extension.dart | 4 ++-- .../connections/connections_panel_mongo.dart | 4 ++-- .../connections/connections_panel_mysql.dart | 12 +++++------ .../connections_panel_pg_tree.dart | 20 +++++++++---------- ...connections_panel_postgres_connection.dart | 4 ++-- .../connections/connections_panel_redis.dart | 4 ++-- .../connections_panel_sidebar.dart | 4 ++-- .../connections/connections_panel_sqlite.dart | 8 ++++---- .../motion/querya_animated_expand_test.dart | 13 ++++++++++++ test/core/motion/querya_motion_test.dart | 2 ++ 14 files changed, 68 insertions(+), 41 deletions(-) diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index 9fa2d07b..cb77c4d9 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -82,7 +82,8 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur |-------|-------|-----| | `instant` | 0 ms | reduced-motion / disabled | | `fast` | 120 ms | hover, small state changes | -| `standard` | 200 ms | dialogs, menus, expand/collapse | +| `standard` | 200 ms | dialogs, menus, general surfaces | +| `treeExpand` | 200 ms (= `standard`) | connection/SDUI tree: **chevron + height** share one clock (#480) | | `slow` | 320 ms | emphasized / large surfaces, theme cross-fade | ### 4.2 Curve tokens @@ -91,7 +92,8 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur |-------|-------|-----| | `enter` | `easeOutCubic` | elements appearing (decelerate) | | `exit` | `easeInCubic` | elements leaving (accelerate) | -| `standard` | `easeInOutCubic` | move/resize in place | +| `standardCurve` | `easeInOutCubic` | move/resize in place | +| `treeExpandCurve` | = `enter` | tree expand chevron + `QueryaAnimatedExpand` | | `emphasized` | `Curves.easeInOutCubicEmphasized` | hero / theme transitions | ### 4.3 Reduced motion / accessibility @@ -151,6 +153,9 @@ When reviewing PRs that touch animation: 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. +5. Tree expand: chevron `AnimatedRotation` and `QueryaAnimatedExpand` **must** use + `QueryaMotion.treeExpand` + `treeExpandCurve` (not `fast`/`standardCurve` mixed + with `standard`/`enter`). **Allowed named non-token durations** (named + documented — not magic literals at call sites): diff --git a/lib/core/motion/querya_animated_expand.dart b/lib/core/motion/querya_animated_expand.dart index 96dd97c5..62108875 100644 --- a/lib/core/motion/querya_animated_expand.dart +++ b/lib/core/motion/querya_animated_expand.dart @@ -19,8 +19,8 @@ class QueryaAnimatedExpand extends StatelessWidget { @override Widget build(BuildContext context) { return AnimatedSize( - duration: context.motionDuration(QueryaMotion.standard), - curve: context.motionCurve(QueryaMotion.enter), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), alignment: alignment, clipBehavior: Clip.hardEdge, child: diff --git a/lib/core/motion/querya_motion.dart b/lib/core/motion/querya_motion.dart index d7064e3c..14efdd37 100644 --- a/lib/core/motion/querya_motion.dart +++ b/lib/core/motion/querya_motion.dart @@ -14,12 +14,16 @@ abstract final class QueryaMotion { /// Hover, small state changes. static const Duration fast = Duration(milliseconds: 120); - /// Dialogs, menus, expand/collapse. + /// Dialogs, menus, general surface transitions. static const Duration standard = Duration(milliseconds: 200); /// Emphasized transitions (theme cross-fade, large surfaces). static const Duration slow = Duration(milliseconds: 320); + /// Connection / SDUI tree expand: chevron rotation **and** height morph share + /// this duration so one gesture does not finish on two clocks (#480). + static const Duration treeExpand = standard; + /// Elements appearing (decelerate). static const Curve enter = Curves.easeOutCubic; @@ -32,6 +36,9 @@ abstract final class QueryaMotion { /// Hero / theme transitions. static const Curve emphasized = Curves.easeInOutCubicEmphasized; + /// Curve for [treeExpand] (chevron + [QueryaAnimatedExpand] height). + static const Curve treeExpandCurve = enter; + /// Returns [token] adjusted for accessibility and [QueryaMotionScope] level. static Duration effectiveDuration(BuildContext context, Duration token) { if (token == instant) return instant; diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index fc845187..4324062e 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -10,10 +10,10 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// /// Visible rows are flattened into a [ListView.builder] so only viewport /// rows are built (large schemas no longer create a full widget Column). -/// Expand chevrons use [QueryaMotion] tokens (same dialect as native trees). -/// Height morph via [QueryaAnimatedExpand] is not used here: nested expand -/// widgets conflict with the flat virtualized row list (see issue #480 for -/// coordinated expand timing across trees). +/// Expand chevrons and height morph share [QueryaMotion.treeExpand] / +/// [QueryaMotion.treeExpandCurve]. Height morph via [QueryaAnimatedExpand] is +/// not used on the flat virtualized row list (nested expand would fight +/// `ListView` itemExtent); chevron timing still matches native trees. class SduiTreeBuilder extends material.StatefulWidget { const SduiTreeBuilder({ super.key, @@ -233,8 +233,8 @@ class SduiTreeBuilderState extends material.State { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 28743426..05077770 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -181,8 +181,8 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: widget.isExpanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: 16, diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index a34e064a..a1193b36 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -197,8 +197,8 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index eaf0e582..069001bd 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -162,8 +162,8 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: 16, @@ -440,8 +440,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -632,8 +632,8 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 4281af75..ad5f8385 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -213,8 +213,8 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { label: 'Databases (${widget.databases.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -340,8 +340,8 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { label: widget.databaseName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -535,8 +535,8 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { label: 'Schemas (${widget.schemas.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -691,8 +691,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { label: widget.schemaName, leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, @@ -1009,8 +1009,8 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 5bccba40..ec01f98b 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -157,8 +157,8 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 2e01abfa..472ae2d2 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -174,8 +174,8 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 16, diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 8a540a91..f8ba2a11 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -253,8 +253,8 @@ class _FolderTileState extends State<_FolderTile> { children: [ material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( material.Icons.chevron_right_rounded, size: 18, diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 1456068c..767fcb7f 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -169,8 +169,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { padding: const material.EdgeInsets.all(2), child: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: 16, @@ -362,8 +362,8 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { label: '${widget.label} (${widget.items.length})', leading: material.AnimatedRotation( turns: _expanded ? 0.25 : 0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.standardCurve), + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), child: material.Icon( QueryaIcons.expandClosed, size: QueryaIconSizes.treeExpand, diff --git a/test/core/motion/querya_animated_expand_test.dart b/test/core/motion/querya_animated_expand_test.dart index 362a3941..c39090f3 100644 --- a/test/core/motion/querya_animated_expand_test.dart +++ b/test/core/motion/querya_animated_expand_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; +import 'package:querya_desktop/core/motion/querya_motion.dart'; void main() { testWidgets('QueryaAnimatedExpand hides child when collapsed', @@ -23,6 +24,18 @@ void main() { expect(find.text('child'), findsOneWidget); }); + + testWidgets('uses treeExpand duration/curve tokens', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: _ExpandHost(expanded: true), + ), + ); + + final size = tester.widget(find.byType(AnimatedSize)); + expect(size.duration, QueryaMotion.treeExpand); + expect(size.curve, QueryaMotion.treeExpandCurve); + }); } class _ExpandHost extends StatelessWidget { diff --git a/test/core/motion/querya_motion_test.dart b/test/core/motion/querya_motion_test.dart index a7d91d0c..aa26700f 100644 --- a/test/core/motion/querya_motion_test.dart +++ b/test/core/motion/querya_motion_test.dart @@ -11,6 +11,7 @@ void main() { expect(QueryaMotion.fast, const Duration(milliseconds: 120)); expect(QueryaMotion.standard, const Duration(milliseconds: 200)); expect(QueryaMotion.slow, const Duration(milliseconds: 320)); + expect(QueryaMotion.treeExpand, QueryaMotion.standard); }); test('curve constants are set', () { @@ -18,6 +19,7 @@ void main() { expect(QueryaMotion.exit, Curves.easeInCubic); expect(QueryaMotion.standardCurve, Curves.easeInOutCubic); expect(QueryaMotion.emphasized, Curves.easeInOutCubicEmphasized); + expect(QueryaMotion.treeExpandCurve, QueryaMotion.enter); }); });