From 3387126a5b0dfc8887c13c01a69cb36aa857cde1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:13:08 +0300 Subject: [PATCH 01/40] fix(macos): correct SPM package source path for refresh_rate plugin (#555) --- third_party/refresh_rate/macos/refresh_rate/Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/third_party/refresh_rate/macos/refresh_rate/Package.swift b/third_party/refresh_rate/macos/refresh_rate/Package.swift index b4ad3b46..24bee2af 100644 --- a/third_party/refresh_rate/macos/refresh_rate/Package.swift +++ b/third_party/refresh_rate/macos/refresh_rate/Package.swift @@ -25,10 +25,10 @@ let package = Package( dependencies: [ .product(name: "FlutterFramework", package: "FlutterFramework"), ], - path: "Classes", + path: "../Classes", publicHeadersPath: ".", cSettings: [ - .headerSearchPath("Classes"), + .headerSearchPath("../Classes"), ] ), ] From 888099db9f07b76c0bd51828567472fa58447cb2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:13:40 +0300 Subject: [PATCH 02/40] fix(macos): add missing network.client and user-selected file entitlements (#556) --- macos/Runner/DebugProfile.entitlements | 4 ++++ macos/Runner/Release.entitlements | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a30..9158f3eb 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,7 +6,11 @@ com.apple.security.cs.allow-jit + com.apple.security.network.client + com.apple.security.network.server + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a4..c1a22b8a 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,11 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + From 45df67380f56a8a6d7d7d8d7ca4f54f0e44674a6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:15:37 +0300 Subject: [PATCH 03/40] feat(grid): manual column drag-resizing in VirtualResultGrid (#548) --- .../main_screen/result_grid_view.dart | 122 +++++++++++++++--- .../main_screen/results_tab_test.dart | 44 +++++++ 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index f56c3779..ec1abd52 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -190,6 +190,7 @@ class _VirtualResultGridState extends material.State { List _columnWidths = const []; List _columnOffsets = const [0]; bool _widthsNeedUpdate = true; + bool _userHasResized = false; double _scrollOffset = 0; @override @@ -209,6 +210,9 @@ class _VirtualResultGridState extends material.State { super.didUpdateWidget(oldWidget); if (oldWidget.columns != widget.columns || oldWidget.rows != widget.rows) { _widthsNeedUpdate = true; + if (oldWidget.columns != widget.columns) { + _userHasResized = false; + } } } @@ -227,6 +231,20 @@ class _VirtualResultGridState extends material.State { setState(() => _scrollOffset = offset); } + void _onColumnResize(int index, double delta) { + if (index < 0 || index >= _columnWidths.length) return; + setState(() { + _userHasResized = true; + final minWidth = context.scaled(ResultGridMetrics.minColumnWidth); + final maxWidth = context.scaled(ResultGridMetrics.maxColumnWidth * 3); + final newWidth = (_columnWidths[index] + delta).clamp(minWidth, maxWidth); + _columnWidths = List.from(_columnWidths); + _columnWidths[index] = newWidth; + _columnOffsets = computeResultGridColumnOffsets(_columnWidths); + _widthsNeedUpdate = false; + }); + } + List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, @@ -264,7 +282,7 @@ class _VirtualResultGridState extends material.State { @override material.Widget build(material.BuildContext context) { - if (_widthsNeedUpdate) { + if (_widthsNeedUpdate && !_userHasResized) { _columnWidths = _computeColumnWidths(); _columnOffsets = computeResultGridColumnOffsets(_columnWidths); _widthsNeedUpdate = false; @@ -280,14 +298,14 @@ class _VirtualResultGridState extends material.State { var displayWidths = _columnWidths; var tableWidth = _tableWidth; - if (tableWidth < availableWidth && _columnWidths.isNotEmpty) { + if (!_userHasResized && + tableWidth < availableWidth && + _columnWidths.isNotEmpty) { final extraPerCol = (availableWidth - tableWidth) / _columnWidths.length; displayWidths = [for (final w in _columnWidths) w + extraPerCol]; tableWidth = availableWidth; - } else if (tableWidth > availableWidth) { - tableWidth = _tableWidth; - } else { + } else if (tableWidth < availableWidth) { tableWidth = availableWidth; } @@ -311,6 +329,7 @@ class _VirtualResultGridState extends material.State { window: window, height: headerHeight, colorScheme: cs, + onResizeColumn: _onColumnResize, ), material.Expanded( child: material.Scrollbar( @@ -354,6 +373,7 @@ class _HeaderRow extends material.StatelessWidget { required this.window, required this.height, required this.colorScheme, + this.onResizeColumn, }); final List columns; @@ -361,6 +381,7 @@ class _HeaderRow extends material.StatelessWidget { final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; + final void Function(int index, double delta)? onResizeColumn; @override material.Widget build(material.BuildContext context) { @@ -379,11 +400,13 @@ class _HeaderRow extends material.StatelessWidget { if (window.leadingWidth > 0) material.SizedBox(width: window.leadingWidth), for (var i = window.first; i <= window.last; i++) - _GridCell( + _HeaderCell( text: columns[i], width: columnWidths[i], - isHeader: true, colorScheme: colorScheme, + onResize: onResizeColumn != null + ? (delta) => onResizeColumn!(i, delta) + : null, ), if (window.trailingWidth > 0) material.SizedBox(width: window.trailingWidth), @@ -393,6 +416,76 @@ class _HeaderRow extends material.StatelessWidget { } } +class _HeaderCell extends material.StatelessWidget { + const _HeaderCell({ + required this.text, + required this.width, + required this.colorScheme, + this.onResize, + }); + + final String text; + final double width; + final ColorScheme colorScheme; + final material.ValueChanged? onResize; + + @override + material.Widget build(material.BuildContext context) { + final style = material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: colorScheme.foreground, + ); + + return material.Container( + width: width, + height: double.infinity, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Stack( + clipBehavior: material.Clip.none, + children: [ + material.Positioned.fill( + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Align( + alignment: material.Alignment.centerLeft, + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ), + if (onResize != null) + material.Positioned( + right: -4, + top: 0, + bottom: 0, + width: 10, + child: material.MouseRegion( + cursor: material.SystemMouseCursors.resizeColumn, + child: material.GestureDetector( + behavior: material.HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) { + onResize!(details.delta.dx); + }, + ), + ), + ), + ], + ), + ); + } +} + class _DataRow extends material.StatelessWidget { const _DataRow({ super.key, @@ -450,25 +543,22 @@ class _GridCell extends material.StatelessWidget { required this.text, required this.width, required this.colorScheme, - this.isHeader = false, }); final String text; final double width; final ColorScheme colorScheme; - final bool isHeader; @override material.Widget build(material.BuildContext context) { - final isNull = !isHeader && text == 'NULL'; + final isNull = text == 'NULL'; final style = material.TextStyle( - fontSize: isHeader ? 12 : 12, - fontWeight: - isHeader ? material.FontWeight.w600 : material.FontWeight.normal, - fontFamily: isHeader ? null : 'monospace', + fontSize: 12, + fontWeight: material.FontWeight.normal, + fontFamily: 'monospace', color: isNull ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : (isHeader ? colorScheme.foreground : colorScheme.foreground), + : colorScheme.foreground, fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, ); @@ -491,8 +581,6 @@ class _GridCell extends material.StatelessWidget { ), ); - if (isHeader) return cell; - final interactiveCell = material.GestureDetector( onSecondaryTap: () => Clipboard.setData(ClipboardData(text: text)), child: cell, diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 2ed4a56a..b7543f0c 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -494,5 +494,49 @@ void main() { ); expect(find.text('Rows affected: 4'), findsOneWidget); }); + + testWidgets('allows manual column drag-resizing in VirtualResultGrid', + (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'username', 'email'], + rows: [ + ['1', 'alice', 'alice@example.com'], + ['2', 'bob', 'bob@example.com'], + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('id'), findsOneWidget); + expect(find.text('username'), findsOneWidget); + expect(find.text('email'), findsOneWidget); + + // Verify resize handles are rendered with resizeColumn cursor + final resizeRegions = find.byWidgetPredicate( + (w) => + w is material.MouseRegion && + w.cursor == material.SystemMouseCursors.resizeColumn, + ); + expect(resizeRegions, findsNWidgets(3)); + + // Drag the first column handle by +50 pixels + final firstHandle = resizeRegions.first; + final startCenter = tester.getCenter(firstHandle); + await tester.dragFrom(startCenter, const material.Offset(50, 0)); + await tester.pumpAndSettle(); + + // Column headers and rows remain stable and rendered + expect(find.text('alice'), findsOneWidget); + expect(find.text('bob'), findsOneWidget); + }); }); } From 2b95ff5c7452f20ee68892cb1545892f1eccaf01 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:17:21 +0300 Subject: [PATCH 04/40] feat(grid): client-side column sorting in VirtualResultGrid (#549) --- .../main_screen/result_grid_view.dart | 149 ++++++++++++++++-- .../main_screen/results_tab_test.dart | 96 +++++++++++ 2 files changed, 233 insertions(+), 12 deletions(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index ec1abd52..ad38ed86 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -168,6 +168,55 @@ ResultGridColumnWindow computeVisibleColumnWindow({ ); } +/// Sorting direction for [VirtualResultGrid]. +enum ResultGridSortOrder { + ascending, + descending, +} + +/// Sorts rows by the specified column index with natural numeric / temporal / lexicographic comparison. +List> sortResultGridRows({ + required List> rows, + required int columnIndex, + required ResultGridSortOrder order, +}) { + if (rows.isEmpty || columnIndex < 0) return rows; + final sorted = List>.from(rows); + + sorted.sort((a, b) { + final valA = columnIndex < a.length ? a[columnIndex] : ''; + final valB = columnIndex < b.length ? b[columnIndex] : ''; + + final isNullA = valA == 'NULL' || valA.isEmpty; + final isNullB = valB == 'NULL' || valB.isEmpty; + if (isNullA && isNullB) return 0; + if (isNullA) return 1; + if (isNullB) return -1; + + final numA = num.tryParse(valA); + final numB = num.tryParse(valB); + int cmp; + if (numA != null && numB != null) { + cmp = numA.compareTo(numB); + } else { + final dtA = DateTime.tryParse(valA); + final dtB = DateTime.tryParse(valB); + if (dtA != null && dtB != null) { + cmp = dtA.compareTo(dtB); + } else { + cmp = valA.toLowerCase().compareTo(valB.toLowerCase()); + if (cmp == 0) { + cmp = valA.compareTo(valB); + } + } + } + + return order == ResultGridSortOrder.ascending ? cmp : -cmp; + }); + + return sorted; +} + /// Virtualized read-only grid for SQL query results (rows + columns). class VirtualResultGrid extends material.StatefulWidget { const VirtualResultGrid({ @@ -193,10 +242,15 @@ class _VirtualResultGridState extends material.State { bool _userHasResized = false; double _scrollOffset = 0; + int? _sortColumnIndex; + ResultGridSortOrder? _sortOrder; + List> _sortedRows = const []; + @override void initState() { super.initState(); _horizontalController.addListener(_onHorizontalScroll); + _updateSortedRows(); } @override @@ -212,7 +266,10 @@ class _VirtualResultGridState extends material.State { _widthsNeedUpdate = true; if (oldWidget.columns != widget.columns) { _userHasResized = false; + _sortColumnIndex = null; + _sortOrder = null; } + _updateSortedRows(); } } @@ -245,6 +302,36 @@ class _VirtualResultGridState extends material.State { }); } + void _toggleSort(int columnIndex) { + if (columnIndex < 0 || columnIndex >= widget.columns.length) return; + setState(() { + if (_sortColumnIndex == columnIndex) { + if (_sortOrder == ResultGridSortOrder.ascending) { + _sortOrder = ResultGridSortOrder.descending; + } else { + _sortColumnIndex = null; + _sortOrder = null; + } + } else { + _sortColumnIndex = columnIndex; + _sortOrder = ResultGridSortOrder.ascending; + } + _updateSortedRows(); + }); + } + + void _updateSortedRows() { + if (_sortColumnIndex == null || _sortOrder == null) { + _sortedRows = widget.rows; + } else { + _sortedRows = sortResultGridRows( + rows: widget.rows, + columnIndex: _sortColumnIndex!, + order: _sortOrder!, + ); + } + } + List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, @@ -329,6 +416,9 @@ class _VirtualResultGridState extends material.State { window: window, height: headerHeight, colorScheme: cs, + sortColumnIndex: _sortColumnIndex, + sortOrder: _sortOrder, + onSortColumn: _toggleSort, onResizeColumn: _onColumnResize, ), material.Expanded( @@ -337,10 +427,10 @@ class _VirtualResultGridState extends material.State { thumbVisibility: true, child: material.ListView.builder( controller: _verticalController, - itemCount: widget.rows.length, + itemCount: _sortedRows.length, itemExtent: rowHeight, itemBuilder: (context, rowIndex) { - final row = widget.rows[rowIndex]; + final row = _sortedRows[rowIndex]; final isEven = rowIndex.isEven; return _DataRow( key: ValueKey('result-row-$rowIndex'), @@ -373,6 +463,9 @@ class _HeaderRow extends material.StatelessWidget { required this.window, required this.height, required this.colorScheme, + this.sortColumnIndex, + this.sortOrder, + this.onSortColumn, this.onResizeColumn, }); @@ -381,6 +474,9 @@ class _HeaderRow extends material.StatelessWidget { final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; + final int? sortColumnIndex; + final ResultGridSortOrder? sortOrder; + final material.ValueChanged? onSortColumn; final void Function(int index, double delta)? onResizeColumn; @override @@ -404,6 +500,8 @@ class _HeaderRow extends material.StatelessWidget { text: columns[i], width: columnWidths[i], colorScheme: colorScheme, + sortOrder: sortColumnIndex == i ? sortOrder : null, + onSort: onSortColumn != null ? () => onSortColumn!(i) : null, onResize: onResizeColumn != null ? (delta) => onResizeColumn!(i, delta) : null, @@ -421,20 +519,25 @@ class _HeaderCell extends material.StatelessWidget { required this.text, required this.width, required this.colorScheme, + this.sortOrder, + this.onSort, this.onResize, }); final String text; final double width; final ColorScheme colorScheme; + final ResultGridSortOrder? sortOrder; + final material.VoidCallback? onSort; final material.ValueChanged? onResize; @override material.Widget build(material.BuildContext context) { + final isSorted = sortOrder != null; final style = material.TextStyle( fontSize: 12, fontWeight: material.FontWeight.w600, - color: colorScheme.foreground, + color: isSorted ? colorScheme.primary : colorScheme.foreground, ); return material.Container( @@ -451,15 +554,37 @@ class _HeaderCell extends material.StatelessWidget { clipBehavior: material.Clip.none, children: [ material.Positioned.fill( - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - child: material.Align( - alignment: material.Alignment.centerLeft, - child: material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, + child: material.MouseRegion( + cursor: onSort != null + ? material.SystemMouseCursors.click + : material.MouseCursor.defer, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: onSort, + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.Expanded( + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (isSorted) ...[ + const material.SizedBox(width: 4), + material.Icon( + sortOrder == ResultGridSortOrder.ascending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 13, + color: colorScheme.primary, + ), + ], + ], + ), ), ), ), diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index b7543f0c..6dc49a68 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -61,6 +61,58 @@ void main() { }); }); + group('sortResultGridRows', () { + test('sorts numerically when all values are numbers', () { + final rows = [ + ['10', 'b'], + ['2', 'a'], + ['1', 'c'], + ]; + final sortedAsc = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sortedAsc.map((r) => r[0]).toList(), ['1', '2', '10']); + + final sortedDesc = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.descending, + ); + expect(sortedDesc.map((r) => r[0]).toList(), ['10', '2', '1']); + }); + + test('sorts lexicographically for non-numeric strings', () { + final rows = [ + ['banana'], + ['Apple'], + ['cherry'], + ]; + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sorted.map((r) => r[0]).toList(), ['Apple', 'banana', 'cherry']); + }); + + test('handles NULL and empty values gracefully', () { + final rows = [ + ['100'], + ['NULL'], + ['50'], + [''], + ]; + final sorted = sortResultGridRows( + rows: rows, + columnIndex: 0, + order: ResultGridSortOrder.ascending, + ); + expect(sorted.map((r) => r[0]).take(2).toList(), ['50', '100']); + }); + }); + group('computeVisibleColumnWindow', () { test('returns empty for no columns', () { expect( @@ -538,5 +590,49 @@ void main() { expect(find.text('alice'), findsOneWidget); expect(find.text('bob'), findsOneWidget); }); + + testWidgets('allows sorting by clicking column headers in VirtualResultGrid', + (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: ['id', 'score'], + rows: [ + ['1', '100'], + ['2', '20'], + ['3', '500'], + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Tap on 'score' header -> Ascending sort + await tester.tap(find.text('score')); + await tester.pumpAndSettle(); + + // Arrow up icon should appear + expect(find.byIcon(material.Icons.arrow_upward_rounded), findsOneWidget); + + // Tap again -> Descending sort + await tester.tap(find.text('score')); + await tester.pumpAndSettle(); + + // Arrow down icon should appear + expect(find.byIcon(material.Icons.arrow_downward_rounded), findsOneWidget); + + // Tap again -> Reset to natural order + await tester.tap(find.text('score')); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.arrow_upward_rounded), findsNothing); + expect(find.byIcon(material.Icons.arrow_downward_rounded), findsNothing); + }); }); } From 76f471e2c70e37fc933fcdbdf129a8fca9a090bc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:20:38 +0300 Subject: [PATCH 05/40] feat(sidebar): rich context menus for database objects (#551) --- .../connections/connections_panel_mysql.dart | 15 +++++- .../connections_panel_pg_tree.dart | 48 ++++++++++++++++++- .../connections/connections_panel_sqlite.dart | 14 +++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index d8673c16..3dbbd6e1 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -531,6 +531,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.tableGroup, itemIcon: QueryaIcons.tableLeaf, items: _tables, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -550,6 +551,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.viewGroup, itemIcon: QueryaIcons.viewLeaf, items: _views, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -569,6 +571,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.functionGroup, itemIcon: QueryaIcons.functionLeaf, items: _procedures, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -588,6 +591,7 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { icon: QueryaIcons.functionGroup, itemIcon: QueryaIcons.functionLeaf, items: _functions, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onItemTap: widget.onMysqlObjectSelected == null ? null : (name) => widget.onMysqlObjectSelected!( @@ -620,6 +624,7 @@ class _MysqlObjectGroup extends StatefulWidget { required this.itemIcon, required this.items, this.onItemTap, + this.onOpenSqlWorkspace, }); final ConnectionRow connection; @@ -631,6 +636,7 @@ class _MysqlObjectGroup extends StatefulWidget { final material.IconData itemIcon; final List items; final void Function(String itemName)? onItemTap; + final void Function(ConnectionRow connection)? onOpenSqlWorkspace; @override State<_MysqlObjectGroup> createState() => _MysqlObjectGroupState(); @@ -700,8 +706,13 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ? () => widget.onItemTap!(item) : null, connection: widget.connection, - onContextRefresh: null, - onOpenSqlWorkspace: null, + openSqlDatabase: widget.databaseName, + openSqlName: item, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null + ? (conn, {database, schema, name, kind}) => + widget.onOpenSqlWorkspace!(conn) + : null, ); }, ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 1579556f..cd285140 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -156,6 +156,48 @@ class _PgTreeRow extends material.StatelessWidget { } final menu = ContextMenu( items: [ + if (openSqlName != null) ...[ + MenuButton( + leading: material.Icon( + material.Icons.table_rows_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + onPressed: (_) { + final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty + ? '"$openSqlSchema"."$openSqlName"' + : '"$openSqlName"'; + Clipboard.setData( + ClipboardData(text: 'SELECT * FROM $qualified LIMIT 100;'), + ); + if (onOpenSqlWorkspace != null && connection != null) { + onOpenSqlWorkspace!( + connection!, + database: openSqlDatabase, + schema: openSqlSchema, + name: openSqlName, + kind: openSqlKind, + ); + } + }, + child: const Text('Select TOP 100'), + ), + MenuButton( + leading: material.Icon( + material.Icons.code_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) { + final qualified = openSqlSchema != null && openSqlSchema!.isNotEmpty + ? '"$openSqlSchema"."$openSqlName"' + : '"$openSqlName"'; + Clipboard.setData(ClipboardData(text: 'SELECT * FROM $qualified;')); + }, + child: const Text('Copy SELECT statement'), + ), + const MenuDivider(), + ], if (onContextRefresh != null) MenuButton( leading: material.Icon( @@ -177,7 +219,7 @@ class _PgTreeRow extends material.StatelessWidget { }, child: const Text('Copy name'), ), - if (onOpenSqlWorkspace != null) + if (onOpenSqlWorkspace != null && openSqlName == null) MenuButton( leading: material.Icon( material.Icons.terminal_rounded, @@ -193,7 +235,8 @@ class _PgTreeRow extends material.StatelessWidget { ), child: const Text('Open in SQL'), ), - if (onContextDelete != null) + if (onContextDelete != null) ...[ + const MenuDivider(), MenuButton( leading: material.Icon( material.Icons.delete_outline_rounded, @@ -203,6 +246,7 @@ class _PgTreeRow extends material.StatelessWidget { onPressed: (_) => onContextDelete!(), child: Text(contextDeleteLabel ?? 'Delete'), ), + ], ], child: row, ); diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index d783df0f..033fabda 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -293,6 +293,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { icon: QueryaIcons.tableGroup, itemIcon: QueryaIcons.tableLeaf, items: _tables, + onOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, onItemTap: widget.onSqliteObjectSelected == null ? null : (name) => widget.onSqliteObjectSelected!( @@ -310,6 +312,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { icon: QueryaIcons.viewGroup, itemIcon: QueryaIcons.viewLeaf, items: _views, + onOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, onItemTap: widget.onSqliteObjectSelected == null ? null : (name) => widget.onSqliteObjectSelected!( @@ -349,6 +353,7 @@ class _SqliteObjectGroup extends StatefulWidget { required this.itemIcon, required this.items, this.onItemTap, + this.onOpenSqlWorkspace, }); final ConnectionRow connection; @@ -359,6 +364,7 @@ class _SqliteObjectGroup extends StatefulWidget { final material.IconData itemIcon; final List items; final void Function(String itemName)? onItemTap; + final void Function(ConnectionRow connection)? onOpenSqlWorkspace; @override State<_SqliteObjectGroup> createState() => _SqliteObjectGroupState(); @@ -428,8 +434,12 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ? () => widget.onItemTap!(item) : null, connection: widget.connection, - onContextRefresh: null, - onOpenSqlWorkspace: null, + openSqlName: item, + onContextRefresh: widget.onRefresh, + onOpenSqlWorkspace: widget.onOpenSqlWorkspace != null + ? (conn, {database, schema, name, kind}) => + widget.onOpenSqlWorkspace!(conn) + : null, ); }, ), From 7095c3baaf1c8b1f519971ff3b582d2963e3d594 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:23:04 +0300 Subject: [PATCH 06/40] feat(grid): multi-cell selection and TSV/CSV clipboard copy (#550) --- .../main_screen/result_grid_view.dart | 424 ++++++++++++++---- .../main_screen/results_tab_test.dart | 43 ++ 2 files changed, 383 insertions(+), 84 deletions(-) diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index ad38ed86..698f4249 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -168,6 +169,115 @@ ResultGridColumnWindow computeVisibleColumnWindow({ ); } +/// Coordinate of a cell in [VirtualResultGrid]. +@immutable +class ResultGridCellCoordinate { + const ResultGridCellCoordinate(this.row, this.column); + + final int row; + final int column; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridCellCoordinate && + row == other.row && + column == other.column; + + @override + int get hashCode => Object.hash(row, column); +} + +/// Rectangular cell selection range in [VirtualResultGrid]. +@immutable +class ResultGridSelection { + const ResultGridSelection({ + required this.startRow, + required this.startColumn, + required this.endRow, + required this.endColumn, + }); + + factory ResultGridSelection.fromPoints({ + required ResultGridCellCoordinate anchor, + required ResultGridCellCoordinate focus, + }) { + final minR = anchor.row < focus.row ? anchor.row : focus.row; + final maxR = anchor.row > focus.row ? anchor.row : focus.row; + final minC = anchor.column < focus.column ? anchor.column : focus.column; + final maxC = anchor.column > focus.column ? anchor.column : focus.column; + return ResultGridSelection( + startRow: minR, + startColumn: minC, + endRow: maxR, + endColumn: maxC, + ); + } + + final int startRow; + final int startColumn; + final int endRow; + final int endColumn; + + bool contains(int row, int column) => + row >= startRow && + row <= endRow && + column >= startColumn && + column <= endColumn; + + int get rowCount => endRow - startRow + 1; + int get columnCount => endColumn - startColumn + 1; + + /// Formats selected cell values as a Tab-Separated Values (TSV) string. + String toTsv(List> rows) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + cells.add(c < rowData.length ? rowData[c] : ''); + } + buffer.writeln(cells.join('\t')); + } + return buffer.toString().trimRight(); + } + + /// Formats selected cell values as a CSV string. + String toCsv(List> rows) { + if (rows.isEmpty) return ''; + final buffer = StringBuffer(); + for (var r = startRow; r <= endRow; r++) { + if (r < 0 || r >= rows.length) continue; + final rowData = rows[r]; + final cells = []; + for (var c = startColumn; c <= endColumn; c++) { + final val = c < rowData.length ? rowData[c] : ''; + if (val.contains(',') || val.contains('"') || val.contains('\n')) { + cells.add('"${val.replaceAll('"', '""')}"'); + } else { + cells.add(val); + } + } + buffer.writeln(cells.join(',')); + } + return buffer.toString().trimRight(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResultGridSelection && + startRow == other.startRow && + startColumn == other.startColumn && + endRow == other.endRow && + endColumn == other.endColumn; + + @override + int get hashCode => Object.hash(startRow, startColumn, endRow, endColumn); +} + /// Sorting direction for [VirtualResultGrid]. enum ResultGridSortOrder { ascending, @@ -235,6 +345,7 @@ class VirtualResultGrid extends material.StatefulWidget { class _VirtualResultGridState extends material.State { final _horizontalController = material.ScrollController(); final _verticalController = material.ScrollController(); + final _focusNode = material.FocusNode(); List _columnWidths = const []; List _columnOffsets = const [0]; @@ -246,6 +357,9 @@ class _VirtualResultGridState extends material.State { ResultGridSortOrder? _sortOrder; List> _sortedRows = const []; + ResultGridCellCoordinate? _selectionAnchor; + ResultGridSelection? _selection; + @override void initState() { super.initState(); @@ -268,6 +382,8 @@ class _VirtualResultGridState extends material.State { _userHasResized = false; _sortColumnIndex = null; _sortOrder = null; + _selectionAnchor = null; + _selection = null; } _updateSortedRows(); } @@ -278,6 +394,7 @@ class _VirtualResultGridState extends material.State { _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); _verticalController.dispose(); + _focusNode.dispose(); super.dispose(); } @@ -332,6 +449,55 @@ class _VirtualResultGridState extends material.State { } } + void _onCellTap(int row, int column, {bool isShift = false}) { + _focusNode.requestFocus(); + setState(() { + final coord = ResultGridCellCoordinate(row, column); + if (isShift && _selectionAnchor != null) { + _selection = ResultGridSelection.fromPoints( + anchor: _selectionAnchor!, + focus: coord, + ); + } else { + _selectionAnchor = coord; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + } + }); + } + + void _onCellSecondaryTap(int row, int column) { + _focusNode.requestFocus(); + if (_selection != null && _selection!.contains(row, column)) { + _copySelection(); + } else { + setState(() { + _selectionAnchor = ResultGridCellCoordinate(row, column); + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + _copySelection(); + } + } + + void _copySelection({bool asCsv = false}) { + if (_selection == null) return; + final text = asCsv + ? _selection!.toCsv(_sortedRows) + : _selection!.toTsv(_sortedRows); + if (text.isNotEmpty) { + Clipboard.setData(ClipboardData(text: text)); + } + } + List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, @@ -378,79 +544,104 @@ class _VirtualResultGridState extends material.State { final rowHeight = _scaledRowHeight(context); final headerHeight = _scaledHeaderHeight(context); - return material.RepaintBoundary( - child: material.LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth; - - var displayWidths = _columnWidths; - var tableWidth = _tableWidth; - if (!_userHasResized && - tableWidth < availableWidth && - _columnWidths.isNotEmpty) { - final extraPerCol = - (availableWidth - tableWidth) / _columnWidths.length; - displayWidths = [for (final w in _columnWidths) w + extraPerCol]; - tableWidth = availableWidth; - } else if (tableWidth < availableWidth) { - tableWidth = availableWidth; - } - - final window = _columnWindow(displayWidths, availableWidth); - - return material.Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - notificationPredicate: (_) => true, - child: material.SingleChildScrollView( - controller: _horizontalController, - scrollDirection: material.Axis.horizontal, - child: material.SizedBox( - width: tableWidth, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _HeaderRow( - columns: widget.columns, - columnWidths: displayWidths, - window: window, - height: headerHeight, - colorScheme: cs, - sortColumnIndex: _sortColumnIndex, - sortOrder: _sortOrder, - onSortColumn: _toggleSort, - onResizeColumn: _onColumnResize, - ), - material.Expanded( - child: material.Scrollbar( - controller: _verticalController, - thumbVisibility: true, - child: material.ListView.builder( - controller: _verticalController, - itemCount: _sortedRows.length, - itemExtent: rowHeight, - itemBuilder: (context, rowIndex) { - final row = _sortedRows[rowIndex]; - final isEven = rowIndex.isEven; - return _DataRow( - key: ValueKey('result-row-$rowIndex'), - row: row, - columnWidths: displayWidths, - window: window, - height: rowHeight, - colorScheme: cs, - striped: !isEven, - ); - }, + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.keyC, + control: true, + ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.escape, + ): () => setState(() { + _selection = null; + _selectionAnchor = null; + }), + }, + child: material.Focus( + focusNode: _focusNode, + child: material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + + var displayWidths = _columnWidths; + var tableWidth = _tableWidth; + if (!_userHasResized && + tableWidth < availableWidth && + _columnWidths.isNotEmpty) { + final extraPerCol = + (availableWidth - tableWidth) / _columnWidths.length; + displayWidths = [for (final w in _columnWidths) w + extraPerCol]; + tableWidth = availableWidth; + } else if (tableWidth < availableWidth) { + tableWidth = availableWidth; + } + + final window = _columnWindow(displayWidths, availableWidth); + + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: tableWidth, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + columns: widget.columns, + columnWidths: displayWidths, + window: window, + height: headerHeight, + colorScheme: cs, + sortColumnIndex: _sortColumnIndex, + sortOrder: _sortOrder, + onSortColumn: _toggleSort, + onResizeColumn: _onColumnResize, ), - ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _sortedRows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = _sortedRows[rowIndex]; + final isEven = rowIndex.isEven; + return _DataRow( + key: ValueKey('result-row-$rowIndex'), + rowIndex: rowIndex, + row: row, + columnWidths: displayWidths, + window: window, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + selection: _selection, + onCellTap: _onCellTap, + onCellSecondaryTap: _onCellSecondaryTap, + ); + }, + ), + ), + ), + ], ), - ], + ), ), - ), - ), - ); - }, + ); + }, + ), + ), ), ); } @@ -614,45 +805,61 @@ class _HeaderCell extends material.StatelessWidget { class _DataRow extends material.StatelessWidget { const _DataRow({ super.key, + required this.rowIndex, required this.row, required this.columnWidths, required this.window, required this.height, required this.colorScheme, required this.striped, + this.selection, + this.onCellTap, + this.onCellSecondaryTap, }); + final int rowIndex; final List row; final List columnWidths; final ResultGridColumnWindow window; final double height; final ColorScheme colorScheme; final bool striped; + final ResultGridSelection? selection; + final void Function(int row, int col, {bool isShift})? onCellTap; + final void Function(int row, int col)? onCellSecondaryTap; @override material.Widget build(material.BuildContext context) { return material.RepaintBoundary( - child: material.Container( + child: material.SizedBox( height: height, - decoration: material.BoxDecoration( - color: striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent, - border: material.Border( - bottom: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.15), - ), - ), - ), child: material.Row( children: [ if (window.leadingWidth > 0) material.SizedBox(width: window.leadingWidth), for (var c = window.first; c <= window.last; c++) _GridCell( + row: rowIndex, + column: c, text: c < row.length ? row[c] : '', width: columnWidths[c], colorScheme: colorScheme, + striped: striped, + isSelected: selection?.contains(rowIndex, c) ?? false, + isSelectionTop: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.startRow, + isSelectionBottom: selection != null && + selection!.contains(rowIndex, c) && + rowIndex == selection!.endRow, + isSelectionLeft: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.startColumn, + isSelectionRight: selection != null && + selection!.contains(rowIndex, c) && + c == selection!.endColumn, + onTap: onCellTap, + onSecondaryTap: onCellSecondaryTap, ), if (window.trailingWidth > 0) material.SizedBox(width: window.trailingWidth), @@ -665,14 +872,34 @@ class _DataRow extends material.StatelessWidget { class _GridCell extends material.StatelessWidget { const _GridCell({ + required this.row, + required this.column, required this.text, required this.width, required this.colorScheme, + required this.striped, + this.isSelected = false, + this.isSelectionTop = false, + this.isSelectionBottom = false, + this.isSelectionLeft = false, + this.isSelectionRight = false, + this.onTap, + this.onSecondaryTap, }); + final int row; + final int column; final String text; final double width; final ColorScheme colorScheme; + final bool striped; + final bool isSelected; + final bool isSelectionTop; + final bool isSelectionBottom; + final bool isSelectionLeft; + final bool isSelectionRight; + final void Function(int row, int col, {bool isShift})? onTap; + final void Function(int row, int col)? onSecondaryTap; @override material.Widget build(material.BuildContext context) { @@ -687,15 +914,37 @@ class _GridCell extends material.StatelessWidget { fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, ); + final bg = isSelected + ? colorScheme.primary.withValues(alpha: 0.18) + : (striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent); + final cell = material.Container( width: width, + height: double.infinity, padding: const material.EdgeInsets.symmetric(horizontal: 10), alignment: material.Alignment.centerLeft, decoration: material.BoxDecoration( + color: bg, border: material.Border( right: material.BorderSide( - color: colorScheme.border.withValues(alpha: 0.3), + color: isSelectionRight + ? colorScheme.primary + : colorScheme.border.withValues(alpha: 0.3), + width: isSelectionRight ? 1.5 : 1.0, ), + left: isSelectionLeft + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + top: isSelectionTop + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide.none, + bottom: isSelectionBottom + ? material.BorderSide(color: colorScheme.primary, width: 1.5) + : material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.15), + ), ), ), child: material.Text( @@ -707,7 +956,14 @@ class _GridCell extends material.StatelessWidget { ); final interactiveCell = material.GestureDetector( - onSecondaryTap: () => Clipboard.setData(ClipboardData(text: text)), + behavior: material.HitTestBehavior.opaque, + onTap: () { + final isShift = HardwareKeyboard.instance.isShiftPressed; + onTap?.call(row, column, isShift: isShift); + }, + onSecondaryTap: () { + onSecondaryTap?.call(row, column); + }, child: cell, ); diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 6dc49a68..1b62f414 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -113,6 +113,49 @@ void main() { }); }); + group('ResultGridSelection', () { + test('contains point inside bounding box', () { + const selection = ResultGridSelection( + startRow: 1, + startColumn: 1, + endRow: 3, + endColumn: 4, + ); + expect(selection.contains(1, 1), isTrue); + expect(selection.contains(2, 3), isTrue); + expect(selection.contains(3, 4), isTrue); + expect(selection.contains(0, 1), isFalse); + expect(selection.contains(1, 5), isFalse); + }); + + test('toTsv formats tab-delimited grid', () { + final rows = [ + ['1', 'Alice', 'Engineer'], + ['2', 'Bob', 'Designer'], + ['3', 'Charlie', 'Manager'], + ]; + final selection = ResultGridSelection.fromPoints( + anchor: const ResultGridCellCoordinate(0, 1), + focus: const ResultGridCellCoordinate(1, 2), + ); + final tsv = selection.toTsv(rows); + expect(tsv, 'Alice\tEngineer\nBob\tDesigner'); + }); + + test('toCsv formats comma-separated grid with quotes if needed', () { + final rows = [ + ['1', 'Alice, Jr.', 'Engineer'], + ['2', 'Bob', 'Designer'], + ]; + final selection = ResultGridSelection.fromPoints( + anchor: const ResultGridCellCoordinate(0, 0), + focus: const ResultGridCellCoordinate(1, 1), + ); + final csv = selection.toCsv(rows); + expect(csv, '1,"Alice, Jr."\n2,Bob'); + }); + }); + group('computeVisibleColumnWindow', () { test('returns empty for no columns', () { expect( From dbe9ea62552d1a6006592d0ddfbaccf0c4c0ba17 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:28:27 +0300 Subject: [PATCH 07/40] feat(a11y): full keyboard navigation in database tree views (#552) --- .../connections/connections_panel.dart | 7 +- .../connections_panel_pg_tree.dart | 78 +++++++++++-------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index e801fefb..32131f09 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -51,8 +51,11 @@ import 'package:flutter/material.dart' as material VoidCallback, RepaintBoundary, ListView, - ClampingScrollPhysics; -import 'package:flutter/services.dart' show Clipboard, ClipboardData; + ClampingScrollPhysics, + CallbackShortcuts, + SingleActivator; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, LogicalKeyboardKey; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index cd285140..68917cdd 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -106,41 +106,53 @@ class _PgTreeRow extends material.StatelessWidget { final theme = Theme.of(context); final primary = theme.colorScheme.primary; final muted = theme.colorScheme.mutedForeground; - final row = material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - borderRadius: material.BorderRadius.circular(4), - hoverColor: primary.withValues(alpha: 0.07), - splashColor: primary.withValues(alpha: 0.10), - highlightColor: primary.withValues(alpha: 0.05), - mouseCursor: onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - child: material.Padding( - padding: material.EdgeInsets.symmetric( - horizontal: 4, - vertical: verticalPadding, - ), - child: material.Row( - children: [ - if (leading != null) ...[ - leading!, - const Gap(4), - ], - if (icon != null) ...[ - material.Icon( - icon, - size: iconSize, - color: iconColor ?? muted, + final row = material.CallbackShortcuts( + bindings: { + if (expanded == false && onTap != null) + const material.SingleActivator(LogicalKeyboardKey.arrowRight): () => + onTap!(), + if (expanded == true && onTap != null) + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): () => + onTap!(), + }, + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + canRequestFocus: onTap != null, + borderRadius: material.BorderRadius.circular(4), + hoverColor: primary.withValues(alpha: 0.07), + focusColor: primary.withValues(alpha: 0.14), + splashColor: primary.withValues(alpha: 0.10), + highlightColor: primary.withValues(alpha: 0.05), + mouseCursor: onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.Padding( + padding: material.EdgeInsets.symmetric( + horizontal: 4, + vertical: verticalPadding, + ), + child: material.Row( + children: [ + if (leading != null) ...[ + leading!, + const Gap(4), + ], + if (icon != null) ...[ + material.Icon( + icon, + size: iconSize, + color: iconColor ?? muted, + ), + const Gap(6), + ], + material.Expanded( + child: _PgTreeRowLabel(label: label, textStyle: textStyle), ), - const Gap(6), + if (trailing != null) trailing!, ], - material.Expanded( - child: _PgTreeRowLabel(label: label, textStyle: textStyle), - ), - if (trailing != null) trailing!, - ], + ), ), ), ), From 1eea454c6d06d07a1ef9b6c0ad49583412c27b52 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 13:32:24 +0300 Subject: [PATCH 08/40] feat(forms): real-time inline validation in connection dialogs (#553) --- .../new_connection_url_dialog.dart | 94 ++++++++++++++++--- .../connections/sqlite_connection_form.dart | 33 +++++++ 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index 1d359f18..89a9527b 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -30,17 +30,47 @@ class _NewConnectionUrlDialogContentState extends material.State<_NewConnectionUrlDialogContent> { final _urlController = material.TextEditingController(); String? _validationError; + ConnectionRow? _parsedRow; + bool _touched = false; + + @override + void initState() { + super.initState(); + _urlController.addListener(_onUrlChanged); + } @override void dispose() { + _urlController.removeListener(_onUrlChanged); _urlController.dispose(); super.dispose(); } + void _onUrlChanged() { + final text = _urlController.text; + if (text.trim().isEmpty) { + setState(() { + _parsedRow = null; + _validationError = _touched ? 'URL/URI is required.' : null; + }); + return; + } + _touched = true; + final result = parseConnectionUrlInput(text); + setState(() { + _parsedRow = result.row; + _validationError = result.error; + }); + } + void _validateAndSubmit() { + _touched = true; final result = parseConnectionUrlInput(_urlController.text); if (result.error != null) { - setState(() => _validationError = result.error); + setState(() { + _parsedRow = null; + _validationError = result.error; + }); return; } material.Navigator.of(context).pop(result.row); @@ -49,6 +79,9 @@ class _NewConnectionUrlDialogContentState @override material.Widget build(material.BuildContext context) { final theme = context.colors; + final isSuccess = _parsedRow != null; + final isError = _validationError != null; + return QueryaDialogCard( constraints: WindowLayout.dialogConstraints( context, @@ -76,9 +109,11 @@ class _NewConnectionUrlDialogContentState color: theme.muted.withValues(alpha: 0.2), borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: _validationError != null + color: isError ? theme.destructive.withValues(alpha: 0.8) - : theme.border.withValues(alpha: 0.4), + : isSuccess + ? theme.primary.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), ), ), padding: const material.EdgeInsets.symmetric( @@ -86,11 +121,17 @@ class _NewConnectionUrlDialogContentState child: material.Row( children: [ material.Icon( - material.Icons.link_rounded, + isSuccess + ? material.Icons.check_circle_outline_rounded + : isError + ? material.Icons.error_outline_rounded + : material.Icons.link_rounded, size: 20, - color: _validationError != null + color: isError ? theme.destructive - : theme.mutedForeground, + : isSuccess + ? theme.primary + : theme.mutedForeground, ), const material.SizedBox(width: 10), material.Expanded( @@ -99,22 +140,51 @@ class _NewConnectionUrlDialogContentState placeholder: const Text('database://user:pass@host:port/db'), onSubmitted: (_) => _validateAndSubmit(), - onChanged: (_) { - if (_validationError != null) { - setState(() => _validationError = null); - } - }, ), ), ], ), ), - if (_validationError != null) ...[ + if (isError) ...[ const material.SizedBox(height: 8), Text( _validationError!, style: material.TextStyle(color: theme.destructive), ).small(), + ] else if (isSuccess) ...[ + material.Container( + margin: const material.EdgeInsets.only(top: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: theme.primary.withValues(alpha: 0.3), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.task_alt_rounded, + size: 16, + color: theme.primary, + ), + const material.SizedBox(width: 8), + material.Expanded( + child: Text( + '${_parsedRow!.type.toUpperCase()} β€’ ${_parsedRow!.name}', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: theme.primary, + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + ], + ), + ), ], ], ), diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index c0f96817..6f919c45 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -49,6 +49,8 @@ class _SqliteConnectionFormContentState String? _testResult; Timer? _dismissTimer; late final FormValidityNotifier _formValidNotifier; + bool _nameTouched = false; + bool _pathTouched = false; bool get _isEditing => widget.initial != null; @@ -66,6 +68,17 @@ class _SqliteConnectionFormContentState _readOnly = initial.useSSL; } + _nameController.addListener(() { + if (_nameController.text.isNotEmpty && !_nameTouched) { + setState(() => _nameTouched = true); + } + }); + _pathController.addListener(() { + if (_pathController.text.isNotEmpty && !_pathTouched) { + setState(() => _pathTouched = true); + } + }); + _formValidNotifier.seed(); } @@ -223,6 +236,16 @@ class _SqliteConnectionFormContentState controller: _nameController, placeholder: const Text('e.g. Local Cache'), ), + if (_nameTouched && _nameController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Connection name is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], const Gap(16), // Database File Path const Text('Database file path').small().semiBold(), @@ -242,6 +265,16 @@ class _SqliteConnectionFormContentState ), ], ), + if (_pathTouched && _pathController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Database file path is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], const Gap(16), // Read-only toggle material.Row( From 92578d01307f1b7e71c881234cfe7e24da439de5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 23 Aug 2026 14:34:25 +0300 Subject: [PATCH 09/40] feat(sidebar): fluid motion collapsible connections tree sidebar (#557) --- lib/core/storage/app_settings.dart | 16 + lib/features/main_screen/main_screen.dart | 333 ++++++++++++------ .../main_screen/querya_window_title_bar.dart | 134 ++++--- .../querya_window_title_bar_test.dart | 37 ++ .../main_screen/sidebar_toggle_test.dart | 193 ++++++++++ 5 files changed, 558 insertions(+), 155 deletions(-) create mode 100644 test/features/main_screen/sidebar_toggle_test.dart diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 386a1a26..bde2ad13 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -122,6 +122,7 @@ abstract final class AppSettingsKeys { static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; static const connectionsPanelWidth = 'connections_panel_width'; + static const sidebarVisible = 'sidebar_visible'; static const recentConnectionIds = 'recent_connection_ids'; static const motionLevel = 'motion_level'; static const updateChannel = 'update_channel'; @@ -272,6 +273,21 @@ class AppSettings { ); } + Future getSidebarVisible() async { + final value = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sidebarVisible, + ); + if (value == null) return true; + return value == '1' || value.toLowerCase() == 'true'; + } + + Future setSidebarVisible(bool visible) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sidebarVisible, + visible ? '1' : '0', + ); + } + /// Most recently selected connection ids (newest first). Future> getRecentConnectionIds() async { final raw = await LocalDb.instance.getAppSetting( diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index f3f5a2f4..eefb344c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -6,9 +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/motion/querya_spring_controller.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/extensions/sandbox/unsandboxed_launch_consent_gate.dart'; @@ -35,9 +35,12 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { final GlobalKey _connectionsPanelKey = GlobalKey(); + final GlobalKey<_MainContentSplitState> _splitKey = + GlobalKey<_MainContentSplitState>(); final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); + final ValueNotifier _isSidebarVisible = ValueNotifier(true); @override void initState() { @@ -52,6 +55,7 @@ class _MainScreenState extends State { void dispose() { UnsandboxedLaunchConsentGate.instance.handler = null; _workspace.dispose(); + _isSidebarVisible.dispose(); super.dispose(); } @@ -210,6 +214,14 @@ class _MainScreenState extends State { control: true, shift: true, ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + const material.SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + ): () => _splitKey.currentState?.toggleSidebar(), + const material.SingleActivator( + LogicalKeyboardKey.keyB, + meta: true, + ): () => _splitKey.currentState?.toggleSidebar(), }, child: SqlEditorGlobalActions( activeConnection: workspace.activeConnection, @@ -221,43 +233,55 @@ class _MainScreenState extends State { width: 1, child: Column( children: [ - QueryaWindowTitleBar( - onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, - onNewDatabaseConnectionFromUrl: - _onNewDatabaseConnectionFromUrl, - activeConnection: workspace.activeConnection, - isReadOnly: workspace.isReadOnly, - onReadOnlyChanged: () { - _workspace.value = _workspace.value.toggleReadOnly(); - }, - onConnect: () { - final active = workspace.activeConnection; - if (active != null && active.id != null) { - _connectionsPanelKey.currentState - ?.connect(active.id!); - } - }, - onReconnect: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState?.reconnect(active); - } - }, - onDisconnect: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState?.disconnect(active); - } - }, - onDisconnectAll: () { - _connectionsPanelKey.currentState?.disconnectAll(); - }, - onDisconnectOthers: () { - final active = workspace.activeConnection; - if (active != null) { - _connectionsPanelKey.currentState - ?.disconnectOthers(active); - } + ValueListenableBuilder( + valueListenable: _isSidebarVisible, + builder: (context, isSidebarVisible, _) { + return QueryaWindowTitleBar( + onNewDatabaseConnection: + _onNewDatabaseConnectionFromMenu, + onNewDatabaseConnectionFromUrl: + _onNewDatabaseConnectionFromUrl, + activeConnection: workspace.activeConnection, + isReadOnly: workspace.isReadOnly, + isSidebarVisible: isSidebarVisible, + onToggleSidebar: () => + _splitKey.currentState?.toggleSidebar(), + onReadOnlyChanged: () { + _workspace.value = + _workspace.value.toggleReadOnly(); + }, + onConnect: () { + final active = workspace.activeConnection; + if (active != null && active.id != null) { + _connectionsPanelKey.currentState + ?.connect(active.id!); + } + }, + onReconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState + ?.reconnect(active); + } + }, + onDisconnect: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState + ?.disconnect(active); + } + }, + onDisconnectAll: () { + _connectionsPanelKey.currentState?.disconnectAll(); + }, + onDisconnectOthers: () { + final active = workspace.activeConnection; + if (active != null) { + _connectionsPanelKey.currentState + ?.disconnectOthers(active); + } + }, + ); }, ), Divider( @@ -265,8 +289,11 @@ class _MainScreenState extends State { color: wb.borderSubtle.withValues(alpha: 0.22)), Expanded( child: _MainContentSplit( + key: _splitKey, connectionsPanelKey: _connectionsPanelKey, workspace: _workspace, + onSidebarVisibilityChanged: (v) => + _isSidebarVisible.value = v, onConnectionSelected: _onConnectionSelected, onPostgresObjectSelected: _onPostgresObjectSelected, onMysqlObjectSelected: _onMysqlObjectSelected, @@ -295,9 +322,10 @@ class _MainScreenState extends State { } } -/// Owns splitter width so resizing does not rebuild [MainScreen] or title bar. +/// Owns splitter width and collapsible spring motion. class _MainContentSplit extends StatefulWidget { const _MainContentSplit({ + super.key, required this.connectionsPanelKey, required this.workspace, required this.onConnectionSelected, @@ -314,10 +342,12 @@ class _MainContentSplit extends StatefulWidget { required this.onRequestNewConnectionFromUrl, required this.onRequestOpenSqlite, required this.onOpenConnection, + this.onSidebarVisibilityChanged, }); final GlobalKey connectionsPanelKey; final ValueNotifier workspace; + final ValueChanged? onSidebarVisibilityChanged; final void Function(ConnectionRow) onConnectionSelected; final void Function( ConnectionRow, @@ -362,12 +392,20 @@ class _MainContentSplitState extends State<_MainContentSplit> static const double _maxLeftWidth = 500; static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; - final ValueNotifier _leftPanelWidth = - ValueNotifier(kDefaultConnectionsPanelWidth); - late final QueryaDragSettleController _widthSettle; + + /// High-responsiveness critically damped spring for fluid sidebar toggling (~0.25s). + static const SpringDescription _sidebarSpring = SpringDescription( + mass: 1.0, + stiffness: 480.0, + damping: 43.8, + ); + + late final QueryaSpringController _widthSpring; late final ConnectionsPanelWidthPersist _widthPersist; VoidCallback? _persistWhenSettled; double _lastMaxWidth = 1200; + double _lastExpandedWidth = kDefaultConnectionsPanelWidth; + bool _sidebarVisible = true; @override void initState() { @@ -375,29 +413,42 @@ class _MainContentSplitState extends State<_MainContentSplit> _widthPersist = ConnectionsPanelWidthPersist( write: AppSettings.instance.setConnectionsPanelWidth, ); - _widthSettle = QueryaDragSettleController( + _widthSpring = QueryaSpringController( vsync: this, value: kDefaultConnectionsPanelWidth, - maxSettleVelocity: 1200, + spring: _sidebarSpring, + cubicDuration: const Duration(milliseconds: 170), + cubicCurve: Curves.easeOutCubic, ); - _widthSettle.addListener(_onWidthSettle); - unawaited(_restoreConnectionsPanelWidth()); + _widthSpring.addListener(_onWidthSpringChanged); + unawaited(_restoreState()); } - Future _restoreConnectionsPanelWidth() async { + Future _restoreState() async { final width = await AppSettings.instance.getConnectionsPanelWidth(); + final visible = await AppSettings.instance.getSidebarVisible(); if (!mounted) return; final clamped = _clampLeftWidth(width, _lastMaxWidth); - _widthSettle.jumpTo(clamped); - _leftPanelWidth.value = clamped; + _lastExpandedWidth = clamped; + _sidebarVisible = visible; + widget.onSidebarVisibilityChanged?.call(visible); + final targetW = visible ? clamped : 0.0; + _widthSpring.jumpTo(targetW); + } + + void toggleSidebar({bool? visible}) { + final target = visible ?? !_sidebarVisible; + _sidebarVisible = target; + widget.onSidebarVisibilityChanged?.call(target); + _widthSpring.useSprings = QueryaSpring.springsEnabled(context); + final targetWidth = target ? _lastExpandedWidth : 0.0; + _widthSpring.animateTo(targetWidth); + unawaited(AppSettings.instance.setSidebarVisible(target)); } - void _onWidthSettle() { - final clamped = _clampLeftWidth(_widthSettle.value, _lastMaxWidth); - _leftPanelWidth.value = clamped; - if (!_widthSettle.isSettling && - (clamped - _widthSettle.value).abs() > 0.5) { - _widthSettle.jumpTo(clamped); + void _onWidthSpringChanged() { + if (mounted) { + setState(() {}); } } @@ -405,18 +456,20 @@ class _MainContentSplitState extends State<_MainContentSplit> _widthPersist.markDirty(); final pending = _persistWhenSettled; if (pending != null) { - _widthSettle.removeListener(pending); + _widthSpring.removeListener(pending); } void listener() { - if (_widthSettle.isSettling) return; - _widthSettle.removeListener(listener); + if (_widthSpring.isAnimating) return; + _widthSpring.removeListener(listener); _persistWhenSettled = null; - unawaited(_widthPersist.persist(_leftPanelWidth.value)); + if (_sidebarVisible) { + unawaited(_widthPersist.persist(_lastExpandedWidth)); + } } _persistWhenSettled = listener; - if (_widthSettle.isSettling) { - _widthSettle.addListener(listener); + if (_widthSpring.isAnimating) { + _widthSpring.addListener(listener); } else { listener(); } @@ -426,13 +479,14 @@ class _MainContentSplitState extends State<_MainContentSplit> void dispose() { final pending = _persistWhenSettled; if (pending != null) { - _widthSettle.removeListener(pending); + _widthSpring.removeListener(pending); _persistWhenSettled = null; } - _widthPersist.disposeFlush(_leftPanelWidth.value); - _widthSettle.removeListener(_onWidthSettle); - _widthSettle.dispose(); - _leftPanelWidth.dispose(); + if (_sidebarVisible) { + _widthPersist.disposeFlush(_lastExpandedWidth); + } + _widthSpring.removeListener(_onWidthSpringChanged); + _widthSpring.dispose(); super.dispose(); } @@ -448,60 +502,109 @@ class _MainContentSplitState extends State<_MainContentSplit> return LayoutBuilder( builder: (context, constraints) { _lastMaxWidth = constraints.maxWidth; + final currentW = _widthSpring.value; + final isFullyCollapsed = currentW <= 0.5 && !_widthSpring.isAnimating; + final handleOpacity = (currentW / 40.0).clamp(0.0, 1.0); + final parallaxOffset = (currentW - _lastExpandedWidth) * 0.28; + final contentOpacity = _lastExpandedWidth > 0 + ? (currentW / (_lastExpandedWidth * 0.45)).clamp(0.0, 1.0) + : 1.0; + return Row( children: [ - ValueListenableBuilder( - valueListenable: _leftPanelWidth, - builder: (context, rawWidth, connectionsPanel) { - final leftW = _clampLeftWidth(rawWidth, constraints.maxWidth); - return SizedBox(width: leftW, child: connectionsPanel); - }, - child: material.RepaintBoundary( - child: _ConnectionsPanelSlot( - connectionsPanelKey: widget.connectionsPanelKey, - workspace: widget.workspace, - onConnectionSelected: widget.onConnectionSelected, - onRedisDatabaseSelected: widget.onRedisDatabaseSelected, - onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - onSqliteObjectSelected: widget.onSqliteObjectSelected, - onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, - onExtensionObjectSelected: widget.onExtensionObjectSelected, + if (!isFullyCollapsed) + ClipRect( + child: SizedBox( + width: currentW, + child: OverflowBox( + minWidth: _lastExpandedWidth, + maxWidth: _lastExpandedWidth, + alignment: Alignment.topLeft, + child: Transform.translate( + offset: Offset(parallaxOffset, 0), + child: Opacity( + opacity: contentOpacity, + child: material.RepaintBoundary( + child: _ConnectionsPanelSlot( + connectionsPanelKey: widget.connectionsPanelKey, + workspace: widget.workspace, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: + widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: + widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: + widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: + widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: + widget.onMysqlOpenSqlWorkspace, + onSqliteObjectSelected: + widget.onSqliteObjectSelected, + onSqliteOpenSqlWorkspace: + widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: + widget.onExtensionObjectSelected, + ), + ), + ), + ), + ), ), ), - ), - QueryaSplitHandle( - key: const Key('main_content_resize_handle'), - axis: Axis.horizontal, - semanticsLabel: 'Resize connections and workspace panes', - onDragDelta: (dx) { - final ml = constraints.maxWidth - - _resizeHandleWidth - - _minWorkspaceWidth; - if (ml <= 0) return; - _widthSettle.dragTo( - _clampLeftWidth( - _widthSettle.value + dx, - constraints.maxWidth, + if (!isFullyCollapsed || _widthSpring.isAnimating) + Opacity( + opacity: handleOpacity, + child: IgnorePointer( + ignoring: currentW <= 20.0, + child: QueryaSplitHandle( + key: const Key('main_content_resize_handle'), + axis: Axis.horizontal, + semanticsLabel: 'Resize connections and workspace panes', + onDragDelta: (dx) { + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + final nextW = _clampLeftWidth( + _widthSpring.value + dx, + constraints.maxWidth, + ); + _lastExpandedWidth = nextW; + _widthSpring.jumpTo(nextW); + }, + onDiscreteResize: () => _widthPersist + .onDiscreteResize(() => _lastExpandedWidth), + onDragEnd: (details) { + _widthPersist.cancelDiscreteTimer(); + final velocity = details.primaryVelocity ?? + details.velocity.pixelsPerSecond.dx; + final useSprings = QueryaSpring.springsEnabled(context); + _widthSpring.useSprings = useSprings; + if (_widthSpring.value < 100 && velocity < -50) { + _sidebarVisible = false; + widget.onSidebarVisibilityChanged?.call(false); + _widthSpring.animateTo(0.0, velocity: velocity); + unawaited(AppSettings.instance.setSidebarVisible(false)); + } else { + _sidebarVisible = true; + widget.onSidebarVisibilityChanged?.call(true); + _lastExpandedWidth = _clampLeftWidth( + _widthSpring.value, + constraints.maxWidth, + ); + _widthSpring.animateTo( + _lastExpandedWidth, + velocity: velocity, + ); + unawaited(AppSettings.instance.setSidebarVisible(true)); + _schedulePersistAfterSettle(); + } + }, ), - ); - }, - onDiscreteResize: () => _widthPersist - .onDiscreteResize(() => _leftPanelWidth.value), - onDragEnd: (details) { - _widthPersist.cancelDiscreteTimer(); - final velocity = details.primaryVelocity ?? - details.velocity.pixelsPerSecond.dx; - _widthSettle.settle( - velocity: velocity, - useSprings: QueryaSpring.springsEnabled(context), - ); - _schedulePersistAfterSettle(); - }, - ), + ), + ), Expanded( child: material.RepaintBoundary( child: ValueListenableBuilder( diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 7625db02..9681997d 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -29,6 +29,8 @@ class QueryaWindowTitleBar extends StatelessWidget { this.onDisconnectOthers, this.isReadOnly = false, this.onReadOnlyChanged, + this.isSidebarVisible = true, + this.onToggleSidebar, }); final Future Function() onNewDatabaseConnection; @@ -41,6 +43,8 @@ class QueryaWindowTitleBar extends StatelessWidget { final VoidCallback? onDisconnectOthers; final bool isReadOnly; final VoidCallback? onReadOnlyChanged; + final bool isSidebarVisible; + final VoidCallback? onToggleSidebar; @visibleForTesting static Color titleBarBackground(BuildContext context) => @@ -84,36 +88,59 @@ class QueryaWindowTitleBar extends StatelessWidget { ); } + /// Allows headless widget tests to render without native bitsdojo symbols. + @visibleForTesting + static bool useNativeWindowChrome = true; + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; final buttonColors = windowButtonColors(context); final closeButtonColors = QueryaWindowTitleBar.closeButtonColors(context); - return material.Container( - height: context.scaled(40), - color: titleBarBackground(context), - child: WindowTitleBarBox( - child: Row( - children: [ - Expanded( - child: MoveWindow( - child: Row( - children: [ - SizedBox( - width: QueryaWindowTitleBar.titleBarLeadingInset( - isMacOS: Platform.isMacOS, - scale: context.scaled, - ), - ), - material.Icon( - material.Icons.storage_rounded, - size: 18, - color: wb.accent, - ), + final rowContent = Row( + children: [ + SizedBox( + width: QueryaWindowTitleBar.titleBarLeadingInset( + isMacOS: Platform.isMacOS, + scale: context.scaled, + ), + ), + material.Icon( + material.Icons.storage_rounded, + size: 18, + color: wb.accent, + ), const Gap(8), const Text('Querya').semiBold().small(), - const Gap(24), + const Gap(12), + if (onToggleSidebar != null) ...[ + material.Tooltip( + message: + 'Toggle Sidebar (${Platform.isMacOS ? "Cmd+B" : "Ctrl+B"})', + child: material.IconButton( + key: const Key('title_bar_toggle_sidebar_button'), + icon: material.Icon( + isSidebarVisible + ? material.Icons.view_sidebar_rounded + : material.Icons.view_sidebar_outlined, + size: 16, + color: isSidebarVisible + ? Theme.of(context).colorScheme.foreground + : wb.mutedForeground, + ), + splashRadius: 14, + padding: const material.EdgeInsets.all(4), + constraints: const material.BoxConstraints( + minWidth: 26, + minHeight: 26, + ), + onPressed: onToggleSidebar, + ), + ), + const Gap(12), + ] else + const Gap(12), Menubar( border: false, popoverOffset: const Offset(0, 8), @@ -180,6 +207,22 @@ class QueryaWindowTitleBar extends StatelessWidget { ], child: const Text('Edit'), ), + MenuButton( + subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.view_sidebar_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+B' : 'Ctrl+B', + ).xSmall().muted(), + onPressed: (_) => onToggleSidebar?.call(), + child: const Text('Toggle Sidebar'), + ), + ], + child: const Text('View'), + ), MenuButton( subMenu: [ MenuButton( @@ -273,27 +316,38 @@ class QueryaWindowTitleBar extends StatelessWidget { ], ), ], - ), - ), - ), - Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - if (activeConnection != null && isReadOnly) - const QueryaReadOnlyBadge(), - UpdateAvailableBadge(controller: UpdateController.instance), - if (QueryaWindowTitleBar.showBitsdojoWindowButtons( + ); + + final inner = Row( + children: [ + Expanded( + child: useNativeWindowChrome + ? MoveWindow(child: rowContent) + : rowContent, + ), + Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (activeConnection != null && isReadOnly) + const QueryaReadOnlyBadge(), + UpdateAvailableBadge(controller: UpdateController.instance), + if (useNativeWindowChrome && + QueryaWindowTitleBar.showBitsdojoWindowButtons( isMacOS: Platform.isMacOS, )) ...[ - MinimizeWindowButton(colors: buttonColors), - MaximizeWindowButton(colors: buttonColors), - CloseWindowButton(colors: closeButtonColors), - ], - ], - ) + MinimizeWindowButton(colors: buttonColors), + MaximizeWindowButton(colors: buttonColors), + CloseWindowButton(colors: closeButtonColors), + ], ], - ), - ), + ) + ], + ); + + return material.Container( + height: context.scaled(40), + color: titleBarBackground(context), + child: useNativeWindowChrome ? WindowTitleBarBox(child: inner) : inner, ); } } diff --git a/test/features/main_screen/querya_window_title_bar_test.dart b/test/features/main_screen/querya_window_title_bar_test.dart index afe0a7e6..74b50f22 100644 --- a/test/features/main_screen/querya_window_title_bar_test.dart +++ b/test/features/main_screen/querya_window_title_bar_test.dart @@ -17,6 +17,14 @@ QueryaTheme _themeWithWorkbench(QueryaWorkbenchTheme workbench) { } void main() { + setUp(() { + QueryaWindowTitleBar.useNativeWindowChrome = false; + }); + + tearDown(() { + QueryaWindowTitleBar.useNativeWindowChrome = true; + }); + testWidgets('title bar background follows workbench canvas', (tester) async { late Color background; @@ -143,4 +151,33 @@ void main() { ); expect(find.text('Read-only'), findsWidgets); }); + + testWidgets('toggle sidebar button calls onToggleSidebar when pressed', + (tester) async { + var toggled = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 1000, + child: QueryaWindowTitleBar( + onNewDatabaseConnection: () async {}, + onNewDatabaseConnectionFromUrl: () async {}, + isSidebarVisible: true, + onToggleSidebar: () { + toggled = true; + }, + ), + ), + ), + ); + + final button = find.byKey(const Key('title_bar_toggle_sidebar_button')); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pump(); + + expect(toggled, isTrue); + }); } diff --git a/test/features/main_screen/sidebar_toggle_test.dart b/test/features/main_screen/sidebar_toggle_test.dart new file mode 100644 index 00000000..29084640 --- /dev/null +++ b/test/features/main_screen/sidebar_toggle_test.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/motion/querya_spring.dart'; +import 'package:querya_desktop/core/motion/querya_spring_controller.dart'; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_sidebar_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + AppDataRoot.resetMocks(); + AppDataRoot.mockInstallDirectory = tempDir.path; + AppDataRoot.mockEnvironment = {'QUERYA_PORTABLE': '1'}; + QueryaWindowTitleBar.useNativeWindowChrome = false; + }); + + tearDown(() async { + QueryaWindowTitleBar.useNativeWindowChrome = true; + AppDataRoot.resetMocks(); + try { + await LocalDb.instance.close(); + await tempDir.delete(recursive: true); + } catch (_) {} + }); + + group('Fluid Collapsible Sidebar (#557)', () { + test('AppSettings preserves sidebarVisible across sets and gets', + () async { + expect(await AppSettings.instance.getSidebarVisible(), isTrue); + + await AppSettings.instance.setSidebarVisible(false); + expect(await AppSettings.instance.getSidebarVisible(), isFalse); + + await AppSettings.instance.setSidebarVisible(true); + expect(await AppSettings.instance.getSidebarVisible(), isTrue); + }); + + testWidgets('title bar sidebar button reflects visibility and fires toggle', + (tester) async { + var toggleCount = 0; + var isVisible = true; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.StatefulBuilder( + builder: (context, setState) { + return material.SizedBox( + width: 800, + child: QueryaWindowTitleBar( + onNewDatabaseConnection: () async {}, + onNewDatabaseConnectionFromUrl: () async {}, + isSidebarVisible: isVisible, + onToggleSidebar: () { + toggleCount++; + setState(() { + isVisible = !isVisible; + }); + }, + ), + ); + }, + ), + ), + ); + await tester.pump(); + + final button = find.byKey(const Key('title_bar_toggle_sidebar_button')); + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pump(); + + expect(toggleCount, 1); + expect(isVisible, isFalse); + + await tester.tap(button); + await tester.pump(); + + expect(toggleCount, 2); + expect(isVisible, isTrue); + }); + + testWidgets('QueryaSpringController smoothly collapses and expands width', + (tester) async { + late QueryaSpringController controller; + + await tester.pumpWidget( + material.MaterialApp( + home: _SpringTestHost( + initialWidth: 260, + onCreated: (c) => controller = c, + ), + ), + ); + await tester.pump(); + + expect(controller.value, 260); + expect(controller.isAnimating, isFalse); + + // Animate collapse to 0 + controller.animateTo(0); + await tester.pump(); + expect(controller.isAnimating, isTrue); + + // Step animation forward + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.value, lessThan(260)); + expect(controller.value, greaterThan(0)); + + // Step until settled + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.value, closeTo(0, 0.01)); + expect(controller.isAnimating, isFalse); + + // Animate expand back to 260 + controller.animateTo(260); + await tester.pump(); + expect(controller.isAnimating, isTrue); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(controller.value, closeTo(260, 0.01)); + expect(controller.isAnimating, isFalse); + }); + }); +} + +class _SpringTestHost extends StatefulWidget { + const _SpringTestHost({ + required this.initialWidth, + required this.onCreated, + }); + + final double initialWidth; + final ValueChanged onCreated; + + @override + State<_SpringTestHost> createState() => _SpringTestHostState(); +} + +class _SpringTestHostState extends State<_SpringTestHost> + with SingleTickerProviderStateMixin { + late final QueryaSpringController _controller; + + @override + void initState() { + super.initState(); + _controller = QueryaSpringController( + vsync: this, + value: widget.initialWidth, + spring: QueryaSpring.gentle, + ); + widget.onCreated(_controller); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return SizedBox( + width: _controller.value, + height: 400, + ); + }, + ); + } +} From ebe9378ed115de079e3cf494e3ac81bf3b04bdf8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 01:58:55 +0300 Subject: [PATCH 10/40] feat(onboarding): interactive welcome tour & 1-click demo playground (#558) --- lib/core/demo/demo_playground_service.dart | 161 +++++++ lib/core/storage/app_settings.dart | 18 + lib/features/main_screen/main_screen.dart | 51 +++ .../main_screen/querya_window_title_bar.dart | 14 + .../main_screen/workspace_empty_hero.dart | 202 ++++++++- lib/features/main_screen/workspace_panel.dart | 7 + .../onboarding/welcome_tour_dialog.dart | 411 ++++++++++++++++++ .../demo/demo_playground_service_test.dart | 93 ++++ test/core/storage/app_settings_test.dart | 8 + .../goldens/workspace_empty_hero.png | Bin 7374 -> 8374 bytes .../onboarding/welcome_tour_dialog_test.dart | 143 ++++++ 11 files changed, 1094 insertions(+), 14 deletions(-) create mode 100644 lib/core/demo/demo_playground_service.dart create mode 100644 lib/features/onboarding/welcome_tour_dialog.dart create mode 100644 test/core/demo/demo_playground_service_test.dart create mode 100644 test/features/onboarding/welcome_tour_dialog_test.dart diff --git a/lib/core/demo/demo_playground_service.dart b/lib/core/demo/demo_playground_service.dart new file mode 100644 index 00000000..6df39c13 --- /dev/null +++ b/lib/core/demo/demo_playground_service.dart @@ -0,0 +1,161 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/storage/app_data_root.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Service that creates, seeds, and manages a lightweight Demo Playground +/// SQLite database for instant exploration without configuring external servers. +abstract final class DemoPlaygroundService { + static const String demoConnectionName = 'Sample Demo Playground'; + + static const String demoDefaultQuery = ''' +-- ✨ Welcome to Querya Demo Playground! +-- Press Cmd+Enter (macOS) or Ctrl+Enter (Linux/Windows) to run queries. +-- Try: +-- β€’ Clicking column headers to sort ascending/descending +-- β€’ Dragging column dividers to resize +-- β€’ Shift+Clicking to select cell ranges and Cmd+C/Ctrl+C to copy TSV +-- β€’ Pressing Cmd+B / Ctrl+B to toggle the left sidebar with fluid spring motion + +SELECT + o.id AS order_id, + u.full_name AS customer, + u.email, + p.category, + o.product_name, + o.amount, + o.status, + o.created_at +FROM orders o +JOIN users u ON o.user_id = u.id +JOIN products p ON o.product_id = p.id +ORDER BY o.created_at DESC; +'''; + + /// Resolves the filesystem path for the demo playground database file. + static Future resolveDemoDatabasePath() async { + final supportDir = await AppDataRoot.applicationSupportDirectory(); + final demoDir = Directory(p.join(supportDir.path, 'demo')); + if (!await demoDir.exists()) { + await demoDir.create(recursive: true); + } + return p.join(demoDir.path, 'demo_playground.sqlite'); + } + + /// Seeds the demo SQLite database with sample tables and rows if not populated. + static Future seedDemoDatabase(String dbPath) async { + await LocalDb.initFfi(); + final db = await databaseFactoryFfi.openDatabase(dbPath); + try { + await db.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + full_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + avatar_color TEXT NOT NULL, + created_at TEXT NOT NULL + ); + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + category TEXT NOT NULL, + price REAL NOT NULL, + stock INTEGER NOT NULL, + rating REAL NOT NULL + ); + '''); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + product_id INTEGER NOT NULL REFERENCES products(id), + product_name TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + '''); + + final userCountResult = await db.rawQuery('SELECT COUNT(*) as count FROM users;'); + final userCount = (userCountResult.first['count'] as num?)?.toInt() ?? 0; + + if (userCount == 0) { + final batch = db.batch(); + batch.rawInsert(''' + INSERT INTO users (full_name, email, role, avatar_color, created_at) VALUES + ('Alex River', 'alex.river@example.com', 'Admin', '#06B6D4', '2026-01-10T08:30:00Z'), + ('Elena Vance', 'elena.vance@example.com', 'Engineer', '#8B5CF6', '2026-01-14T11:20:00Z'), + ('Marcus Chen', 'marcus.c@example.com', 'Product Manager', '#10B981', '2026-01-18T14:45:00Z'), + ('Sophia Taylor', 'sophia.t@example.com', 'Designer', '#F59E0B', '2026-02-01T09:15:00Z'), + ('David Kim', 'david.kim@example.com', 'Data Scientist', '#EC4899', '2026-02-05T16:00:00Z'), + ('Olivia Brown', 'olivia.b@example.com', 'QA Lead', '#3B82F6', '2026-02-10T12:10:00Z'), + ('Lucas Garcia', 'lucas.g@example.com', 'Security Engineer', '#EF4444', '2026-02-12T15:30:00Z'), + ('Emma Watson', 'emma.w@example.com', 'Analyst', '#6366F1', '2026-02-15T10:00:00Z'); + '''); + + batch.rawInsert(''' + INSERT INTO products (title, category, price, stock, rating) VALUES + ('UltraWide 34" Curved Monitor', 'Hardware', 749.99, 45, 4.8), + ('Ergonomic Mechanical Keyboard', 'Peripherals', 189.50, 120, 4.9), + ('Precision Wireless Mouse', 'Peripherals', 89.00, 200, 4.7), + ('Noise Cancelling Studio Headphones', 'Audio', 299.99, 60, 4.8), + ('USB-C 100W Multiport Dock', 'Accessories', 129.99, 85, 4.6), + ('4K Ultra HD Streaming Webcam', 'Video', 159.00, 95, 4.5), + ('Adjustable Standing Desk Mat', 'Office', 69.95, 150, 4.7), + ('Thunderbolt 4 Pro Cable 2m', 'Accessories', 49.00, 300, 4.9); + '''); + + batch.rawInsert(''' + INSERT INTO orders (user_id, product_id, product_name, amount, currency, status, created_at) VALUES + (1, 1, 'UltraWide 34" Curved Monitor', 749.99, 'USD', 'delivered', '2026-02-16T10:30:00Z'), + (2, 2, 'Ergonomic Mechanical Keyboard', 189.50, 'USD', 'delivered', '2026-02-16T14:15:00Z'), + (3, 4, 'Noise Cancelling Studio Headphones', 299.99, 'USD', 'shipped', '2026-02-17T09:00:00Z'), + (4, 3, 'Precision Wireless Mouse', 89.00, 'USD', 'delivered', '2026-02-17T11:45:00Z'), + (5, 5, 'USB-C 100W Multiport Dock', 129.99, 'USD', 'processing', '2026-02-18T08:20:00Z'), + (6, 6, '4K Ultra HD Streaming Webcam', 159.00, 'USD', 'shipped', '2026-02-18T13:10:00Z'), + (7, 2, 'Ergonomic Mechanical Keyboard', 189.50, 'USD', 'delivered', '2026-02-19T16:00:00Z'), + (8, 7, 'Adjustable Standing Desk Mat', 69.95, 'USD', 'processing', '2026-02-20T10:15:00Z'), + (1, 8, 'Thunderbolt 4 Pro Cable 2m', 49.00, 'USD', 'delivered', '2026-02-20T12:40:00Z'), + (3, 1, 'UltraWide 34" Curved Monitor', 749.99, 'USD', 'processing', '2026-02-21T09:30:00Z'), + (5, 4, 'Noise Cancelling Studio Headphones', 299.99, 'USD', 'pending', '2026-02-22T15:00:00Z'), + (2, 5, 'USB-C 100W Multiport Dock', 129.99, 'USD', 'pending', '2026-02-23T11:20:00Z'); + '''); + + await batch.commit(noResult: true); + } + } finally { + await db.close(); + } + } + + /// Gets or registers the demo playground connection row in [LocalDb]. + static Future getOrCreateDemoConnection() async { + final dbPath = await resolveDemoDatabasePath(); + await seedDemoDatabase(dbPath); + + final existingConnections = await LocalDb.instance.getConnections(); + for (final conn in existingConnections) { + if (conn.type == 'sqlite' && (conn.host == dbPath || conn.name == demoConnectionName)) { + return conn; + } + } + + final newRow = ConnectionRow( + type: 'sqlite', + name: demoConnectionName, + host: dbPath, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + + final createdId = await LocalDb.instance.addConnection(newRow); + return newRow.copyWith(id: createdId); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index bde2ad13..356b5727 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -128,6 +128,7 @@ abstract final class AppSettingsKeys { static const updateChannel = 'update_channel'; static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; static const updateDismissedVersion = 'update_dismissed_version'; + static const hasCompletedWelcomeTour = 'has_completed_welcome_tour'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -698,4 +699,21 @@ class AppSettings { } AppSettingsRevision.bump(); } + + /// Whether the user has completed or dismissed the initial interactive welcome tour. + Future getHasCompletedWelcomeTour() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.hasCompletedWelcomeTour, + ); + if (v == null || v.isEmpty) return false; + return v == 'true' || v == '1'; + } + + Future setHasCompletedWelcomeTour(bool completed) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.hasCompletedWelcomeTour, + completed ? 'true' : 'false', + ); + AppSettingsRevision.bump(); + } } diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index eefb344c..690c4a61 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -5,6 +5,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; 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/demo/demo_playground_service.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/layout/querya_split_handle.dart'; import 'package:querya_desktop/core/motion/querya_spring.dart'; @@ -19,6 +20,7 @@ import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; @@ -49,6 +51,16 @@ class _MainScreenState extends State { if (!mounted) return Future.value(false); return showUnsandboxedDriverConsentDialog(context, details); }; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final completed = await AppSettings.instance.getHasCompletedWelcomeTour(); + if (!completed && mounted) { + final connections = await LocalDb.instance.getConnections(); + if (connections.isEmpty && mounted) { + _onOpenWelcomeTour(); + } + } + }); } @override @@ -201,6 +213,23 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + void _onOpenWelcomeTour() { + showWelcomeTourDialog( + context, + onLaunchDemo: _onLaunchDemoPlayground, + ); + } + + Future _onLaunchDemoPlayground() async { + try { + final demoConn = await DemoPlaygroundService.getOrCreateDemoConnection(); + if (!mounted) return; + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + _workspace.value = _workspace.value.selectConnection(demoConn); + _onSqliteOpenSqlWorkspace(demoConn); + } catch (_) {} + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -222,6 +251,19 @@ class _MainScreenState extends State { LogicalKeyboardKey.keyB, meta: true, ): () => _splitKey.currentState?.toggleSidebar(), + const material.SingleActivator( + LogicalKeyboardKey.f1, + ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ): () => _onOpenWelcomeTour(), }, child: SqlEditorGlobalActions( activeConnection: workspace.activeConnection, @@ -246,6 +288,7 @@ class _MainScreenState extends State { isSidebarVisible: isSidebarVisible, onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), + onOpenWelcomeTour: _onOpenWelcomeTour, onReadOnlyChanged: () { _workspace.value = _workspace.value.toggleReadOnly(); @@ -308,6 +351,8 @@ class _MainScreenState extends State { onRequestNewConnectionFromUrl: _onNewDatabaseConnectionFromUrl, onRequestOpenSqlite: _openSqliteFromHero, + onRequestLaunchDemo: _onLaunchDemoPlayground, + onRequestOpenTour: _onOpenWelcomeTour, onOpenConnection: _onConnectionSelected, ), ), @@ -341,6 +386,8 @@ class _MainContentSplit extends StatefulWidget { required this.onRequestNewConnection, required this.onRequestNewConnectionFromUrl, required this.onRequestOpenSqlite, + this.onRequestLaunchDemo, + this.onRequestOpenTour, required this.onOpenConnection, this.onSidebarVisibilityChanged, }); @@ -380,6 +427,8 @@ class _MainContentSplit extends StatefulWidget { final VoidCallback onRequestNewConnection; final VoidCallback onRequestNewConnectionFromUrl; final VoidCallback onRequestOpenSqlite; + final VoidCallback? onRequestLaunchDemo; + final VoidCallback? onRequestOpenTour; final void Function(ConnectionRow) onOpenConnection; @override @@ -629,6 +678,8 @@ class _MainContentSplitState extends State<_MainContentSplit> onRequestNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, onRequestOpenSqlite: widget.onRequestOpenSqlite, + onRequestLaunchDemo: widget.onRequestLaunchDemo, + onRequestOpenTour: widget.onRequestOpenTour, onOpenConnection: widget.onOpenConnection, ); }, diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 9681997d..9ac830f0 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -31,6 +31,7 @@ class QueryaWindowTitleBar extends StatelessWidget { this.onReadOnlyChanged, this.isSidebarVisible = true, this.onToggleSidebar, + this.onOpenWelcomeTour, }); final Future Function() onNewDatabaseConnection; @@ -45,6 +46,7 @@ class QueryaWindowTitleBar extends StatelessWidget { final VoidCallback? onReadOnlyChanged; final bool isSidebarVisible; final VoidCallback? onToggleSidebar; + final VoidCallback? onOpenWelcomeTour; @visibleForTesting static Color titleBarBackground(BuildContext context) => @@ -300,6 +302,18 @@ class QueryaWindowTitleBar extends StatelessWidget { ), MenuButton( subMenu: [ + MenuButton( + leading: const material.Icon( + material.Icons.auto_awesome_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+Shift+H' : 'F1', + ).xSmall().muted(), + onPressed: (_) => onOpenWelcomeTour?.call(), + child: const Text('Welcome & Quick Tour…'), + ), + const MenuDivider(), MenuButton( onPressed: (ctx) => showAboutDialog(ctx), child: const Text('About')), diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 06252381..8faef9c3 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -17,6 +17,8 @@ class WorkspaceEmptyHero extends StatefulWidget { required this.onNewConnection, this.onNewConnectionFromUrl, this.onOpenSqlite, + this.onLaunchDemo, + this.onOpenTour, this.onOpenConnection, this.recentConnections, }); @@ -24,6 +26,8 @@ class WorkspaceEmptyHero extends StatefulWidget { final VoidCallback onNewConnection; final VoidCallback? onNewConnectionFromUrl; final VoidCallback? onOpenSqlite; + final VoidCallback? onLaunchDemo; + final VoidCallback? onOpenTour; final ValueChanged? onOpenConnection; /// When provided (e.g. in tests), skips loading recent connections from storage. @@ -133,7 +137,7 @@ class _WorkspaceEmptyHeroState extends State { ), const material.SizedBox(height: 8), material.Text( - 'Connect to a database or open a local SQLite file.', + 'Connect to a database, try the demo playground, or take a quick tour.', textAlign: material.TextAlign.center, style: material.TextStyle( color: cs.mutedForeground, @@ -155,6 +159,16 @@ class _WorkspaceEmptyHeroState extends State { ), child: const Text('New connection'), ), + if (widget.onLaunchDemo != null) + OutlineButton( + key: const Key('empty_try_demo_playground'), + onPressed: widget.onLaunchDemo, + leading: const material.Icon( + material.Icons.play_circle_outline_rounded, + size: 18, + ), + child: const Text('Demo Playground'), + ), if (widget.onNewConnectionFromUrl != null) OutlineButton( key: const Key('empty_new_from_url'), @@ -205,6 +219,9 @@ class _WorkspaceEmptyHeroState extends State { wb.borderSubtle.withValues(alpha: 0.55), foreground: cs.foreground, primary: cs.primary, + onLaunchDemo: widget.onLaunchDemo, + onOpenTour: widget.onOpenTour, + onNewConnection: widget.onNewConnection, ), ), ], @@ -225,6 +242,9 @@ class _QuickStartSection extends StatelessWidget { required this.borderColor, required this.foreground, required this.primary, + this.onLaunchDemo, + this.onOpenTour, + required this.onNewConnection, }); final bool compact; @@ -232,9 +252,15 @@ class _QuickStartSection extends StatelessWidget { final material.Color borderColor; final material.Color foreground; final material.Color primary; + final VoidCallback? onLaunchDemo; + final VoidCallback? onOpenTour; + final VoidCallback onNewConnection; @override Widget build(BuildContext context) { + final openTour = onOpenTour; + final launchDemo = onLaunchDemo; + return material.Container( padding: material.EdgeInsets.all(compact ? 16 : 20), decoration: material.BoxDecoration( @@ -254,22 +280,40 @@ class _QuickStartSection extends StatelessWidget { ), ), const material.SizedBox(height: 12), - _QuickStartRow( + if (openTour != null) ...[ + _InteractiveQuickStartRow( + icon: material.Icons.auto_awesome_rounded, + title: 'Interactive Quick Tour', + description: + 'Learn connections, shortcuts & grid navigation in 4 visual steps', + color: primary, + actionLabel: 'Start Tour', + onTap: openTour, + ), + const material.SizedBox(height: 10), + ], + if (launchDemo != null) ...[ + _InteractiveQuickStartRow( + icon: material.Icons.play_arrow_rounded, + title: '1-Click Demo Playground', + description: + 'Instant sample SQLite database with ready-to-run queries & grid', + color: const material.Color(0xFF10B981), + actionLabel: 'Launch Demo', + onTap: launchDemo, + ), + const material.SizedBox(height: 10), + ], + _InteractiveQuickStartRow( icon: material.Icons.dns_rounded, - title: 'Server databases', + title: 'Server & Local Databases', description: - 'PostgreSQL, MySQL, MongoDB, Redis and extension drivers', - color: primary, + 'PostgreSQL, MySQL, MongoDB, Redis, SQLite & custom extension drivers', + color: const material.Color(0xFF8B5CF6), + actionLabel: 'Connect', + onTap: onNewConnection, ), - const material.SizedBox(height: 12), - _QuickStartRow( - icon: material.Icons.insert_drive_file_rounded, - title: 'Local database', - description: - 'Open an existing SQLite file or create a connection', - color: primary, - ), - const material.SizedBox(height: 12), + const material.SizedBox(height: 10), _QuickStartRow( icon: material.Icons.security_rounded, title: 'Credentials stay protected', @@ -418,6 +462,136 @@ class _RecentConnectionRow extends StatelessWidget { } } +class _InteractiveQuickStartRow extends StatefulWidget { + const _InteractiveQuickStartRow({ + required this.icon, + required this.title, + required this.description, + required this.color, + required this.actionLabel, + required this.onTap, + }); + + final material.IconData icon; + final String title; + final String description; + final material.Color color; + final String actionLabel; + final VoidCallback onTap; + + @override + State<_InteractiveQuickStartRow> createState() => + _InteractiveQuickStartRowState(); +} + +class _InteractiveQuickStartRowState extends State<_InteractiveQuickStartRow> { + var _hovered = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final wb = context.workbench; + + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.GestureDetector( + onTap: widget.onTap, + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: material.BoxDecoration( + color: _hovered + ? wb.borderSubtle.withValues(alpha: 0.2) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _hovered + ? wb.borderSubtle.withValues(alpha: 0.6) + : material.Colors.transparent, + ), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Container( + width: 32, + height: 32, + decoration: material.BoxDecoration( + color: widget.color.withValues(alpha: 0.15), + shape: material.BoxShape.circle, + ), + child: material.Icon(widget.icon, size: 16, color: widget.color), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + widget.title, + style: material.TextStyle( + color: cs.foreground, + fontSize: 13, + fontWeight: material.FontWeight.w600, + ), + ), + const material.SizedBox(height: 2), + material.Text( + widget.description, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 12, + height: 1.35, + ), + ), + ], + ), + ), + const material.SizedBox(width: 8), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: _hovered + ? wb.accent.withValues(alpha: 0.2) + : wb.borderSubtle.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.actionLabel, + style: material.TextStyle( + color: _hovered ? wb.accent : cs.mutedForeground, + fontSize: 11, + fontWeight: material.FontWeight.w600, + ), + ), + const material.SizedBox(width: 4), + material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: _hovered ? wb.accent : cs.mutedForeground, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + class _QuickStartRow extends StatelessWidget { const _QuickStartRow({ required this.icon, diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index c33bd719..f11c7846 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -58,9 +58,14 @@ class WorkspacePanel extends StatefulWidget { this.onRequestNewConnection, this.onRequestNewConnectionFromUrl, this.onRequestOpenSqlite, + this.onRequestLaunchDemo, + this.onRequestOpenTour, this.onOpenConnection, }); + final VoidCallback? onRequestLaunchDemo; + final VoidCallback? onRequestOpenTour; + /// Currently selected connection from the sidebar. final ConnectionRow? activeConnection; final bool isReadOnly; @@ -147,6 +152,8 @@ class _WorkspacePanelState extends State { onNewConnection: widget.onRequestNewConnection ?? () {}, onNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, onOpenSqlite: widget.onRequestOpenSqlite, + onLaunchDemo: widget.onRequestLaunchDemo, + onOpenTour: widget.onRequestOpenTour, onOpenConnection: widget.onOpenConnection, ), ); diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart new file mode 100644 index 00000000..87ef9854 --- /dev/null +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows the interactive Welcome Tour dialog. +Future showWelcomeTourDialog( + material.BuildContext context, { + material.VoidCallback? onLaunchDemo, +}) { + return showAppDialog( + context: context, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: WelcomeTourDialog(onLaunchDemo: onLaunchDemo), + ), + ); +} + +class WelcomeTourDialog extends StatefulWidget { + const WelcomeTourDialog({ + super.key, + this.onLaunchDemo, + }); + + final material.VoidCallback? onLaunchDemo; + + @override + State createState() => _WelcomeTourDialogState(); +} + +class _WelcomeTourDialogState extends State { + int _currentStep = 0; + static const int _totalSteps = 4; + + void _next() { + if (_currentStep < _totalSteps - 1) { + setState(() => _currentStep++); + } else { + _finish(); + } + } + + void _previous() { + if (_currentStep > 0) { + setState(() => _currentStep--); + } + } + + void _finish() { + unawaited(AppSettings.instance.setHasCompletedWelcomeTour(true)); + if (mounted && material.Navigator.of(context).canPop()) { + material.Navigator.of(context).pop(); + } + } + + void _launchDemoAndClose() { + unawaited(AppSettings.instance.setHasCompletedWelcomeTour(true)); + if (mounted && material.Navigator.of(context).canPop()) { + material.Navigator.of(context).pop(); + } + widget.onLaunchDemo?.call(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final wb = context.workbench; + + final isMac = Platform.isMacOS; + final cmdCtrl = isMac ? 'Cmd' : 'Ctrl'; + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.arrowRight): _next, + const material.SingleActivator(LogicalKeyboardKey.arrowLeft): _previous, + const material.SingleActivator(LogicalKeyboardKey.enter): _next, + const material.SingleActivator(LogicalKeyboardKey.escape): _finish, + }, + child: QueryaDialogCard( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 580, + minWidth: 420, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header row + material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + ), + child: material.Icon( + material.Icons.auto_awesome_rounded, + size: 20, + color: wb.accent, + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Welcome to Querya').semiBold().large(), + Text('Step ${_currentStep + 1} of $_totalSteps') + .xSmall() + .muted(), + ], + ), + ), + material.IconButton( + icon: const material.Icon( + material.Icons.close_rounded, + size: 18, + ), + tooltip: 'Close', + splashRadius: 16, + onPressed: _finish, + ), + ], + ), + const material.SizedBox(height: 18), + + // Slide body + _buildSlideContent( + step: _currentStep, + cmdCtrl: cmdCtrl, + cs: cs, + wb: wb, + ), + const material.SizedBox(height: 20), + + // Footer controls & Step dots + material.Row( + children: [ + // Step indicator dots + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: List.generate(_totalSteps, (index) { + final active = index == _currentStep; + return material.Container( + margin: const material.EdgeInsets.symmetric(horizontal: 3), + width: active ? 20 : 6, + height: 6, + decoration: material.BoxDecoration( + color: active + ? wb.accent + : wb.mutedForeground.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(3), + ), + ); + }), + ), + const material.Spacer(), + + // Action buttons + if (_currentStep > 0) + OutlineButton( + key: const Key('welcome_tour_prev_button'), + onPressed: _previous, + child: const Text('Back'), + ) + else + GhostButton( + key: const Key('welcome_tour_skip_button'), + onPressed: _finish, + child: const Text('Skip'), + ), + const material.SizedBox(width: 8), + + PrimaryButton( + key: const Key('welcome_tour_next_button'), + onPressed: _next, + child: Text( + _currentStep == _totalSteps - 1 + ? 'Get Started' + : 'Next', + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildSlideContent({ + required int step, + required String cmdCtrl, + required ColorScheme cs, + required QueryaWorkbenchTheme wb, + }) { + switch (step) { + case 0: + return _TourSlideView( + key: const ValueKey(0), + icon: material.Icons.hub_rounded, + iconColor: const material.Color(0xFF06B6D4), // Cyan + title: 'Connect in Seconds', + description: + 'Connect directly to PostgreSQL, MySQL, SQLite, Redis, MongoDB or custom extension drivers. Paste a connection URL or use our visual form with real-time field validation.', + tipText: 'Press $cmdCtrl+N anytime to create a new connection.', + shortcutBadge: '$cmdCtrl+N', + wb: wb, + ); + case 1: + return _TourSlideView( + key: const ValueKey(1), + icon: material.Icons.view_sidebar_rounded, + iconColor: const material.Color(0xFF8B5CF6), // Purple + title: 'Fluid Sidebar & Navigation', + description: + 'Browse database schemas, tables, and views with full keyboard navigation. Right-click any object for instant SELECT queries and DDL copy.', + tipText: + 'Collapse the sidebar with $cmdCtrl+B for an expansive full-screen workspace.', + shortcutBadge: '$cmdCtrl+B', + wb: wb, + ); + case 2: + return _TourSlideView( + key: const ValueKey(2), + icon: material.Icons.table_chart_rounded, + iconColor: const material.Color(0xFF10B981), // Emerald + title: 'Interactive SQL & 2D Grid', + description: + 'Run queries instantly with $cmdCtrl+Enter. Sort results by clicking headers, drag column borders to resize, and Shift+Click to copy cell ranges in TSV format.', + tipText: + 'Copied cell ranges paste directly into Google Sheets and Excel.', + shortcutBadge: '$cmdCtrl+Enter', + wb: wb, + ); + case 3: + default: + return _TourSlideView( + key: const ValueKey(3), + icon: material.Icons.shield_rounded, + iconColor: const material.Color(0xFFF59E0B), // Amber + title: 'Zero-Trust Security & Playground', + description: + 'Database credentials stay protected inside your operating system secure keychain. Try our 1-click Demo Playground to explore Querya instantly without server setup.', + tipText: + 'Toggle Read-Only mode in the title bar for risk-free production exploration.', + shortcutBadge: 'Protected', + actionButton: widget.onLaunchDemo != null + ? OutlineButton( + key: const Key('welcome_tour_demo_button'), + onPressed: _launchDemoAndClose, + leading: const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Try Demo Playground Now'), + ) + : null, + wb: wb, + ); + } + } +} + +class _TourSlideView extends StatelessWidget { + const _TourSlideView({ + super.key, + required this.icon, + required this.iconColor, + required this.title, + required this.description, + required this.tipText, + required this.shortcutBadge, + this.actionButton, + required this.wb, + }); + + final material.IconData icon; + final material.Color iconColor; + final String title; + final String description; + final String tipText; + final String shortcutBadge; + final Widget? actionButton; + final QueryaWorkbenchTheme wb; + + @override + Widget build(BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + // Graphic & Badge banner + material.Container( + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: wb.canvas, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: wb.borderSubtle.withValues(alpha: 0.5)), + ), + child: material.Row( + children: [ + material.Container( + width: 44, + height: 44, + decoration: material.BoxDecoration( + color: iconColor.withValues(alpha: 0.15), + shape: material.BoxShape.circle, + ), + child: material.Icon( + icon, + size: 24, + color: iconColor, + ), + ), + const material.SizedBox(width: 14), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(title).semiBold().base(), + const material.SizedBox(height: 4), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.6), + ), + ), + child: Text( + shortcutBadge, + style: TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: wb.accent, + ), + ), + ), + ], + ), + ), + ], + ), + ), + const material.SizedBox(height: 14), + + // Description + Text( + description, + style: TextStyle( + color: wb.mutedForeground, + fontSize: 13, + height: 1.4, + ), + ), + const material.SizedBox(height: 12), + + // Tip banner + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: wb.accent.withValues(alpha: 0.2)), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.lightbulb_outline_rounded, + size: 16, + color: wb.accent, + ), + const material.SizedBox(width: 8), + material.Expanded( + child: Text( + tipText, + style: TextStyle( + fontSize: 12, + color: wb.onAccent, + height: 1.3, + ), + ), + ), + ], + ), + ), + + if (actionButton != null) ...[ + const material.SizedBox(height: 14), + actionButton!, + ], + ], + ); + } +} diff --git a/test/core/demo/demo_playground_service_test.dart b/test/core/demo/demo_playground_service_test.dart new file mode 100644 index 00000000..5e74ea60 --- /dev/null +++ b/test/core/demo/demo_playground_service_test.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/demo/demo_playground_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_demo_service_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('DemoPlaygroundService resolves path and seeds demo SQLite database', () async { + final dbPath = await DemoPlaygroundService.resolveDemoDatabasePath(); + expect(dbPath.endsWith('demo_playground.sqlite'), isTrue); + + await DemoPlaygroundService.seedDemoDatabase(dbPath); + expect(File(dbPath).existsSync(), isTrue); + + final db = await databaseFactoryFfi.openDatabase(dbPath); + try { + final users = await db.rawQuery('SELECT * FROM users;'); + expect(users.length, equals(8)); + + final products = await db.rawQuery('SELECT * FROM products;'); + expect(products.length, equals(8)); + + final orders = await db.rawQuery('SELECT * FROM orders;'); + expect(orders.length, equals(12)); + } finally { + await db.close(); + } + + // Calling seedDemoDatabase a second time must be idempotent + await DemoPlaygroundService.seedDemoDatabase(dbPath); + final db2 = await databaseFactoryFfi.openDatabase(dbPath); + try { + final users2 = await db2.rawQuery('SELECT * FROM users;'); + expect(users2.length, equals(8)); + } finally { + await db2.close(); + } + }); + + test('DemoPlaygroundService getOrCreateDemoConnection creates and reuses connection row', () async { + final conn1 = await DemoPlaygroundService.getOrCreateDemoConnection(); + expect(conn1.name, equals(DemoPlaygroundService.demoConnectionName)); + expect(conn1.type, equals('sqlite')); + expect(conn1.id, isNotNull); + + final conn2 = await DemoPlaygroundService.getOrCreateDemoConnection(); + expect(conn2.id, equals(conn1.id)); + expect(conn2.name, equals(conn1.name)); + }); + + test('DemoPlaygroundService demoDefaultQuery is non-empty and contains key tables', () { + const query = DemoPlaygroundService.demoDefaultQuery; + expect(query.contains('FROM orders'), isTrue); + expect(query.contains('JOIN users'), isTrue); + expect(query.contains('JOIN products'), isTrue); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index ec00eb0f..7cf70e6c 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -415,5 +415,13 @@ void main() { await AppSettings.instance.setCheckForUpdatesOnStartup(false); expect(await AppSettings.instance.getCheckForUpdatesOnStartup(), isFalse); }); + + test('hasCompletedWelcomeTour defaults to false and roundtrips true', () async { + expect(await AppSettings.instance.getHasCompletedWelcomeTour(), isFalse); + await AppSettings.instance.setHasCompletedWelcomeTour(true); + expect(await AppSettings.instance.getHasCompletedWelcomeTour(), isTrue); + await AppSettings.instance.setHasCompletedWelcomeTour(false); + expect(await AppSettings.instance.getHasCompletedWelcomeTour(), isFalse); + }); }); } diff --git a/test/features/main_screen/goldens/workspace_empty_hero.png b/test/features/main_screen/goldens/workspace_empty_hero.png index c01458566a55a21a38879ac56471ef8ae0c0bc84..a61ba3a1962353fc972d5ac62dc8a7f6da20f78a 100644 GIT binary patch literal 8374 zcmdT~c|6o>+y9{ssVt@F*bYuHWsNLj>u5w|Crh>{Yt|XrXE+ieQXNZn6gbnjD76${&b$@ocFvv&-1>YXZgJI&wS?hyYK6|?rZs8%RP6E4RsE)f6op8 zz~M`}FjD|vxeWl!5p3UqPnw=prh{Mm;98f=*}zX2+x1xRHxt}cM++z+3Qz&y2jCLy zC-Wfm94Y*Tt!udO0vUdbMk?aHntK1H(o5%0yluykEKv@6hcElZx$F5d%QM*qvSAeX zbhUaA%<_?sPZ>PEbiqCNph1kRY~9KGDfitwdJXRy9N=Sqc~~p;o|N;!>(>48;WtQW zq~#UP8lINQFk0Vy7GWz)Lw>iNf~FKvvPhu~TWEE0W>B>M2cL)|*nAeC(G(rr8tD+1 zqS=i|jE#LuJxsT+UNg!}WCG$X&Iy9&h;WerEdfNEvM~VwJC+MP9*>^LJ^+v~$AgOh zU<4VTAmbfL4c|AFb=JZet^Q2`QOb&d!fSRB0671IM+>B(1^EVz6fHIYFqVks0)QOj zf60bFNvSekD^D9mks=Zg1J2Fg;Ph`Xn^nDO50e`)d1!-dkZdpeipdPBDk|z<-x}2J zTNnE_xuHH+elhV{!qm1x)*|x3K=$JCUpAcTn)#+T-h6gf5{#IBsw)%ABf~!yyTLdd zmb#ca5yTDQq{;tCDOgOPKdjqLsNy5?PDr)LcJf{{>O<_ED&xs-4EL{B`u~Chjsyzm z$b>_*b=;)4G|#7sp~AIY#|5Jf>~Hih;5sg;vm5fxJ%}YAvDOv2tlo0<&V;%j@eo{+ zLSaj=#OjMuR$26#ohIuvOzUoz;iaYmCReLET9=Vtb=u$WsqDmf5pTqli%VtbgsC2z z*pIEe1<<)7(8C+umIVL9&iQxr3n&v#njmn`)*I?LCuD16kF-#H*S3N<>m%jp;Q`*Y$_FCpXxD6 zdAMsMT{?2DAyK&!2Zl8OC|vj&s-p^*&wg2K!EAgf#S+CEtIPhmc<@aX{R>`5vKE4hGtO8hs?yN4hH29R zu9lC;&F5iuIwiPjJZ`*lZlsFbgAjW6DUIubU;kVZM!pBBWa~wca{Tp^4kX$XT&gnod(j{sbV|{UkPMi$E#enb3vEtp1Rx z|a*Lmv&uH}^JP~C*u#q-jzlB#ihq<$eoM6NvqQ8pXaPh>xmP>K#&E@mTzVEfF$h85p*I~PtVY@!>et2PS zREG+y#)V`f+w|?by`h{FIwIF){p-r8Y(RM0(pBDCcRNdlq$ooc+n>hD&(0SJv8aMQ z)j_TQLa;oCiC)a(>JH4D$NQYtvF%52L?GL_)3Vkcv0jmM8$rv&zeTC8o1Z`6pO|SH z*Pcmyy>7xrZO}2QQx?t7NMw=)&I}h_v48c#VEGsX8^bEdCUHTp&t7T9nTtnjv&6$f zgTUOuG+U1CbR}zVM1^ zJ6ilPOUAaJptAra+8@1H!MYXr!Z!O=qm(7x=ffvFbvhI2vvX(lpzUEhp*8h7+FFr) zNS}{zr(BDl{pQs8K^bK8qLA~5;7I+4Y=yi$;pL^?L$-k?Ynr zZ>JnWrB%HotbvS|_w~D47LTX_o2}nSuD`1teg`W;mpflhK3N8Ypq!11<-a1pncI^5 zbLEvyg$|)FsC>Y^5RzxfjvFlO6;%L8U_JAPa^m!!hrp*XNuylJ$O3RtR5Ce$w!gFX zQ+nm_%Nw4Cg(1zpXPHjEt=WBJ7wAQ&tA^kBOVx3x1wmYm%fA3je7Z}2*qZUHY4PUG zmLQJFO2h{x;nhWbzV{W#;oBI3G*@L9eziT`e%{Ly%F>u4xuPzOt&_^#EPO4_{O-Qu zG`fBW!QPUjrAwZJGA7lzsZa9Zz@f$+9r8q$wVh>^&SVk1WT#|*qCPR;j3`o(GR!U# zPD$g5N**T}=m4MbDP#Br1AKO3tOcaG^N=EsaYULn?A*C?i#7RCvdGcO)JTV z$~F3aBz1bFox3bYzX-T!6K5B!K|q$_N#XRhQtipWDcc<+%vSlaZ)5d46$dnYGfAaY zHRY%8z`}_opiOD1={51S=G$b{FB{)UULc^CXr-CfbUb5WTcM#h>gHJ3m}V(%B^f=D z$=&CG<>6dYIwfo9pV%Wh<0(3xi=H^F8tBc)%$Jv|-;N|2qr)kYX)C5kuSd@MiGu>% zjAvh=Fb3k}IysnLv_1%;)|S5}N3DBLv$%mF5h;sZ8IBE7rVOT(>;$)XJ`Y`IlB81L-BSZxwAQP^z@bv2NT?{sG}>pdW*N^Yr`k3K znX*OJkG!IeKZIgx=-N%G=;crx?i>kmDq`x{K)d)Wn+@_nz-X2!ZD783wgmLgeF?nL zT%U{noEp|rx*3VOs4-9^zx(hVP&wC%j&+XIhi~oXF9Q>K9#BBmetJ3tl>y&+;8WWai@pcZ#nb^e1vEVVHA(P`F{5;@;yq zusfL$!6HmylCv}pZ@lve@1`F*UZ!sSSl$Dy@b9qByLKa=e6``)j`ogz^A8ClHL7}> zi_lFuI0Rb}4n1zH9L>cS>_C_+5>C&^7=H0M+{ML3sD8VnQ1RN*+A8?FQl7Cj|d4t@lvDPWiY zM}ZBFKeR`B2CP8KFEtczf(S)f3Oj=WjpK-(vUPZ9s#fjXy2cOMMG|%8&y85P$pFEoPeF)JIQh5%;@>R#YaQH(8S912A`u)=2H!<0o zOYcEcn)qqYGy^4GtYYKFu$}F%mP0S!;9h)7ppVTyFv*eO(aP~Ljbo(1ZL{HoT(Ah3 zIEA%3mQ3bJX1v(*jD^7iIi;01RcP_tRAaq9&_9{Ma4&JlHoZJIShnM^=VQWO?Rk5R2@^(U?4W>&W)&EmL4Iu#A1qY^=SP)PM^rRLh1yic zEC*JPamx)Mq$6&)ouzCp#cqakm$@R5(?1KU-csvENI2G2U+3X_yyvZ_uq$M-acgcr z%2CF91ZYJ-{my_v40FpvFjPmU-}9pRyVPZ{9&o*(!>&N0 z2xU8nSSFuKaHbGd0~Ii=e+wD@o$nkEfPG`E@>GhMp&^8zf}Nq5>hYWDd5U&l2f+o% zK`q%5H;joJukA~B!z`Lv>0gGHeR~S^3b+(MPd+r#D8dahPGWcz`P%v_! zaCo^vD-#g$BoqCmskgWHrX78kl%*c3@d1%_q#}dEI0v!RR)Bnkv++S~FH&!T91%hQ z;07xli1)4<$wH$;HgFbaj8wzc7aWu@g$nGS?qkB13TbXtR~c&ypIS}r2EH=5IEBs| z!5EpDoyn^5=)*nE%Gx#UN!QEe;^?Xk;I}7H5gNYh3kck-eUBg5ME)os6-{`YVY8Tf z_wHI?l7Pxf0`)TX87R;LL)Y=S@^T%Ved!2-2LSjW1bZbHS65tH8*wCqXnG{tS6A`o zps%4GHMqRo`v(>PFuHN;)+rn=WbDpiexf&2AHwrS#J%Da@&*z)Ht{0>d~}~gJ0p=A zHpTs=3K%R8cm{Kekg4rD)i`FLv8jOh=qgfs_XQiOHs~etj0CHV!h+dfgT3{Ub`1^X@<154~d%PAg2a3lFdjo{Hxs zkWXYPYR3uSZ;>=px3poSA+zLWUY|NYldZ>{1yv~<`vAvaW45%yGo@jEb&H#`nTc{Q zk6(4{pp@;+Y9`uF=7G-rKTLgoSGoP;5c%v@50h2KRi7RF$+^Ime1l}s))G5<(iF0+ zsYWi_$fpnv4x>Kn8?Z_=x7;hBT!`UN5Cj!*aV+DUr!#$?74b;Kw@uLqO%n_dl9wBJP-Y!t=VX`VmI?Kdk`)nuTwQ@Y=$A*DvBUpY^0Aa8s5#FWa5 zPLp_bPQVdt0WS7Gk_j=X+mLD)MkL(ultZ}*b=q=zqWzr5Vzska0a-Tb6JSp-@plxw zGWXy5Bk>+C%yJJv#_{)`SR(X#Uv>L%gb<+fw*gT z>(0hppJ?e>esX~|BXLn|uHXE*^`aSz=6?Ulpq00rxg(h;J}U!_&o0TybtPVD-~7eD zYc>E3c?BUzrOW%_LBbZ3-3_*|m>0(42t%QF6efu2bD?f4(2aE=DX(wj_jz1xle%x| z9tj!(W?>)z&#batrN=|3w|r{H-5Cx=8L`Bsna7wwiU}a0jo(>8xs+&JGf>+?7axQg zx_aDz!8(X)_A8a#+;kE_JzrS$RgPC0j^v%xaa!b;CtSzfaD@D1xno*@gpQsON@Tda zbLjih+IfQu@8vl(=!={!81i--=UjFuHGce2>CV*tImU+3&ZV%~N@o;m!%9;LOo3L9 zPgmrUW+a2QoQt=cMXwwgDK<1OJQgr774#)GHW2l>JbpN9ajkOJf$(e&quJ`Bk;bc8 zx@+?#>}8Mz)=E+b={;q_c2IuEzVGv({Bn|_q6W}dUAx225Z_%oirP+L&?u$h&bBMt zyd3IB+oO^I_8xD8V=ACKEL>;5b`94%n(O<}c@J>&xsKW;v1vgDkqQ$TU_4xG_=a_M#&l=4WvSI}0GabjbYAlYrir#AK!casGm5-Zy zFMOWQ+~k{i9OEjSn7*MC13d+NK;OOF=2mfMi%FribYQk+dpoR0&gp$dMOi4FrS=_H zb-P=n%e3%4d$8GGUY!ZP!3An`t~PztzjvMtQk>@?K!8&)sw3t4G6o1H3F=__AO7B$OTc>UFv#?D6uB7)n%CtOFkxt9rI zehZ7V*0{EZx$zVJm1d}@q1b*1%s zXuuvE(y4oJs4h^xoOJqU0C>>>^1Ot>gR2|`(pCA2Jw$E#Cc9*QF6ckXXh=kdeg#)N zMgagME!jm{dy6QKKo8_I%ewG4Z!c^{ds?ku-s3mwjNuS;gMwFC33cZ@`&;HooS#o$ zaQw^o;XiasvX|6aeMsUyiEHJZ*1Z~}t@*^A(opJymXRvP#|LXstFwkSD;g8G>ZNXQ zq&kgGdyO8NRRbbioqm=43R~;j4)V6P+NSVWNQakU7$wwsW~xE<)nx$0z?c7v$_38G zbaAwPhcz=1R1qZFH{mj2n^$eGX0(z`OW3<{0>?W!Jp4*oS)78<4~Rpc@`FA5pWh?l zg+Ex_Y5Z0hGF5g|au~0_yhMt23URsMrvwP>WLhjPh|`R`KXS zq0&3|P|cL%kF%v;ue-jPmxE>1frJB`)89Fk{dVGQ?g2dy`D?fXfb3HLb8CKlpk_|d zp-2$5|J$|Lp0GCVRp(G(FnL44Lm#^R$3?%rfBU9DzrAdG1XjOs|8y7pbbk^Z3c=<( zgO=7+-Bf~Sc|mo+3i(s;W$@M@!dV}pP2A|pho@+X2s}W~ZrK!H>5&6N@@a)lF*tfT z-=m`Ugp1>n9p&oC+}*!Y35Wmb1(DDRrt3THU|asN5S!xUjY)MupuNMq>RAeTCx)j6qd9;GRl~h&X zUf_~(Y4HntBM4T@Vw<51^q0Eo5lj*q-jfVAQhpW|%`<&Pc>}ZcaB_V(stb|zK;rfV zMRoNZ?4vW5UNeY%>G{=KJ+Ob*_N=fe^XNNZGO!vW9OBmLa}HCWzzCULdX|m0vbOAKrvQjPE$9D1c%ViPt?I#Kmp7-eQ*(>G(g?4<*feo+32bEK2jL36B literal 7374 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 getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_welcome_tour_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + testWidgets('WelcomeTourDialog displays steps and allows navigation through all 4 slides', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: WelcomeTourDialog(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Slide 1: Connect in Seconds + expect(find.text('Welcome to Querya'), findsOneWidget); + expect(find.text('Step 1 of 4'), findsOneWidget); + expect(find.text('Connect in Seconds'), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_skip_button')), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_next_button')), findsOneWidget); + + // Click Next -> Slide 2 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 2 of 4'), findsOneWidget); + expect(find.text('Fluid Sidebar & Navigation'), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_prev_button')), findsOneWidget); + + // Click Next -> Slide 3 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 3 of 4'), findsOneWidget); + expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + + // Click Next -> Slide 4 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 4 of 4'), findsOneWidget); + expect(find.text('Zero-Trust Security & Playground'), findsOneWidget); + expect(find.text('Get Started'), findsOneWidget); + + // Click Back -> Slide 3 + await tester.tap(find.byKey(const Key('welcome_tour_prev_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 3 of 4'), findsOneWidget); + expect(find.text('Interactive SQL & 2D Grid'), findsOneWidget); + }); + + testWidgets('WelcomeTourDialog launches demo playground on action button tap', + (tester) async { + var demoLaunched = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: WelcomeTourDialog( + onLaunchDemo: () => demoLaunched = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Advance to slide 4 + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Step 4 of 4'), findsOneWidget); + expect(find.byKey(const Key('welcome_tour_demo_button')), findsOneWidget); + + await tester.tap(find.byKey(const Key('welcome_tour_demo_button'))); + await tester.pumpAndSettle(); + + expect(demoLaunched, isTrue); + }); + + testWidgets('WelcomeTourDialog handles Skip button and dismisses dialog', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: WelcomeTourDialog(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('welcome_tour_skip_button')), findsOneWidget); + await tester.tap(find.byKey(const Key('welcome_tour_skip_button'))); + await tester.pumpAndSettle(); + }); +} From 6e03c1da21dd5b066a11c309c0e6933e6dc0e9e4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 02:02:13 +0300 Subject: [PATCH 11/40] fix(ui): smooth sidebar collapse motion, enhanced tutorial dialog with home navigation, and hotkeys --- lib/core/motion/querya_spring_controller.dart | 3 +- lib/features/main_screen/main_screen.dart | 56 ++++-- .../main_screen/querya_window_title_bar.dart | 44 +++++ .../onboarding/welcome_tour_dialog.dart | 165 ++++++++++++++---- .../onboarding/welcome_tour_dialog_test.dart | 22 +++ 5 files changed, 240 insertions(+), 50 deletions(-) diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart index be401721..ec7a344f 100644 --- a/lib/core/motion/querya_spring_controller.dart +++ b/lib/core/motion/querya_spring_controller.dart @@ -136,9 +136,8 @@ class QueryaSpringController extends ChangeNotifier { 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); + ((next - _target).abs() < 0.15 && _velocity.abs() < 1.0); if (settled) { _value = _target; diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 690c4a61..7c15f792 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -213,10 +213,15 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + void _onGoHome() { + _workspace.value = MainScreenWorkspaceState.empty; + } + void _onOpenWelcomeTour() { showWelcomeTourDialog( context, onLaunchDemo: _onLaunchDemoPlayground, + onGoHome: _workspace.value.activeConnection != null ? _onGoHome : null, ); } @@ -238,6 +243,19 @@ class _MainScreenState extends State { builder: (context, workspace, _) { return material.CallbackShortcuts( bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + control: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + shift: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), const material.SingleActivator( LogicalKeyboardKey.keyN, control: true, @@ -264,6 +282,16 @@ class _MainScreenState extends State { control: true, shift: true, ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.digit0, + meta: true, + shift: true, + ): _onGoHome, + const material.SingleActivator( + LogicalKeyboardKey.digit0, + control: true, + shift: true, + ): _onGoHome, }, child: SqlEditorGlobalActions( activeConnection: workspace.activeConnection, @@ -289,6 +317,7 @@ class _MainScreenState extends State { onToggleSidebar: () => _splitKey.currentState?.toggleSidebar(), onOpenWelcomeTour: _onOpenWelcomeTour, + onGoHome: _onGoHome, onReadOnlyChanged: () { _workspace.value = _workspace.value.toggleReadOnly(); @@ -442,11 +471,11 @@ class _MainContentSplitState extends State<_MainContentSplit> static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; - /// High-responsiveness critically damped spring for fluid sidebar toggling (~0.25s). + /// High-responsiveness critically damped spring for fluid sidebar toggling (~0.22s). static const SpringDescription _sidebarSpring = SpringDescription( mass: 1.0, - stiffness: 480.0, - damping: 43.8, + stiffness: 580.0, + damping: 48.0, ); late final QueryaSpringController _widthSpring; @@ -466,7 +495,7 @@ class _MainContentSplitState extends State<_MainContentSplit> vsync: this, value: kDefaultConnectionsPanelWidth, spring: _sidebarSpring, - cubicDuration: const Duration(milliseconds: 170), + cubicDuration: const Duration(milliseconds: 160), cubicCurve: Curves.easeOutCubic, ); _widthSpring.addListener(_onWidthSpringChanged); @@ -551,13 +580,14 @@ class _MainContentSplitState extends State<_MainContentSplit> return LayoutBuilder( builder: (context, constraints) { _lastMaxWidth = constraints.maxWidth; - final currentW = _widthSpring.value; - final isFullyCollapsed = currentW <= 0.5 && !_widthSpring.isAnimating; - final handleOpacity = (currentW / 40.0).clamp(0.0, 1.0); - final parallaxOffset = (currentW - _lastExpandedWidth) * 0.28; - final contentOpacity = _lastExpandedWidth > 0 - ? (currentW / (_lastExpandedWidth * 0.45)).clamp(0.0, 1.0) - : 1.0; + final currentW = _widthSpring.value.clamp(0.0, constraints.maxWidth); + final isFullyCollapsed = currentW <= 0.001 && !_widthSpring.isAnimating; + final progress = _lastExpandedWidth > 0 + ? (currentW / _lastExpandedWidth).clamp(0.0, 1.0) + : 0.0; + final handleOpacity = (progress * 5.0).clamp(0.0, 1.0); + final contentOpacity = (progress * 2.2).clamp(0.0, 1.0); + final parallaxOffset = (progress - 1.0) * 36.0; return Row( children: [ @@ -602,11 +632,11 @@ class _MainContentSplitState extends State<_MainContentSplit> ), ), ), - if (!isFullyCollapsed || _widthSpring.isAnimating) + if (!isFullyCollapsed) Opacity( opacity: handleOpacity, child: IgnorePointer( - ignoring: currentW <= 20.0, + ignoring: currentW <= 20.0 || !_sidebarVisible, child: QueryaSplitHandle( key: const Key('main_content_resize_handle'), axis: Axis.horizontal, diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 9ac830f0..b254e834 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -32,6 +32,7 @@ class QueryaWindowTitleBar extends StatelessWidget { this.isSidebarVisible = true, this.onToggleSidebar, this.onOpenWelcomeTour, + this.onGoHome, }); final Future Function() onNewDatabaseConnection; @@ -47,6 +48,7 @@ class QueryaWindowTitleBar extends StatelessWidget { final bool isSidebarVisible; final VoidCallback? onToggleSidebar; final VoidCallback? onOpenWelcomeTour; + final VoidCallback? onGoHome; @visibleForTesting static Color titleBarBackground(BuildContext context) => @@ -179,6 +181,20 @@ class QueryaWindowTitleBar extends StatelessWidget { ); }, child: const Text('Save')), + if (activeConnection != null && onGoHome != null) ...[ + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.home_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+Shift+0' : 'Ctrl+Shift+0', + ).xSmall().muted(), + onPressed: (_) => onGoHome?.call(), + child: const Text('Close Workspace (Home)'), + ), + ], const MenuDivider(), MenuButton( onPressed: (_) => appWindow.close(), @@ -222,6 +238,34 @@ class QueryaWindowTitleBar extends StatelessWidget { onPressed: (_) => onToggleSidebar?.call(), child: const Text('Toggle Sidebar'), ), + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.auto_awesome_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS ? 'Cmd+Shift+H' : 'F1', + ).xSmall().muted(), + onPressed: (_) => onOpenWelcomeTour?.call(), + child: const Text('Welcome & Tutorial…'), + ), + if (activeConnection != null && onGoHome != null) ...[ + const MenuDivider(), + MenuButton( + leading: const material.Icon( + material.Icons.home_rounded, + size: 18, + ), + trailing: Text( + Platform.isMacOS + ? 'Cmd+Shift+0' + : 'Ctrl+Shift+0', + ).xSmall().muted(), + onPressed: (_) => onGoHome?.call(), + child: const Text('Home / Start Screen'), + ), + ], ], child: const Text('View'), ), diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index 87ef9854..007b3e80 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -12,13 +12,17 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; Future showWelcomeTourDialog( material.BuildContext context, { material.VoidCallback? onLaunchDemo, + material.VoidCallback? onGoHome, }) { return showAppDialog( context: context, builder: (ctx) => material.Dialog( backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(ctx), - child: WelcomeTourDialog(onLaunchDemo: onLaunchDemo), + child: WelcomeTourDialog( + onLaunchDemo: onLaunchDemo, + onGoHome: onGoHome, + ), ), ); } @@ -27,9 +31,11 @@ class WelcomeTourDialog extends StatefulWidget { const WelcomeTourDialog({ super.key, this.onLaunchDemo, + this.onGoHome, }); final material.VoidCallback? onLaunchDemo; + final material.VoidCallback? onGoHome; @override State createState() => _WelcomeTourDialogState(); @@ -39,6 +45,12 @@ class _WelcomeTourDialogState extends State { int _currentStep = 0; static const int _totalSteps = 4; + void _goToStep(int step) { + if (step >= 0 && step < _totalSteps && step != _currentStep) { + setState(() => _currentStep = step); + } + } + void _next() { if (_currentStep < _totalSteps - 1) { setState(() => _currentStep++); @@ -68,6 +80,14 @@ class _WelcomeTourDialogState extends State { widget.onLaunchDemo?.call(); } + void _goHomeAndClose() { + unawaited(AppSettings.instance.setHasCompletedWelcomeTour(true)); + if (mounted && material.Navigator.of(context).canPop()) { + material.Navigator.of(context).pop(); + } + widget.onGoHome?.call(); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -103,6 +123,9 @@ class _WelcomeTourDialogState extends State { decoration: material.BoxDecoration( color: wb.accent.withValues(alpha: 0.15), borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.3), + ), ), child: material.Icon( material.Icons.auto_awesome_rounded, @@ -122,6 +145,19 @@ class _WelcomeTourDialogState extends State { ], ), ), + if (widget.onGoHome != null) ...[ + GhostButton( + key: const Key('welcome_tour_home_button'), + density: ButtonDensity.compact, + onPressed: _goHomeAndClose, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Home'), + ), + const material.SizedBox(width: 4), + ], material.IconButton( icon: const material.Icon( material.Icons.close_rounded, @@ -135,32 +171,60 @@ class _WelcomeTourDialogState extends State { ), const material.SizedBox(height: 18), - // Slide body - _buildSlideContent( - step: _currentStep, - cmdCtrl: cmdCtrl, - cs: cs, - wb: wb, + // Animated Slide body + material.AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return material.FadeTransition( + opacity: animation, + child: material.SlideTransition( + position: material.Tween( + begin: const material.Offset(0.04, 0), + end: material.Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: _buildSlideContent( + step: _currentStep, + cmdCtrl: cmdCtrl, + cs: cs, + wb: wb, + ), ), const material.SizedBox(height: 20), // Footer controls & Step dots material.Row( children: [ - // Step indicator dots + // Step indicator dots (clickable) material.Row( mainAxisSize: material.MainAxisSize.min, children: List.generate(_totalSteps, (index) { final active = index == _currentStep; - return material.Container( - margin: const material.EdgeInsets.symmetric(horizontal: 3), - width: active ? 20 : 6, - height: 6, - decoration: material.BoxDecoration( - color: active - ? wb.accent - : wb.mutedForeground.withValues(alpha: 0.3), - borderRadius: material.BorderRadius.circular(3), + return material.GestureDetector( + onTap: () => _goToStep(index), + behavior: material.HitTestBehavior.opaque, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 3, + vertical: 6, + ), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: active ? 22 : 6, + height: 6, + decoration: material.BoxDecoration( + color: active + ? wb.accent + : wb.mutedForeground.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(3), + ), + ), ), ); }), @@ -257,17 +321,30 @@ class _WelcomeTourDialogState extends State { tipText: 'Toggle Read-Only mode in the title bar for risk-free production exploration.', shortcutBadge: 'Protected', - actionButton: widget.onLaunchDemo != null - ? OutlineButton( - key: const Key('welcome_tour_demo_button'), - onPressed: _launchDemoAndClose, - leading: const material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - ), - child: const Text('Try Demo Playground Now'), - ) - : null, + actionButtons: [ + if (widget.onLaunchDemo != null) + OutlineButton( + key: const Key('welcome_tour_demo_button'), + onPressed: _launchDemoAndClose, + leading: const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Try Demo Playground Now'), + ), + if (widget.onGoHome != null) ...[ + const material.SizedBox(height: 8), + GhostButton( + key: const Key('welcome_tour_slide_home_button'), + onPressed: _goHomeAndClose, + leading: const material.Icon( + material.Icons.home_rounded, + size: 16, + ), + child: const Text('Return to Start Screen'), + ), + ], + ], wb: wb, ); } @@ -283,7 +360,7 @@ class _TourSlideView extends StatelessWidget { required this.description, required this.tipText, required this.shortcutBadge, - this.actionButton, + this.actionButtons, required this.wb, }); @@ -293,7 +370,7 @@ class _TourSlideView extends StatelessWidget { final String description; final String tipText; final String shortcutBadge; - final Widget? actionButton; + final List? actionButtons; final QueryaWorkbenchTheme wb; @override @@ -307,7 +384,17 @@ class _TourSlideView extends StatelessWidget { decoration: material.BoxDecoration( color: wb.canvas, borderRadius: material.BorderRadius.circular(12), - border: material.Border.all(color: wb.borderSubtle.withValues(alpha: 0.5)), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.5), + ), + gradient: material.LinearGradient( + begin: material.Alignment.topLeft, + end: material.Alignment.bottomRight, + colors: [ + wb.canvas, + wb.surface.withValues(alpha: 0.85), + ], + ), ), child: material.Row( children: [ @@ -317,6 +404,9 @@ class _TourSlideView extends StatelessWidget { decoration: material.BoxDecoration( color: iconColor.withValues(alpha: 0.15), shape: material.BoxShape.circle, + border: material.Border.all( + color: iconColor.withValues(alpha: 0.35), + ), ), child: material.Icon( icon, @@ -373,11 +463,16 @@ class _TourSlideView extends StatelessWidget { // Tip banner material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), decoration: material.BoxDecoration( color: wb.accent.withValues(alpha: 0.08), borderRadius: material.BorderRadius.circular(8), - border: material.Border.all(color: wb.accent.withValues(alpha: 0.2)), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.2), + ), ), child: material.Row( children: [ @@ -401,9 +496,9 @@ class _TourSlideView extends StatelessWidget { ), ), - if (actionButton != null) ...[ + if (actionButtons != null && actionButtons!.isNotEmpty) ...[ const material.SizedBox(height: 14), - actionButton!, + ...actionButtons!, ], ], ); diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index 9bfceee9..761c24d5 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -140,4 +140,26 @@ void main() { await tester.tap(find.byKey(const Key('welcome_tour_skip_button'))); await tester.pumpAndSettle(); }); + + testWidgets('WelcomeTourDialog exposes Home buttons and triggers onGoHome', + (tester) async { + var homeTriggered = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: WelcomeTourDialog( + onGoHome: () => homeTriggered = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('welcome_tour_home_button')), findsOneWidget); + await tester.tap(find.byKey(const Key('welcome_tour_home_button'))); + await tester.pumpAndSettle(); + + expect(homeTriggered, isTrue); + }); } From a972e1c41274beb5a1ed56dc54a236950c6a4a90 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 02:58:56 +0300 Subject: [PATCH 12/40] fix(ui): enhance tip banner contrast, smooth dialog animated resizing, and clean up quick start items --- .../main_screen/workspace_empty_hero.dart | 58 ------------------ .../onboarding/welcome_tour_dialog.dart | 53 ++++++++++------ .../goldens/workspace_empty_hero.png | Bin 8374 -> 7985 bytes 3 files changed, 34 insertions(+), 77 deletions(-) diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index 8faef9c3..903e750f 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -313,14 +313,6 @@ class _QuickStartSection extends StatelessWidget { actionLabel: 'Connect', onTap: onNewConnection, ), - const material.SizedBox(height: 10), - _QuickStartRow( - icon: material.Icons.security_rounded, - title: 'Credentials stay protected', - description: - 'Passwords are stored in your operating system secure store', - color: primary, - ), ], ), ); @@ -592,56 +584,6 @@ class _InteractiveQuickStartRowState extends State<_InteractiveQuickStartRow> { } } -class _QuickStartRow extends StatelessWidget { - const _QuickStartRow({ - required this.icon, - required this.title, - required this.description, - required this.color, - }); - - final material.IconData icon; - final String title; - final String description; - final material.Color color; - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Icon(icon, size: 18, color: color), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Text( - title, - style: material.TextStyle( - color: cs.foreground, - fontSize: 13, - fontWeight: material.FontWeight.w500, - ), - ), - const material.SizedBox(height: 2), - material.Text( - description, - style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 12, - height: 1.35, - ), - ), - ], - ), - ), - ], - ); - } -} - String _connectionSubtitle(ConnectionRow connection) { if (connection.type == 'sqlite') { final path = connection.databaseName ?? connection.connectionString; diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index 007b3e80..d44b595f 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -109,13 +109,17 @@ class _WelcomeTourDialogState extends State { maxWidth: 580, minWidth: 420, ), - child: material.Padding( - padding: const material.EdgeInsets.all(24), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header row + child: material.AnimatedSize( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + alignment: material.Alignment.topCenter, + child: material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header row material.Row( children: [ material.Container( @@ -261,8 +265,9 @@ class _WelcomeTourDialogState extends State { ), ), ), - ); - } + ), + ); +} Widget _buildSlideContent({ required int step, @@ -468,27 +473,37 @@ class _TourSlideView extends StatelessWidget { vertical: 10, ), decoration: material.BoxDecoration( - color: wb.accent.withValues(alpha: 0.08), + color: wb.surface, borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: wb.accent.withValues(alpha: 0.2), + color: wb.borderSubtle.withValues(alpha: 0.65), ), ), child: material.Row( children: [ - material.Icon( - material.Icons.lightbulb_outline_rounded, - size: 16, - color: wb.accent, + material.Container( + padding: const material.EdgeInsets.all(4), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.15), + shape: material.BoxShape.circle, + ), + child: material.Icon( + material.Icons.lightbulb_rounded, + size: 14, + color: wb.accent, + ), ), - const material.SizedBox(width: 8), + const material.SizedBox(width: 10), material.Expanded( child: Text( tipText, style: TextStyle( - fontSize: 12, - color: wb.onAccent, - height: 1.3, + fontSize: 12.5, + color: Theme.of(context) + .colorScheme + .foreground + .withValues(alpha: 0.9), + height: 1.35, ), ), ), diff --git a/test/features/main_screen/goldens/workspace_empty_hero.png b/test/features/main_screen/goldens/workspace_empty_hero.png index a61ba3a1962353fc972d5ac62dc8a7f6da20f78a..1d746c4a7d4e94d2a2679ec515cc5a73394282d7 100644 GIT binary patch literal 7985 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 literal 8374 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 From 0e84a33affebdf3d896e91b3b4bb2a78d13b4abc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 04:10:13 +0300 Subject: [PATCH 13/40] chore(deps): safe upgrade of direct and transitive dependencies (fixes #559) --- lib/core/motion/querya_spring_controller.dart | 2 +- pubspec.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/motion/querya_spring_controller.dart b/lib/core/motion/querya_spring_controller.dart index ec7a344f..5112d79a 100644 --- a/lib/core/motion/querya_spring_controller.dart +++ b/lib/core/motion/querya_spring_controller.dart @@ -137,7 +137,7 @@ class QueryaSpringController extends ChangeNotifier { final next = simulation.x(t); _velocity = simulation.dx(t); final settled = simulation.isDone(t) || - ((next - _target).abs() < 0.15 && _velocity.abs() < 1.0); + ((next - _target).abs() < 0.002 && _velocity.abs() < 0.02); if (settled) { _value = _target; diff --git a/pubspec.yaml b/pubspec.yaml index 72bf256c..fb5ad6d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: path_provider: ^2.1.5 sqflite: ^2.3.2 - sqflite_common_ffi: ^2.3.2 + sqflite_common_ffi: ^2.4.2 mongo_dart: ^0.10.8 redis: ^4.0.0 postgres: ^3.5.6 @@ -29,7 +29,7 @@ dependencies: flutter_secure_storage: ^10.3.1 file_selector: ^1.1.0 syntax_highlight: ^0.5.0 - archive: ^4.0.9 + archive: ^4.2.0 url_launcher: ^6.3.1 package_info_plus: ^9.0.1 flutter_svg: ^2.3.0 From 63d844bd0367b92e2ed633fa3d22b1d79dfe22e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 04:57:11 +0300 Subject: [PATCH 14/40] fix(sidebar): dynamically collapse split handle width to eliminate remaining pixel hitch --- lib/features/main_screen/main_screen.dart | 112 ++++++++++++---------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 7c15f792..53fad84c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -585,6 +585,8 @@ class _MainContentSplitState extends State<_MainContentSplit> final progress = _lastExpandedWidth > 0 ? (currentW / _lastExpandedWidth).clamp(0.0, 1.0) : 0.0; + final currentHandleW = + (progress * _resizeHandleWidth).clamp(0.0, _resizeHandleWidth); final handleOpacity = (progress * 5.0).clamp(0.0, 1.0); final contentOpacity = (progress * 2.2).clamp(0.0, 1.0); final parallaxOffset = (progress - 1.0) * 36.0; @@ -633,54 +635,68 @@ class _MainContentSplitState extends State<_MainContentSplit> ), ), if (!isFullyCollapsed) - Opacity( - opacity: handleOpacity, - child: IgnorePointer( - ignoring: currentW <= 20.0 || !_sidebarVisible, - child: QueryaSplitHandle( - key: const Key('main_content_resize_handle'), - axis: Axis.horizontal, - semanticsLabel: 'Resize connections and workspace panes', - onDragDelta: (dx) { - final ml = constraints.maxWidth - - _resizeHandleWidth - - _minWorkspaceWidth; - if (ml <= 0) return; - final nextW = _clampLeftWidth( - _widthSpring.value + dx, - constraints.maxWidth, - ); - _lastExpandedWidth = nextW; - _widthSpring.jumpTo(nextW); - }, - onDiscreteResize: () => _widthPersist - .onDiscreteResize(() => _lastExpandedWidth), - onDragEnd: (details) { - _widthPersist.cancelDiscreteTimer(); - final velocity = details.primaryVelocity ?? - details.velocity.pixelsPerSecond.dx; - final useSprings = QueryaSpring.springsEnabled(context); - _widthSpring.useSprings = useSprings; - if (_widthSpring.value < 100 && velocity < -50) { - _sidebarVisible = false; - widget.onSidebarVisibilityChanged?.call(false); - _widthSpring.animateTo(0.0, velocity: velocity); - unawaited(AppSettings.instance.setSidebarVisible(false)); - } else { - _sidebarVisible = true; - widget.onSidebarVisibilityChanged?.call(true); - _lastExpandedWidth = _clampLeftWidth( - _widthSpring.value, - constraints.maxWidth, - ); - _widthSpring.animateTo( - _lastExpandedWidth, - velocity: velocity, - ); - unawaited(AppSettings.instance.setSidebarVisible(true)); - _schedulePersistAfterSettle(); - } - }, + SizedBox( + width: currentHandleW, + child: ClipRect( + child: OverflowBox( + minWidth: _resizeHandleWidth, + maxWidth: _resizeHandleWidth, + alignment: Alignment.center, + child: Opacity( + opacity: handleOpacity, + child: IgnorePointer( + ignoring: currentW <= 20.0 || !_sidebarVisible, + child: QueryaSplitHandle( + key: const Key('main_content_resize_handle'), + axis: Axis.horizontal, + semanticsLabel: + 'Resize connections and workspace panes', + onDragDelta: (dx) { + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + final nextW = _clampLeftWidth( + _widthSpring.value + dx, + constraints.maxWidth, + ); + _lastExpandedWidth = nextW; + _widthSpring.jumpTo(nextW); + }, + onDiscreteResize: () => _widthPersist + .onDiscreteResize(() => _lastExpandedWidth), + onDragEnd: (details) { + _widthPersist.cancelDiscreteTimer(); + final velocity = details.primaryVelocity ?? + details.velocity.pixelsPerSecond.dx; + final useSprings = + QueryaSpring.springsEnabled(context); + _widthSpring.useSprings = useSprings; + if (_widthSpring.value < 100 && velocity < -50) { + _sidebarVisible = false; + widget.onSidebarVisibilityChanged?.call(false); + _widthSpring.animateTo(0.0, velocity: velocity); + unawaited( + AppSettings.instance.setSidebarVisible(false)); + } else { + _sidebarVisible = true; + widget.onSidebarVisibilityChanged?.call(true); + _lastExpandedWidth = _clampLeftWidth( + _widthSpring.value, + constraints.maxWidth, + ); + _widthSpring.animateTo( + _lastExpandedWidth, + velocity: velocity, + ); + unawaited( + AppSettings.instance.setSidebarVisible(true)); + _schedulePersistAfterSettle(); + } + }, + ), + ), + ), ), ), ), From 3a67958af440a7144ce3b12deb6332ebc6562c63 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:04:55 +0300 Subject: [PATCH 15/40] feat(shortcuts): bind Ctrl+Enter, Cmd+Enter, F5 across all SQL editors and enable global FocusScope --- .../extensions/extension_sql_workspace.dart | 40 +++- lib/features/main_screen/main_screen.dart | 181 ++++++++++++------ .../main_screen/querya_window_title_bar.dart | 18 +- lib/features/mysql/mysql_sql_workspace.dart | 40 +++- .../postgresql/postgres_sql_workspace.dart | 36 ++++ lib/features/sqlite/sqlite_sql_workspace.dart | 40 +++- 6 files changed, 281 insertions(+), 74 deletions(-) diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 2be7c719..265e59d3 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -224,9 +224,43 @@ class _ExtensionSqlWorkspaceState return material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) unawaited(_execute()); }, }, child: material.Focus( diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 53fad84c..e8b7c144 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; import 'package:querya_desktop/core/demo/demo_playground_service.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; @@ -241,64 +242,119 @@ class _MainScreenState extends State { return ValueListenableBuilder( valueListenable: _workspace, builder: (context, workspace, _) { - return material.CallbackShortcuts( - bindings: { - const material.SingleActivator( - LogicalKeyboardKey.keyN, - meta: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), - const material.SingleActivator( - LogicalKeyboardKey.keyN, - control: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), - const material.SingleActivator( - LogicalKeyboardKey.keyN, - meta: true, - shift: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), - const material.SingleActivator( - LogicalKeyboardKey.keyN, - control: true, - shift: true, - ): () => unawaited(_onNewDatabaseConnectionFromMenu()), - const material.SingleActivator( - LogicalKeyboardKey.keyB, - control: true, - ): () => _splitKey.currentState?.toggleSidebar(), - const material.SingleActivator( - LogicalKeyboardKey.keyB, - meta: true, - ): () => _splitKey.currentState?.toggleSidebar(), - const material.SingleActivator( - LogicalKeyboardKey.f1, - ): () => _onOpenWelcomeTour(), - const material.SingleActivator( - LogicalKeyboardKey.keyH, - meta: true, - shift: true, - ): () => _onOpenWelcomeTour(), - const material.SingleActivator( - LogicalKeyboardKey.keyH, - control: true, - shift: true, - ): () => _onOpenWelcomeTour(), - const material.SingleActivator( - LogicalKeyboardKey.digit0, - meta: true, - shift: true, - ): _onGoHome, - const material.SingleActivator( - LogicalKeyboardKey.digit0, - control: true, - shift: true, - ): _onGoHome, - }, - child: SqlEditorGlobalActions( - activeConnection: workspace.activeConnection, - onOpenSqlWorkspace: _openSqlWorkspaceForConnection, - child: material.Scaffold( - backgroundColor: wb.canvas, - body: WindowBorder( + return FocusScope( + autofocus: true, + child: material.CallbackShortcuts( + bindings: { + // New Database Connection: Ctrl+N / Cmd+N + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + control: true, + ): () => unawaited(_onNewDatabaseConnectionFromMenu()), + + // New Query Tab: Ctrl+Shift+N / Cmd+Shift+N + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + shift: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + control: true, + shift: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + }, + + // Open SQL File: Ctrl+O / Cmd+O + const material.SingleActivator( + LogicalKeyboardKey.keyO, + meta: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyO, + control: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + }, + + // Save SQL File: Ctrl+S / Cmd+S + const material.SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ): () { + final ctx = + FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + }, + + // Toggle Left Sidebar: Ctrl+B / Cmd+B + const material.SingleActivator( + LogicalKeyboardKey.keyB, + control: true, + ): () => _splitKey.currentState?.toggleSidebar(), + const material.SingleActivator( + LogicalKeyboardKey.keyB, + meta: true, + ): () => _splitKey.currentState?.toggleSidebar(), + + // Welcome Tour & Tutorial: F1 / Cmd+Shift+H / Ctrl+Shift+H + const material.SingleActivator( + LogicalKeyboardKey.f1, + ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ): () => _onOpenWelcomeTour(), + const material.SingleActivator( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ): () => _onOpenWelcomeTour(), + + // Return to Start / Home Screen: Cmd+Shift+0 / Ctrl+Shift+0 + const material.SingleActivator( + LogicalKeyboardKey.digit0, + meta: true, + shift: true, + ): _onGoHome, + const material.SingleActivator( + LogicalKeyboardKey.digit0, + control: true, + shift: true, + ): _onGoHome, + }, + child: SqlEditorGlobalActions( + activeConnection: workspace.activeConnection, + onOpenSqlWorkspace: _openSqlWorkspaceForConnection, + child: material.Scaffold( + backgroundColor: wb.canvas, + body: WindowBorder( color: wb.borderSubtle.withValues(alpha: 0.35), width: 1, child: Column( @@ -390,10 +446,11 @@ class _MainScreenState extends State { ), ), ), - ); - }, - ); - } + ), + ); + }, + ); +} } /// Owns splitter width and collapsible spring motion. diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index b254e834..d30252b5 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -152,6 +152,11 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( + trailing: Text( + Platform.isMacOS + ? 'Cmd+Shift+N' + : 'Ctrl+Shift+N', + ).xSmall().muted(), onPressed: (ctx) { Actions.maybeInvoke( FocusManager @@ -160,8 +165,11 @@ class QueryaWindowTitleBar extends StatelessWidget { const NewSqlIntent(), ); }, - child: const Text('New')), + child: const Text('New Query')), MenuButton( + trailing: Text( + Platform.isMacOS ? 'Cmd+O' : 'Ctrl+O', + ).xSmall().muted(), onPressed: (ctx) { Actions.maybeInvoke( FocusManager @@ -172,6 +180,9 @@ class QueryaWindowTitleBar extends StatelessWidget { }, child: const Text('Open...')), MenuButton( + trailing: Text( + Platform.isMacOS ? 'Cmd+S' : 'Ctrl+S', + ).xSmall().muted(), onPressed: (ctx) { Actions.maybeInvoke( FocusManager @@ -275,8 +286,9 @@ class QueryaWindowTitleBar extends StatelessWidget { leading: const material.Icon( material.Icons.add_link_rounded, size: 18), - trailing: - const Text('Shift+Ctrl+N').xSmall().muted(), + trailing: Text( + Platform.isMacOS ? 'Cmd+N' : 'Ctrl+N', + ).xSmall().muted(), onPressed: (_) => onNewDatabaseConnection(), child: const Text('New Database Connection'), ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 5bf34a27..694524fb 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -351,9 +351,43 @@ class _MysqlSqlWorkspaceState extends material.State { child: material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) unawaited(_execute()); }, }, child: material.Focus( diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 2efe818b..4a18d3b2 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -492,6 +492,42 @@ class _PostgresSqlWorkspaceState extends material.State { const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_running) _execute(); }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) _execute(); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) _execute(); + }, }, child: material.Focus( autofocus: true, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index f8dd7eb9..3e0a3cc0 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -299,9 +299,43 @@ class _SqliteSqlWorkspaceState extends material.State { child: material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { - if (!_running) { - unawaited(_execute()); - } + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + meta: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + control: true, + ): () { + if (!_running) unawaited(_execute()); + }, + const material.SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ): () { + if (!_running) unawaited(_execute()); }, }, child: material.Focus( From 1e3dc74d8219c1564b8f67c0beabeb0990e2e69b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:11:36 +0300 Subject: [PATCH 16/40] fix(shortcuts): add HardwareKeyboard global listener with physical key layout-independent dispatch --- .../actions/sql_editor_command_bridge.dart | 18 ++- .../extensions/extension_sql_workspace.dart | 31 ++++- lib/features/main_screen/main_screen.dart | 109 +++++++++++++++++- lib/features/mysql/mysql_sql_workspace.dart | 3 + .../postgresql/postgres_sql_workspace.dart | 3 + lib/features/sqlite/sqlite_sql_workspace.dart | 3 + 6 files changed, 159 insertions(+), 8 deletions(-) diff --git a/lib/core/actions/sql_editor_command_bridge.dart b/lib/core/actions/sql_editor_command_bridge.dart index adb9a739..2f6a91b6 100644 --- a/lib/core/actions/sql_editor_command_bridge.dart +++ b/lib/core/actions/sql_editor_command_bridge.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -enum SqlEditorPendingAction { none, newQuery, openFile, saveFile } +enum SqlEditorPendingAction { none, newQuery, openFile, saveFile, executeQuery } /// Lets the active SQL workspace handle File menu commands when menu focus is /// outside the editor subtree (e.g. title bar). @@ -13,21 +13,25 @@ class SqlEditorCommandBridge { VoidCallback? _onNew; VoidCallback? _onOpen; VoidCallback? _onSave; + VoidCallback? _onExecute; SqlEditorPendingAction pendingAction = SqlEditorPendingAction.none; bool get isActive => _onNew != null; + bool get canExecute => _onExecute != null; void register({ required int? connectionId, required VoidCallback onNew, required VoidCallback onOpen, required VoidCallback onSave, + VoidCallback? onExecute, }) { _ownerConnectionId = connectionId; _onNew = onNew; _onOpen = onOpen; _onSave = onSave; + _onExecute = onExecute; _flushPending(); } @@ -37,6 +41,7 @@ class SqlEditorCommandBridge { _onNew = null; _onOpen = null; _onSave = null; + _onExecute = null; } void queuePending(SqlEditorPendingAction action) { @@ -67,6 +72,14 @@ class SqlEditorCommandBridge { pendingAction = SqlEditorPendingAction.saveFile; } + void invokeExecute() { + if (_onExecute != null) { + _onExecute!(); + return; + } + pendingAction = SqlEditorPendingAction.executeQuery; + } + void _flushPending() { final action = pendingAction; if (action == SqlEditorPendingAction.none) return; @@ -81,6 +94,8 @@ class SqlEditorCommandBridge { _onOpen?.call(); case SqlEditorPendingAction.saveFile: _onSave?.call(); + case SqlEditorPendingAction.executeQuery: + _onExecute?.call(); } } @@ -90,6 +105,7 @@ class SqlEditorCommandBridge { _onNew = null; _onOpen = null; _onSave = null; + _onExecute = null; pendingAction = SqlEditorPendingAction.none; } } diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart index 265e59d3..ff5a9380 100644 --- a/lib/features/extensions/extension_sql_workspace.dart +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -60,9 +61,32 @@ class _ExtensionSqlWorkspaceState material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); _applySelectedObject(); + _registerSqlEditorCommands(); }); } + @override + void dispose() { + SqlEditorCommandBridge.instance + .unregister(connectionId: widget.connectionRow.id); + _sqlController.dispose(); + _topFraction.dispose(); + super.dispose(); + } + + void _registerSqlEditorCommands() { + if (!mounted) return; + SqlEditorCommandBridge.instance.register( + connectionId: widget.connectionRow.id, + onNew: () => _sqlController.clear(), + onOpen: () => unawaited(_openSqlFile()), + onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) unawaited(_execute()); + }, + ); + } + @override void didUpdateWidget(covariant ExtensionSqlWorkspace oldWidget) { super.didUpdateWidget(oldWidget); @@ -99,12 +123,7 @@ class _ExtensionSqlWorkspaceState }); } - @override - void dispose() { - _topFraction.dispose(); - _sqlController.dispose(); - super.dispose(); - } + Future _execute() async { if (_running) return; diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index e8b7c144..a86211e8 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart' as material; -import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/services.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; import 'package:querya_desktop/core/demo/demo_playground_service.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; @@ -48,6 +50,7 @@ class _MainScreenState extends State { @override void initState() { super.initState(); + HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = (details) { if (!mounted) return Future.value(false); return showUnsandboxedDriverConsentDialog(context, details); @@ -66,12 +69,116 @@ class _MainScreenState extends State { @override void dispose() { + HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = null; _workspace.dispose(); _isSidebarVisible.dispose(); super.dispose(); } + bool _handleGlobalKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return false; + + final isMac = Platform.isMacOS; + final isCmdOrCtrl = isMac + ? (HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isControlPressed) + : HardwareKeyboard.instance.isControlPressed; + final isShift = HardwareKeyboard.instance.isShiftPressed; + final isAlt = HardwareKeyboard.instance.isAltPressed; + + final physical = event.physicalKey; + final logical = event.logicalKey; + + // 1. Execute SQL: F5, or (Ctrl/Cmd + Enter), or (Ctrl/Cmd + R) + final isEnter = physical == PhysicalKeyboardKey.enter || + physical == PhysicalKeyboardKey.numpadEnter || + logical == LogicalKeyboardKey.enter || + logical == LogicalKeyboardKey.numpadEnter; + final isF5 = physical == PhysicalKeyboardKey.f5 || + logical == LogicalKeyboardKey.f5; + final isKeyR = physical == PhysicalKeyboardKey.keyR || + logical == LogicalKeyboardKey.keyR || + logical == const LogicalKeyboardKey(0x0000043a); // Russian 'ΠΊ' + + if ((isCmdOrCtrl && isEnter) || + isF5 || + (isCmdOrCtrl && !isShift && !isAlt && isKeyR)) { + if (SqlEditorCommandBridge.instance.canExecute) { + SqlEditorCommandBridge.instance.invokeExecute(); + return true; + } + } + + // 2. Toggle Left Sidebar: Ctrl+B / Cmd+B (Physical B, Logical B, Russian 'ΠΈ') + final isKeyB = physical == PhysicalKeyboardKey.keyB || + logical == LogicalKeyboardKey.keyB || + logical == const LogicalKeyboardKey(0x00000438); // Russian 'ΠΈ' + if (isCmdOrCtrl && !isShift && !isAlt && isKeyB) { + _splitKey.currentState?.toggleSidebar(); + return true; + } + + // 3. New Connection / New Query Tab: (Physical N, Logical N, Russian 'Ρ‚') + final isKeyN = physical == PhysicalKeyboardKey.keyN || + logical == LogicalKeyboardKey.keyN || + logical == const LogicalKeyboardKey(0x00000442); // Russian 'Ρ‚' + if (isCmdOrCtrl && !isAlt && isKeyN) { + if (isShift) { + // Ctrl+Shift+N: New Query + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const NewSqlIntent()); + } else { + // Ctrl+N: New Database Connection + unawaited(_onNewDatabaseConnectionFromMenu()); + } + return true; + } + + // 4. Open SQL: Ctrl+O / Cmd+O (Physical O, Logical O, Russian 'Ρ‰') + final isKeyO = physical == PhysicalKeyboardKey.keyO || + logical == LogicalKeyboardKey.keyO || + logical == const LogicalKeyboardKey(0x00000449); // Russian 'Ρ‰' + if (isCmdOrCtrl && !isShift && !isAlt && isKeyO) { + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const OpenSqlIntent()); + return true; + } + + // 5. Save SQL: Ctrl+S / Cmd+S (Physical S, Logical S, Russian 'Ρ‹') + final isKeyS = physical == PhysicalKeyboardKey.keyS || + logical == LogicalKeyboardKey.keyS || + logical == const LogicalKeyboardKey(0x0000044b); // Russian 'Ρ‹' + if (isCmdOrCtrl && !isShift && !isAlt && isKeyS) { + final ctx = FocusManager.instance.primaryFocus?.context ?? context; + Actions.maybeInvoke(ctx, const SaveSqlIntent()); + return true; + } + + // 6. Tutorial & Welcome: F1 or Ctrl+Shift+H / Cmd+Shift+H (Physical H, Logical H, Russian 'Ρ€') + final isF1 = physical == PhysicalKeyboardKey.f1 || + logical == LogicalKeyboardKey.f1; + final isKeyH = physical == PhysicalKeyboardKey.keyH || + logical == LogicalKeyboardKey.keyH || + logical == const LogicalKeyboardKey(0x00000440); // Russian 'Ρ€' + if (isF1 || (isCmdOrCtrl && isShift && isKeyH)) { + _onOpenWelcomeTour(); + return true; + } + + // 7. Home / Start Screen: Ctrl+Shift+0 / Cmd+Shift+0 + final isDigit0 = physical == PhysicalKeyboardKey.digit0 || + physical == PhysicalKeyboardKey.numpad0 || + logical == LogicalKeyboardKey.digit0 || + logical == LogicalKeyboardKey.numpad0; + if (isCmdOrCtrl && isShift && isDigit0) { + _onGoHome(); + return true; + } + + return false; + } + void _onConnectionSelected(ConnectionRow connection) { _workspace.value = _workspace.value.selectConnection(connection); final id = connection.id; diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 694524fb..ecaf78ba 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -75,6 +75,9 @@ class _MysqlSqlWorkspaceState extends material.State { onNew: () => _sqlController.clear(), onOpen: () => unawaited(_openSqlFile()), onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) unawaited(_execute()); + }, ); } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 4a18d3b2..d508ee94 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -118,6 +118,9 @@ class _PostgresSqlWorkspaceState extends material.State { onNew: () => _sqlController.clear(), onOpen: () => unawaited(_openSqlFile()), onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) _execute(); + }, ); } diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 3e0a3cc0..37ae79d8 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -72,6 +72,9 @@ class _SqliteSqlWorkspaceState extends material.State { onNew: () => _sqlController.clear(), onOpen: () => unawaited(_openSqlFile()), onSave: () => unawaited(_saveSqlFile()), + onExecute: () { + if (!_running) unawaited(_execute()); + }, ); } From e79d11f0e2888c662bc370f6e88b315112f0a704 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:16:06 +0300 Subject: [PATCH 17/40] feat(shortcuts): toggle WelcomeTourDialog on hotkey and prevent stacking modal dialogs --- lib/features/main_screen/main_screen.dart | 39 ++++++++++------ .../onboarding/welcome_tour_dialog.dart | 44 ++++++++++++++++++- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index a86211e8..d13196cb 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -46,6 +46,7 @@ class _MainScreenState extends State { final ValueNotifier _workspace = ValueNotifier(MainScreenWorkspaceState.empty); final ValueNotifier _isSidebarVisible = ValueNotifier(true); + bool _openingConnectionDialog = false; @override void initState() { @@ -296,22 +297,34 @@ class _MainScreenState extends State { } Future _onNewDatabaseConnectionFromMenu() async { - // Menu overlay context is torn down before the connection form opens. - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - final row = await promptCreateConnection(context, folderId: null); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + if (_openingConnectionDialog) return; + _openingConnectionDialog = true; + try { + // Menu overlay context is torn down before the connection form opens. + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await promptCreateConnection(context, folderId: null); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } finally { + _openingConnectionDialog = false; + } } Future _onNewDatabaseConnectionFromUrl() async { - await Future.delayed(const Duration(milliseconds: 100)); - if (!mounted) return; - final row = await showNewConnectionUrlDialog(context); - if (!mounted || row == null) return; - await LocalDb.instance.addConnection(row); - await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + if (_openingConnectionDialog) return; + _openingConnectionDialog = true; + try { + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await showNewConnectionUrlDialog(context); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } finally { + _openingConnectionDialog = false; + } } Future _openSqliteFromHero() async { diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index d44b595f..e020216a 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -8,12 +8,17 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_workbench_theme.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Shows the interactive Welcome Tour dialog. +/// Shows or toggles the interactive Welcome Tour dialog. Future showWelcomeTourDialog( material.BuildContext context, { material.VoidCallback? onLaunchDemo, material.VoidCallback? onGoHome, }) { + if (WelcomeTourDialog.isOpen) { + WelcomeTourDialog.close(); + return Future.value(); + } + return showAppDialog( context: context, builder: (ctx) => material.Dialog( @@ -37,6 +42,18 @@ class WelcomeTourDialog extends StatefulWidget { final material.VoidCallback? onLaunchDemo; final material.VoidCallback? onGoHome; + static material.BuildContext? _activeContext; + static bool get isOpen => _activeContext != null; + + static void close() { + if (_activeContext != null) { + if (material.Navigator.of(_activeContext!).canPop()) { + material.Navigator.of(_activeContext!).pop(); + } + _activeContext = null; + } + } + @override State createState() => _WelcomeTourDialogState(); } @@ -45,6 +62,20 @@ class _WelcomeTourDialogState extends State { int _currentStep = 0; static const int _totalSteps = 4; + @override + void initState() { + super.initState(); + WelcomeTourDialog._activeContext = context; + } + + @override + void dispose() { + if (WelcomeTourDialog._activeContext == context) { + WelcomeTourDialog._activeContext = null; + } + super.dispose(); + } + void _goToStep(int step) { if (step >= 0 && step < _totalSteps && step != _currentStep) { setState(() => _currentStep = step); @@ -102,6 +133,17 @@ class _WelcomeTourDialogState extends State { const material.SingleActivator(LogicalKeyboardKey.arrowLeft): _previous, const material.SingleActivator(LogicalKeyboardKey.enter): _next, const material.SingleActivator(LogicalKeyboardKey.escape): _finish, + const material.SingleActivator(LogicalKeyboardKey.f1): _finish, + const material.SingleActivator( + LogicalKeyboardKey.keyH, + control: true, + shift: true, + ): _finish, + const material.SingleActivator( + LogicalKeyboardKey.keyH, + meta: true, + shift: true, + ): _finish, }, child: QueryaDialogCard( constraints: WindowLayout.dialogConstraints( From 09f8f6fc70a6fe59a89fb3197d32e8e2fdb0c503 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:37:09 +0300 Subject: [PATCH 18/40] feat(data-grid): staging buffer engine and visual change tracking (#560) --- .../main_screen/data_grid_staging_buffer.dart | 254 +++++++++++++++++ .../data_grid_staging_toolbar.dart | 257 ++++++++++++++++++ .../main_screen/result_grid_view.dart | 186 +++++++++++-- lib/features/main_screen/results_tab.dart | 69 +++-- .../data_grid_staging_buffer_test.dart | 123 +++++++++ .../main_screen/results_tab_test.dart | 78 ++++++ 6 files changed, 922 insertions(+), 45 deletions(-) create mode 100644 lib/features/main_screen/data_grid_staging_buffer.dart create mode 100644 lib/features/main_screen/data_grid_staging_toolbar.dart create mode 100644 test/features/main_screen/data_grid_staging_buffer_test.dart diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart new file mode 100644 index 00000000..160cc45a --- /dev/null +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -0,0 +1,254 @@ +import 'package:flutter/foundation.dart'; + +/// Status of a row within the staging buffer. +enum StagedRowStatus { + unchanged, + modified, + inserted, + deleted, +} + +/// Status of an individual cell. +enum StagedCellStatus { + clean, + modified, +} + +/// In-memory staging buffer for interactive table data edits. +/// +/// Keeps original data intact and tracks staged changes (modified cells, +/// newly inserted rows, and rows marked for deletion). Notifies listeners +/// on any mutation so the UI (VirtualResultGrid, toolbar) updates reactively. +class DataGridStagingBuffer extends ChangeNotifier { + DataGridStagingBuffer({ + required List columns, + required List> rows, + }) : _originalColumns = List.unmodifiable(columns), + _originalRows = List.unmodifiable( + rows.map((r) => List.unmodifiable(r)).toList(), + ); + + final List _originalColumns; + final List> _originalRows; + + /// Map of `rowIndex -> (colIndex -> stagedValue)` for modified cells in baseline rows. + final Map> _modifiedCells = {}; + + /// Rows appended as new records. + final List> _insertedRows = []; + + /// Baseline row indices marked for deletion. + final Set _deletedRowIndices = {}; + + List get columns => _originalColumns; + List> get originalRows => _originalRows; + + /// Total number of visible rows (baseline + inserted). + int get totalRowCount => _originalRows.length + _insertedRows.length; + + /// True if there are any pending edits, insertions, or deletions. + bool get isDirty => + _modifiedCells.isNotEmpty || + _insertedRows.isNotEmpty || + _deletedRowIndices.isNotEmpty; + + /// Total count of staged modifications (modified cells + inserted rows + deleted rows). + int get changeCount { + var cellCount = 0; + for (final colMap in _modifiedCells.values) { + cellCount += colMap.length; + } + return cellCount + _insertedRows.length + _deletedRowIndices.length; + } + + /// Number of baseline rows marked for deletion. + int get deletedRowCount => _deletedRowIndices.length; + + /// Number of newly inserted rows. + int get insertedRowCount => _insertedRows.length; + + /// Number of modified cells in baseline rows. + int get modifiedCellCount { + var count = 0; + for (final colMap in _modifiedCells.values) { + count += colMap.length; + } + return count; + } + + /// Returns the current effective cell value. + String getCellValue(int row, int col) { + if (row < 0 || col < 0) return ''; + if (row < _originalRows.length) { + final staged = _modifiedCells[row]?[col]; + if (staged != null) return staged; + if (col < _originalRows[row].length) { + return _originalRows[row][col]; + } + return ''; + } + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length && col < _insertedRows[insertIdx].length) { + return _insertedRows[insertIdx][col]; + } + return ''; + } + + /// Returns original baseline cell value, or null if row is inserted. + String? getOriginalCellValue(int row, int col) { + if (row >= 0 && row < _originalRows.length && col >= 0 && col < _originalRows[row].length) { + return _originalRows[row][col]; + } + return null; + } + + /// Returns the status of the specified row. + StagedRowStatus getRowStatus(int row) { + if (row < 0) return StagedRowStatus.unchanged; + if (row >= _originalRows.length) { + return StagedRowStatus.inserted; + } + if (_deletedRowIndices.contains(row)) { + return StagedRowStatus.deleted; + } + if (_modifiedCells.containsKey(row) && _modifiedCells[row]!.isNotEmpty) { + return StagedRowStatus.modified; + } + return StagedRowStatus.unchanged; + } + + /// Returns the status of the specified cell. + StagedCellStatus getCellStatus(int row, int col) { + if (row >= 0 && row < _originalRows.length) { + if (_modifiedCells[row]?.containsKey(col) == true) { + return StagedCellStatus.modified; + } + } + return StagedCellStatus.clean; + } + + /// Stages an edit for the specified cell. + /// If the new value equals original value, clears the modified flag. + void setCell(int row, int col, String value) { + if (row < 0 || col < 0) return; + + if (row < _originalRows.length) { + final orig = col < _originalRows[row].length ? _originalRows[row][col] : ''; + if (value == orig) { + if (_modifiedCells.containsKey(row)) { + _modifiedCells[row]!.remove(col); + if (_modifiedCells[row]!.isEmpty) { + _modifiedCells.remove(row); + } + notifyListeners(); + } + } else { + final rowMap = _modifiedCells.putIfAbsent(row, () => {}); + if (rowMap[col] != value) { + rowMap[col] = value; + notifyListeners(); + } + } + } else { + final insertIdx = row - _originalRows.length; + if (insertIdx < _insertedRows.length) { + while (_insertedRows[insertIdx].length <= col) { + _insertedRows[insertIdx].add(''); + } + if (_insertedRows[insertIdx][col] != value) { + _insertedRows[insertIdx][col] = value; + notifyListeners(); + } + } + } + } + + /// Appends a new empty or pre-filled row. + int addRow([List? initialValues]) { + final row = initialValues != null + ? List.from(initialValues) + : List.filled(_originalColumns.length, ''); + _insertedRows.add(row); + notifyListeners(); + return totalRowCount - 1; + } + + /// Removes an inserted row at the given absolute row index. + void removeInsertedRow(int row) { + final insertIdx = row - _originalRows.length; + if (insertIdx >= 0 && insertIdx < _insertedRows.length) { + _insertedRows.removeAt(insertIdx); + notifyListeners(); + } + } + + /// Marks a baseline row as deleted, or removes an inserted row. + void toggleDeleteRow(int row) { + if (row < 0) return; + if (row < _originalRows.length) { + if (_deletedRowIndices.contains(row)) { + _deletedRowIndices.remove(row); + } else { + _deletedRowIndices.add(row); + } + notifyListeners(); + } else { + removeInsertedRow(row); + } + } + + /// Reverts changes for a single cell back to baseline. + void revertCell(int row, int col) { + if (row < _originalRows.length && _modifiedCells.containsKey(row)) { + if (_modifiedCells[row]!.remove(col) != null) { + if (_modifiedCells[row]!.isEmpty) { + _modifiedCells.remove(row); + } + notifyListeners(); + } + } + } + + /// Reverts all modifications or deletion for a given row. + void revertRow(int row) { + if (row < 0) return; + if (row < _originalRows.length) { + var changed = false; + if (_modifiedCells.remove(row) != null) changed = true; + if (_deletedRowIndices.remove(row)) changed = true; + if (changed) notifyListeners(); + } else { + removeInsertedRow(row); + } + } + + /// Reverts all staged changes and resets buffer to clean baseline. + void revertAll() { + if (!isDirty) return; + _modifiedCells.clear(); + _insertedRows.clear(); + _deletedRowIndices.clear(); + notifyListeners(); + } + + /// Returns the full list of effective rows (original with modifications applied + inserted rows). + List> get effectiveRows { + final result = >[]; + for (var r = 0; r < _originalRows.length; r++) { + final row = List.from(_originalRows[r]); + final mods = _modifiedCells[r]; + if (mods != null) { + for (final entry in mods.entries) { + if (entry.key < row.length) { + row[entry.key] = entry.value; + } + } + } + result.add(row); + } + for (final ins in _insertedRows) { + result.add(List.from(ins)); + } + return result; + } +} diff --git a/lib/features/main_screen/data_grid_staging_toolbar.dart b/lib/features/main_screen/data_grid_staging_toolbar.dart new file mode 100644 index 00000000..50a42cf9 --- /dev/null +++ b/lib/features/main_screen/data_grid_staging_toolbar.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Toolbar for managing staged data changes (Add, Delete, Revert, Save). +class DataGridStagingToolbar extends StatelessWidget { + const DataGridStagingToolbar({ + super.key, + required this.stagingBuffer, + this.selectedRowIndex, + this.onApplyChanges, + this.isSaving = false, + }); + + final DataGridStagingBuffer stagingBuffer; + final int? selectedRowIndex; + final VoidCallback? onApplyChanges; + final bool isSaving; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return ListenableBuilder( + listenable: stagingBuffer, + builder: (context, _) { + final isDirty = stagingBuffer.isDirty; + final changeCount = stagingBuffer.changeCount; + final selectedRow = selectedRowIndex; + final hasSelectedRow = selectedRow != null && + selectedRow >= 0 && + selectedRow < stagingBuffer.totalRowCount; + + final isSelectedDeleted = hasSelectedRow && + stagingBuffer.getRowStatus(selectedRow) == StagedRowStatus.deleted; + + return material.Container( + height: 32, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Add Row + _ToolbarButton( + label: 'Add Row', + icon: material.Icons.add_rounded, + onPressed: isSaving ? null : () => stagingBuffer.addRow(), + ), + const Gap(4), + + // Delete / Restore Row + _ToolbarButton( + label: isSelectedDeleted ? 'Restore Row' : 'Delete Row', + icon: isSelectedDeleted + ? material.Icons.restore_from_trash_rounded + : material.Icons.remove_circle_outline_rounded, + color: isSelectedDeleted + ? cs.primary + : (hasSelectedRow ? cs.destructive : null), + onPressed: isSaving || !hasSelectedRow + ? null + : () => stagingBuffer.toggleDeleteRow(selectedRow), + ), + const Gap(4), + + // Revert + if (isDirty) ...[ + _ToolbarButton( + label: 'Revert All', + icon: material.Icons.undo_rounded, + color: cs.mutedForeground, + onPressed: isSaving ? null : () => stagingBuffer.revertAll(), + ), + const Gap(6), + ], + + // Badge + if (isDirty) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Container( + width: 5, + height: 5, + decoration: material.BoxDecoration( + color: cs.primary, + shape: material.BoxShape.circle, + ), + ), + const Gap(5), + Text( + '$changeCount pending ${changeCount == 1 ? 'change' : 'changes'}', + ).xSmall().semiBold(), + ], + ), + ) + else + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.check_circle_outline_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(4), + const Text('No changes').xSmall().muted(), + ], + ), + + const Gap(12), + + // Save Changes button + material.MouseRegion( + cursor: (isDirty && !isSaving) + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: isDirty && !isSaving ? onApplyChanges : null, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: isDirty + ? cs.primary + : cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + if (isSaving) + material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primaryForeground, + ), + ) + else + material.Icon( + material.Icons.save_rounded, + size: 14, + color: isDirty + ? cs.primaryForeground + : cs.mutedForeground, + ), + const Gap(5), + material.Text( + isSaving ? 'Saving…' : 'Save Changes', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: isDirty + ? cs.primaryForeground + : cs.mutedForeground, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _ToolbarButton extends material.StatelessWidget { + const _ToolbarButton({ + required this.label, + required this.icon, + this.onPressed, + this.color, + }); + + final String label; + final material.IconData icon; + final material.VoidCallback? onPressed; + final material.Color? color; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final enabled = onPressed != null; + final fg = color ?? cs.foreground; + + return material.MouseRegion( + cursor: enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + child: material.GestureDetector( + onTap: onPressed, + behavior: material.HitTestBehavior.opaque, + child: material.Opacity( + opacity: enabled ? 1.0 : 0.4, + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(icon, size: 14, color: fg), + const material.SizedBox(width: 4), + material.Text( + label, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 698f4249..e969a359 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart' show Clipboard, ClipboardData, HardwareKeyboard, LogicalKeyboardKey; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Layout metrics for [VirtualResultGrid]. @@ -327,16 +328,20 @@ List> sortResultGridRows({ return sorted; } -/// Virtualized read-only grid for SQL query results (rows + columns). +/// Virtualized read-only or interactive grid for SQL query results (rows + columns). class VirtualResultGrid extends material.StatefulWidget { const VirtualResultGrid({ super.key, required this.columns, required this.rows, + this.stagingBuffer, + this.onRowSelected, }); final List columns; final List> rows; + final DataGridStagingBuffer? stagingBuffer; + final material.ValueChanged? onRowSelected; @override material.State createState() => _VirtualResultGridState(); @@ -364,9 +369,18 @@ class _VirtualResultGridState extends material.State { void initState() { super.initState(); _horizontalController.addListener(_onHorizontalScroll); + widget.stagingBuffer?.addListener(_onStagingBufferChanged); _updateSortedRows(); } + void _onStagingBufferChanged() { + if (!mounted) return; + setState(() { + _updateSortedRows(); + _widthsNeedUpdate = true; + }); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -376,7 +390,13 @@ class _VirtualResultGridState extends material.State { @override void didUpdateWidget(VirtualResultGrid oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.columns != widget.columns || oldWidget.rows != widget.rows) { + if (oldWidget.stagingBuffer != widget.stagingBuffer) { + oldWidget.stagingBuffer?.removeListener(_onStagingBufferChanged); + widget.stagingBuffer?.addListener(_onStagingBufferChanged); + } + if (oldWidget.columns != widget.columns || + oldWidget.rows != widget.rows || + oldWidget.stagingBuffer != widget.stagingBuffer) { _widthsNeedUpdate = true; if (oldWidget.columns != widget.columns) { _userHasResized = false; @@ -384,6 +404,7 @@ class _VirtualResultGridState extends material.State { _sortOrder = null; _selectionAnchor = null; _selection = null; + widget.onRowSelected?.call(null); } _updateSortedRows(); } @@ -391,6 +412,7 @@ class _VirtualResultGridState extends material.State { @override void dispose() { + widget.stagingBuffer?.removeListener(_onStagingBufferChanged); _horizontalController.removeListener(_onHorizontalScroll); _horizontalController.dispose(); _verticalController.dispose(); @@ -437,12 +459,16 @@ class _VirtualResultGridState extends material.State { }); } + List> get _baseRows => + widget.stagingBuffer?.effectiveRows ?? widget.rows; + void _updateSortedRows() { + final rows = _baseRows; if (_sortColumnIndex == null || _sortOrder == null) { - _sortedRows = widget.rows; + _sortedRows = rows; } else { _sortedRows = sortResultGridRows( - rows: widget.rows, + rows: rows, columnIndex: _sortColumnIndex!, order: _sortOrder!, ); @@ -451,6 +477,7 @@ class _VirtualResultGridState extends material.State { void _onCellTap(int row, int column, {bool isShift = false}) { _focusNode.requestFocus(); + widget.onRowSelected?.call(row); setState(() { final coord = ResultGridCellCoordinate(row, column); if (isShift && _selectionAnchor != null) { @@ -472,6 +499,7 @@ class _VirtualResultGridState extends material.State { void _onCellSecondaryTap(int row, int column) { _focusNode.requestFocus(); + widget.onRowSelected?.call(row); if (_selection != null && _selection!.contains(row, column)) { _copySelection(); } else { @@ -501,7 +529,7 @@ class _VirtualResultGridState extends material.State { List _computeColumnWidths() { return computeResultGridColumnWidths( columns: widget.columns, - rows: widget.rows, + rows: _baseRows, minWidth: context.scaled(ResultGridMetrics.minColumnWidth), maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), ); @@ -554,12 +582,59 @@ class _VirtualResultGridState extends material.State { LogicalKeyboardKey.keyC, control: true, ): () => _copySelection(), + const material.SingleActivator( + LogicalKeyboardKey.insert, + control: true, + ): () => widget.stagingBuffer?.addRow(), + const material.SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ): () { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.addRow(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.delete, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.backspace, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + control: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.revertRow(_selection!.startRow); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ): () { + if (widget.stagingBuffer != null && _selection != null) { + widget.stagingBuffer!.revertRow(_selection!.startRow); + } + }, const material.SingleActivator( LogicalKeyboardKey.escape, - ): () => setState(() { - _selection = null; - _selectionAnchor = null; - }), + ): () { + widget.onRowSelected?.call(null); + setState(() { + _selection = null; + _selectionAnchor = null; + }); + }, }, child: material.Focus( focusNode: _focusNode, @@ -627,6 +702,7 @@ class _VirtualResultGridState extends material.State { colorScheme: cs, striped: !isEven, selection: _selection, + stagingBuffer: widget.stagingBuffer, onCellTap: _onCellTap, onCellSecondaryTap: _onCellSecondaryTap, ); @@ -748,7 +824,7 @@ class _HeaderCell extends material.StatelessWidget { child: material.MouseRegion( cursor: onSort != null ? material.SystemMouseCursors.click - : material.MouseCursor.defer, + : material.SystemMouseCursors.basic, child: material.GestureDetector( behavior: material.HitTestBehavior.opaque, onTap: onSort, @@ -764,13 +840,13 @@ class _HeaderCell extends material.StatelessWidget { maxLines: 1, ), ), - if (isSorted) ...[ - const material.SizedBox(width: 4), + if (sortOrder != null) ...[ + const Gap(4), material.Icon( sortOrder == ResultGridSortOrder.ascending ? material.Icons.arrow_upward_rounded : material.Icons.arrow_downward_rounded, - size: 13, + size: 14, color: colorScheme.primary, ), ], @@ -813,6 +889,7 @@ class _DataRow extends material.StatelessWidget { required this.colorScheme, required this.striped, this.selection, + this.stagingBuffer, this.onCellTap, this.onCellSecondaryTap, }); @@ -825,11 +902,14 @@ class _DataRow extends material.StatelessWidget { final ColorScheme colorScheme; final bool striped; final ResultGridSelection? selection; + final DataGridStagingBuffer? stagingBuffer; final void Function(int row, int col, {bool isShift})? onCellTap; final void Function(int row, int col)? onCellSecondaryTap; @override material.Widget build(material.BuildContext context) { + final rowStatus = stagingBuffer?.getRowStatus(rowIndex) ?? StagedRowStatus.unchanged; + return material.RepaintBoundary( child: material.SizedBox( height: height, @@ -845,6 +925,8 @@ class _DataRow extends material.StatelessWidget { width: columnWidths[c], colorScheme: colorScheme, striped: striped, + rowStatus: rowStatus, + cellStatus: stagingBuffer?.getCellStatus(rowIndex, c) ?? StagedCellStatus.clean, isSelected: selection?.contains(rowIndex, c) ?? false, isSelectionTop: selection != null && selection!.contains(rowIndex, c) && @@ -878,6 +960,8 @@ class _GridCell extends material.StatelessWidget { required this.width, required this.colorScheme, required this.striped, + this.rowStatus = StagedRowStatus.unchanged, + this.cellStatus = StagedCellStatus.clean, this.isSelected = false, this.isSelectionTop = false, this.isSelectionBottom = false, @@ -893,6 +977,8 @@ class _GridCell extends material.StatelessWidget { final double width; final ColorScheme colorScheme; final bool striped; + final StagedRowStatus rowStatus; + final StagedCellStatus cellStatus; final bool isSelected; final bool isSelectionTop; final bool isSelectionBottom; @@ -904,21 +990,34 @@ class _GridCell extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { final isNull = text == 'NULL'; - final style = material.TextStyle( + final isDeleted = rowStatus == StagedRowStatus.deleted; + final isInserted = rowStatus == StagedRowStatus.inserted; + final isModified = cellStatus == StagedCellStatus.modified; + + var style = material.TextStyle( fontSize: 12, fontWeight: material.FontWeight.normal, fontFamily: 'monospace', - color: isNull - ? colorScheme.mutedForeground.withValues(alpha: 0.5) - : colorScheme.foreground, + color: isDeleted + ? colorScheme.destructive.withValues(alpha: 0.7) + : (isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : colorScheme.foreground), + decoration: isDeleted ? material.TextDecoration.lineThrough : null, fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, ); - final bg = isSelected + var bg = isSelected ? colorScheme.primary.withValues(alpha: 0.18) - : (striped - ? colorScheme.muted.withValues(alpha: 0.12) - : material.Colors.transparent); + : (isDeleted + ? colorScheme.destructive.withValues(alpha: 0.08) + : (isModified + ? colorScheme.primary.withValues(alpha: 0.14) + : (isInserted + ? colorScheme.primary.withValues(alpha: 0.08) + : (striped + ? colorScheme.muted.withValues(alpha: 0.12) + : material.Colors.transparent)))); final cell = material.Container( width: width, @@ -947,11 +1046,26 @@ class _GridCell extends material.StatelessWidget { ), ), ), - child: material.Text( - text, - style: style, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, + child: material.Stack( + clipBehavior: material.Clip.none, + alignment: material.Alignment.centerLeft, + children: [ + material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + if (isModified) + material.Positioned( + top: -8, + right: -8, + child: material.CustomPaint( + size: const material.Size(6, 6), + painter: _TriangleCornerPainter(color: colorScheme.primary), + ), + ), + ], ), ); @@ -978,3 +1092,23 @@ class _GridCell extends material.StatelessWidget { ); } } + +class _TriangleCornerPainter extends material.CustomPainter { + const _TriangleCornerPainter({required this.color}); + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + final paint = material.Paint()..color = color; + final path = material.Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width, size.height) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => + oldDelegate.color != color; +} diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 77b1c308..8bf42665 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -3,6 +3,8 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -16,7 +18,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Mode changes (idle / loading / error / status / grid) morph via /// [QueryaFadeSlide]. Keys are per **mode**, not per row β€” so grid data updates /// and scroll rebuilds do not re-trigger the transition. -class ResultsTab extends StatelessWidget { +class ResultsTab extends material.StatefulWidget { const ResultsTab({ super.key, this.columns = const [], @@ -26,6 +28,9 @@ class ResultsTab extends StatelessWidget { this.affectedRows, this.statusLine, this.showExportToolbar = true, + this.stagingBuffer, + this.onApplyChanges, + this.isSaving = false, }); final List columns; @@ -35,6 +40,16 @@ class ResultsTab extends StatelessWidget { final int? affectedRows; final String? statusLine; final bool showExportToolbar; + final DataGridStagingBuffer? stagingBuffer; + final material.VoidCallback? onApplyChanges; + final bool isSaving; + + @override + material.State createState() => _ResultsTabState(); +} + +class _ResultsTabState extends material.State { + int? _selectedRowIndex; @override Widget build(BuildContext context) { @@ -46,17 +61,17 @@ class ResultsTab extends StatelessWidget { } material.Widget _buildBody(material.BuildContext context) { - if (isLoading) { + if (widget.isLoading) { return const material.Center( key: material.ValueKey('results_mode_loading'), child: material.CircularProgressIndicator(), ); } - if (errorMessage != null && errorMessage!.isNotEmpty) { + if (widget.errorMessage != null && widget.errorMessage!.isNotEmpty) { return material.KeyedSubtree( key: const material.ValueKey('results_mode_error'), child: VirtualSelectableTextView( - text: errorMessage!, + text: widget.errorMessage!, style: material.TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -65,21 +80,21 @@ class ResultsTab extends StatelessWidget { ), ); } - if (columns.isEmpty && rows.isEmpty) { - if (statusLine != null) { + if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { + if (widget.statusLine != null) { return material.Padding( key: const material.ValueKey('results_mode_status'), padding: const material.EdgeInsets.all(16), child: Align( alignment: material.Alignment.topLeft, - child: Text(statusLine!).muted().small(), + child: Text(widget.statusLine!).muted().small(), ), ); } - if (affectedRows != null) { + if (widget.affectedRows != null) { return material.Center( key: const material.ValueKey('results_mode_affected'), - child: Text('Rows affected: $affectedRows').muted(), + child: Text('Rows affected: ${widget.affectedRows}').muted(), ); } return material.Center( @@ -88,11 +103,22 @@ class ResultsTab extends StatelessWidget { ); } + final effectiveRows = widget.stagingBuffer != null + ? widget.stagingBuffer!.effectiveRows + : widget.rows; + return material.Column( key: const material.ValueKey('results_mode_grid'), crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - if (showExportToolbar && columns.isNotEmpty) + if (widget.stagingBuffer != null) + DataGridStagingToolbar( + stagingBuffer: widget.stagingBuffer!, + selectedRowIndex: _selectedRowIndex, + onApplyChanges: widget.onApplyChanges, + isSaving: widget.isSaving, + ), + if (widget.showExportToolbar && widget.columns.isNotEmpty) material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, @@ -118,10 +144,10 @@ class ResultsTab extends StatelessWidget { ), const Gap(6), Text( - statusLine ?? - (affectedRows != null - ? 'Rows affected: $affectedRows' - : '${rows.length} rows returned'), + widget.statusLine ?? + (widget.affectedRows != null + ? 'Rows affected: ${widget.affectedRows}' + : '${effectiveRows.length} rows returned'), ).small().semiBold(), const material.Spacer(), ExportMenuButton( @@ -132,8 +158,8 @@ class ResultsTab extends StatelessWidget { unawaited(() async { await DataExportService.copyToClipboard( format, - columns: columns, - rows: rows, + columns: widget.columns, + rows: effectiveRows, ); }()); }, @@ -147,8 +173,8 @@ class ResultsTab extends StatelessWidget { unawaited(() async { final outcome = await DataExportService.saveToFile( format, - columns: columns, - rows: rows, + columns: widget.columns, + rows: effectiveRows, ); if (!context.mounted) return; if (outcome == SaveExportOutcome.error) { @@ -161,7 +187,12 @@ class ResultsTab extends StatelessWidget { ), ), material.Expanded( - child: VirtualResultGrid(columns: columns, rows: rows), + child: VirtualResultGrid( + columns: widget.columns, + rows: widget.rows, + stagingBuffer: widget.stagingBuffer, + onRowSelected: (row) => setState(() => _selectedRowIndex = row), + ), ), ], ); diff --git a/test/features/main_screen/data_grid_staging_buffer_test.dart b/test/features/main_screen/data_grid_staging_buffer_test.dart new file mode 100644 index 00000000..57483b67 --- /dev/null +++ b/test/features/main_screen/data_grid_staging_buffer_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; + +void main() { + group('DataGridStagingBuffer', () { + late DataGridStagingBuffer buffer; + + setUp(() { + buffer = DataGridStagingBuffer( + columns: ['id', 'name', 'email'], + rows: [ + ['1', 'Alice', 'alice@test.com'], + ['2', 'Bob', 'bob@test.com'], + ['3', 'Charlie', 'charlie@test.com'], + ], + ); + }); + + test('initial state is clean and non-dirty', () { + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.totalRowCount, 3); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + expect(buffer.getCellStatus(0, 1), StagedCellStatus.clean); + expect(buffer.getCellValue(0, 1), 'Alice'); + expect(buffer.getOriginalCellValue(0, 1), 'Alice'); + }); + + test('modifying cell sets dirty and updates cell value', () { + buffer.setCell(0, 1, 'Alice Cooper'); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 1); + expect(buffer.modifiedCellCount, 1); + expect(buffer.getCellValue(0, 1), 'Alice Cooper'); + expect(buffer.getOriginalCellValue(0, 1), 'Alice'); + expect(buffer.getCellStatus(0, 1), StagedCellStatus.modified); + expect(buffer.getRowStatus(0), StagedRowStatus.modified); + + // Reverting cell value to original clears dirty status + buffer.setCell(0, 1, 'Alice'); + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.getCellStatus(0, 1), StagedCellStatus.clean); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + }); + + test('adding row appends inserted row and increments row count', () { + final newIdx = buffer.addRow(['4', 'Diana', 'diana@test.com']); + + expect(newIdx, 3); + expect(buffer.totalRowCount, 4); + expect(buffer.isDirty, isTrue); + expect(buffer.insertedRowCount, 1); + expect(buffer.getRowStatus(3), StagedRowStatus.inserted); + expect(buffer.getCellValue(3, 1), 'Diana'); + expect(buffer.getOriginalCellValue(3, 1), isNull); + + // Modifying cell in inserted row + buffer.setCell(3, 1, 'Diana Prince'); + expect(buffer.getCellValue(3, 1), 'Diana Prince'); + }); + + test('toggling delete row marks row deleted or removes inserted row', () { + // Delete baseline row + buffer.toggleDeleteRow(1); + expect(buffer.isDirty, isTrue); + expect(buffer.deletedRowCount, 1); + expect(buffer.getRowStatus(1), StagedRowStatus.deleted); + + // Toggle again restores it + buffer.toggleDeleteRow(1); + expect(buffer.isDirty, isFalse); + expect(buffer.deletedRowCount, 0); + expect(buffer.getRowStatus(1), StagedRowStatus.unchanged); + + // Insert row and then delete it -> removes from inserted list + final insIdx = buffer.addRow(['4', 'New', 'new@test.com']); + expect(buffer.totalRowCount, 4); + buffer.toggleDeleteRow(insIdx); + expect(buffer.totalRowCount, 3); + expect(buffer.isDirty, isFalse); + }); + + test('revertCell, revertRow, and revertAll restore pristine state', () { + buffer.setCell(0, 1, 'Alice Modified'); + buffer.setCell(0, 2, 'modified@test.com'); + buffer.toggleDeleteRow(1); + buffer.addRow(['4', 'Extra', 'extra@test.com']); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 4); // 2 cells + 1 deleted row + 1 inserted row + + // Revert single cell + buffer.revertCell(0, 1); + expect(buffer.getCellValue(0, 1), 'Alice'); + expect(buffer.getCellValue(0, 2), 'modified@test.com'); + + // Revert whole row 0 + buffer.revertRow(0); + expect(buffer.getCellValue(0, 2), 'alice@test.com'); + expect(buffer.getRowStatus(0), StagedRowStatus.unchanged); + + // Revert all + buffer.revertAll(); + expect(buffer.isDirty, isFalse); + expect(buffer.changeCount, 0); + expect(buffer.totalRowCount, 3); + }); + + test('effectiveRows computes combined snapshot accurately', () { + buffer.setCell(0, 1, 'Alice Updated'); + buffer.addRow(['4', 'David', 'david@test.com']); + + final eff = buffer.effectiveRows; + expect(eff.length, 4); + expect(eff[0], ['1', 'Alice Updated', 'alice@test.com']); + expect(eff[1], ['2', 'Bob', 'bob@test.com']); + expect(eff[2], ['3', 'Charlie', 'charlie@test.com']); + expect(eff[3], ['4', 'David', 'david@test.com']); + }); + }); +} diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 1b62f414..1c6f4486 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart' as material; 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_scope.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; @@ -677,5 +679,81 @@ void main() { expect(find.byIcon(material.Icons.arrow_upward_rounded), findsNothing); expect(find.byIcon(material.Icons.arrow_downward_rounded), findsNothing); }); + + testWidgets('renders DataGridStagingToolbar and updates on add/delete/revert', + (tester) async { + final staging = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + ); + + var appliedChanges = 0; + + await tester.pumpWidget( + resultsShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 500, + child: ResultsTab( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + stagingBuffer: staging, + onApplyChanges: () => appliedChanges++, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify DataGridStagingToolbar is rendered + expect(find.byType(DataGridStagingToolbar), findsOneWidget); + expect(find.text('No changes'), findsOneWidget); + expect(find.text('Add Row'), findsOneWidget); + + // Tap 'Add Row' -> appends a new row + await tester.tap(find.text('Add Row')); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isTrue); + expect(staging.totalRowCount, 3); + expect(find.text('1 pending change'), findsOneWidget); + expect(find.text('Revert All'), findsOneWidget); + + // Tap 'Revert All' -> resets buffer + await tester.tap(find.text('Revert All')); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isFalse); + expect(staging.totalRowCount, 2); + expect(find.text('No changes'), findsOneWidget); + + // Select row 0 in grid + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + + // Tap 'Delete Row' in toolbar + await tester.tap(find.text('Delete Row')); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isTrue); + expect(staging.deletedRowCount, 1); + expect(find.text('Restore Row'), findsOneWidget); + + // Save button should now be enabled; tap it + await tester.ensureVisible(find.text('Save Changes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + expect(appliedChanges, 1); + }); }); } From 963f7d4209649c548e4c63d333ec74ae866df3c1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:42:50 +0300 Subject: [PATCH 19/40] feat(data-grid): type-aware inline cell editors and popover inspectors (#561) --- .../main_screen/grid_cell_editor.dart | 152 ++++++++++ .../grid_cell_popover_inspector.dart | 261 ++++++++++++++++++ .../main_screen/result_grid_view.dart | 232 +++++++++++++++- .../main_screen/grid_cell_editor_test.dart | 199 +++++++++++++ .../main_screen/results_tab_test.dart | 1 + 5 files changed, 840 insertions(+), 5 deletions(-) create mode 100644 lib/features/main_screen/grid_cell_editor.dart create mode 100644 lib/features/main_screen/grid_cell_popover_inspector.dart create mode 100644 test/features/main_screen/grid_cell_editor_test.dart diff --git a/lib/features/main_screen/grid_cell_editor.dart b/lib/features/main_screen/grid_cell_editor.dart new file mode 100644 index 00000000..24bbdb85 --- /dev/null +++ b/lib/features/main_screen/grid_cell_editor.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Active inline editor widget for a data grid cell. +class GridCellEditor extends material.StatefulWidget { + const GridCellEditor({ + super.key, + required this.initialValue, + required this.width, + required this.height, + required this.onCommit, + required this.onCancel, + this.onOpenInspector, + }); + + final String initialValue; + final double width; + final double height; + final void Function( + String value, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + }) onCommit; + final material.VoidCallback onCancel; + final material.VoidCallback? onOpenInspector; + + @override + material.State createState() => _GridCellEditorState(); +} + +class _GridCellEditorState extends material.State { + late final material.TextEditingController _controller; + final _focusNode = material.FocusNode(); + + @override + void initState() { + super.initState(); + final isNull = widget.initialValue == 'NULL'; + _controller = material.TextEditingController( + text: isNull ? '' : widget.initialValue, + ); + _controller.selection = material.TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _handleKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return; + + final isShift = HardwareKeyboard.instance.isShiftPressed; + final isAlt = HardwareKeyboard.instance.isAltPressed; + + if (event.logicalKey == LogicalKeyboardKey.keyN && isAlt) { + widget.onCommit('NULL'); + return; + } + + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter) { + widget.onCommit(_controller.text, moveNextRow: true); + return; + } + + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (isShift) { + widget.onCommit(_controller.text, movePrevCol: true); + } else { + widget.onCommit(_controller.text, moveNextCol: true); + } + return; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel(); + return; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Container( + width: widget.width, + height: widget.height, + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border.all( + color: cs.primary, + width: 1.5, + ), + ), + padding: const material.EdgeInsets.symmetric(horizontal: 6), + alignment: material.Alignment.centerLeft, + child: material.Row( + children: [ + material.Expanded( + child: material.KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + autofocus: true, + child: material.TextField( + controller: _controller, + autofocus: true, + maxLines: 1, + style: const material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, + ), + onSubmitted: (value) { + widget.onCommit(value, moveNextRow: true); + }, + ), + ), + ), + if (widget.onOpenInspector != null) + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () { + widget.onOpenInspector!(); + }, + child: material.Padding( + padding: const material.EdgeInsets.only(left: 4), + child: material.Icon( + material.Icons.open_in_full_rounded, + size: 13, + color: cs.mutedForeground, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/main_screen/grid_cell_popover_inspector.dart b/lib/features/main_screen/grid_cell_popover_inspector.dart new file mode 100644 index 00000000..8f0d7448 --- /dev/null +++ b/lib/features/main_screen/grid_cell_popover_inspector.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Opens a rich modal inspector for viewing and editing large text or JSON values. +Future showGridCellInspectorDialog({ + required material.BuildContext context, + required String columnName, + required String initialValue, + int? rowIndex, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _GridCellInspectorDialog( + columnName: columnName, + initialValue: initialValue, + rowIndex: rowIndex, + ), + ); +} + +class _GridCellInspectorDialog extends material.StatefulWidget { + const _GridCellInspectorDialog({ + required this.columnName, + required this.initialValue, + this.rowIndex, + }); + + final String columnName; + final String initialValue; + final int? rowIndex; + + @override + material.State<_GridCellInspectorDialog> createState() => + _GridCellInspectorDialogState(); +} + +class _GridCellInspectorDialogState + extends material.State<_GridCellInspectorDialog> { + late final material.TextEditingController _controller; + bool _isNull = false; + + @override + void initState() { + super.initState(); + _isNull = widget.initialValue == 'NULL'; + _controller = material.TextEditingController( + text: _isNull ? '' : widget.initialValue, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _formatJson() { + try { + final parsed = jsonDecode(_controller.text); + final pretty = const JsonEncoder.withIndent(' ').convert(parsed); + setState(() { + _isNull = false; + _controller.text = pretty; + }); + } catch (_) { + // Not valid JSON, keep as is + } + } + + void _minifyJson() { + try { + final parsed = jsonDecode(_controller.text); + final compact = jsonEncode(parsed); + setState(() { + _isNull = false; + _controller.text = compact; + }); + } catch (_) { + // Not valid JSON, keep as is + } + } + + void _setNull() { + setState(() { + _isNull = true; + _controller.clear(); + }); + } + + bool _isJson() { + final text = _controller.text.trim(); + if ((text.startsWith('{') && text.endsWith('}')) || + (text.startsWith('[') && text.endsWith(']'))) { + try { + jsonDecode(text); + return true; + } catch (_) { + return false; + } + } + return false; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final rowLabel = + widget.rowIndex != null ? ' (Row ${widget.rowIndex! + 1})' : ''; + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), + ), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 500, + maxWidth: 720, + minHeight: 380, + maxHeight: 560, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 18, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: Text( + 'Edit ${widget.columnName}$rowLabel', + ).semiBold(), + ), + if (_isJson()) ...[ + GhostButton( + density: ButtonDensity.compact, + onPressed: _formatJson, + leading: const material.Icon( + material.Icons.format_align_left_rounded, + size: 14, + ), + child: const Text('Format JSON'), + ), + const Gap(6), + GhostButton( + density: ButtonDensity.compact, + onPressed: _minifyJson, + leading: const material.Icon( + material.Icons.compress_rounded, + size: 14, + ), + child: const Text('Minify'), + ), + const Gap(6), + ], + GhostButton( + density: ButtonDensity.compact, + onPressed: _isNull ? null : _setNull, + child: const Text('Set NULL'), + ), + ], + ), + const Gap(12), + + // Editor Body + material.Expanded( + child: material.Container( + decoration: material.BoxDecoration( + color: cs.background, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _isNull + ? cs.primary.withValues(alpha: 0.5) + : cs.border, + width: 1, + ), + ), + child: _isNull + ? material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Value is NULL').muted().semiBold(), + const Gap(8), + GhostButton( + density: ButtonDensity.compact, + onPressed: () => setState(() => _isNull = false), + child: const Text('Enter text value'), + ), + ], + ), + ) + : material.TextField( + controller: _controller, + maxLines: null, + expands: true, + autofocus: true, + style: const material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'Enter cell value…', + ), + ), + ), + ), + const Gap(12), + + // Footer + material.Row( + children: [ + GhostButton( + density: ButtonDensity.compact, + onPressed: () { + Clipboard.setData( + ClipboardData(text: _isNull ? 'NULL' : _controller.text), + ); + }, + leading: const material.Icon( + material.Icons.copy_rounded, + size: 14, + ), + child: const Text('Copy'), + ), + const material.Spacer(), + OutlineButton( + density: ButtonDensity.compact, + onPressed: () => material.Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + density: ButtonDensity.compact, + onPressed: () { + final result = _isNull ? 'NULL' : _controller.text; + material.Navigator.of(context).pop(result); + }, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index e969a359..10b90f3d 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -4,6 +4,8 @@ import 'package:flutter/services.dart' import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/ui/querya_tooltip.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_editor.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_popover_inspector.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Layout metrics for [VirtualResultGrid]. @@ -364,6 +366,7 @@ class _VirtualResultGridState extends material.State { ResultGridCellCoordinate? _selectionAnchor; ResultGridSelection? _selection; + ResultGridCellCoordinate? _editingCell; @override void initState() { @@ -404,6 +407,7 @@ class _VirtualResultGridState extends material.State { _sortOrder = null; _selectionAnchor = null; _selection = null; + _editingCell = null; widget.onRowSelected?.call(null); } _updateSortedRows(); @@ -420,6 +424,112 @@ class _VirtualResultGridState extends material.State { super.dispose(); } + void _startEditing(int row, int column) { + if (widget.stagingBuffer == null) return; + if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + setState(() { + _editingCell = ResultGridCellCoordinate(row, column); + _selectionAnchor = _editingCell; + _selection = ResultGridSelection( + startRow: row, + startColumn: column, + endRow: row, + endColumn: column, + ); + }); + } + + void _commitEdit( + int row, + int column, + String value, { + bool moveNextCol = false, + bool movePrevCol = false, + bool moveNextRow = false, + }) { + if (widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(row, column, value); + } + setState(() { + if (moveNextCol) { + if (column + 1 < widget.columns.length) { + _editingCell = ResultGridCellCoordinate(row, column + 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column + 1, + endRow: row, + endColumn: column + 1, + ); + } else if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, 0); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: 0, + endRow: row + 1, + endColumn: 0, + ); + } else { + _editingCell = null; + } + } else if (movePrevCol) { + if (column > 0) { + _editingCell = ResultGridCellCoordinate(row, column - 1); + _selection = ResultGridSelection( + startRow: row, + startColumn: column - 1, + endRow: row, + endColumn: column - 1, + ); + } else if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, widget.columns.length - 1); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: widget.columns.length - 1, + endRow: row - 1, + endColumn: widget.columns.length - 1, + ); + } else { + _editingCell = null; + } + } else if (moveNextRow) { + if (row + 1 < _sortedRows.length) { + _editingCell = ResultGridCellCoordinate(row + 1, column); + _selection = ResultGridSelection( + startRow: row + 1, + startColumn: column, + endRow: row + 1, + endColumn: column, + ); + } else { + _editingCell = null; + } + } else { + _editingCell = null; + } + }); + } + + void _cancelEdit() { + setState(() { + _editingCell = null; + }); + } + + Future _openInspector(int row, int column) async { + if (row < 0 || row >= _sortedRows.length || column < 0 || column >= widget.columns.length) return; + final colName = widget.columns[column]; + final currentVal = column < _sortedRows[row].length ? _sortedRows[row][column] : ''; + final result = await showGridCellInspectorDialog( + context: context, + columnName: colName, + initialValue: currentVal, + rowIndex: row, + ); + if (result != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell(row, column, result); + } + } + void _onHorizontalScroll() { if (!_horizontalController.hasClients) return; final offset = _horizontalController.offset; @@ -629,11 +739,55 @@ class _VirtualResultGridState extends material.State { const material.SingleActivator( LogicalKeyboardKey.escape, ): () { - widget.onRowSelected?.call(null); - setState(() { - _selection = null; - _selectionAnchor = null; - }); + if (_editingCell != null) { + _cancelEdit(); + } else { + widget.onRowSelected?.call(null); + setState(() { + _selection = null; + _selectionAnchor = null; + }); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.f2, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.enter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.numpadEnter, + ): () { + if (_selection != null && _editingCell == null) { + _startEditing(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.space, + ): () { + if (_selection != null && _editingCell == null) { + _openInspector(_selection!.startRow, _selection!.startColumn); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyN, + alt: true, + ): () { + if (_selection != null && widget.stagingBuffer != null) { + widget.stagingBuffer!.setCell( + _selection!.startRow, + _selection!.startColumn, + 'NULL', + ); + } }, }, child: material.Focus( @@ -703,8 +857,13 @@ class _VirtualResultGridState extends material.State { striped: !isEven, selection: _selection, stagingBuffer: widget.stagingBuffer, + editingCell: _editingCell, onCellTap: _onCellTap, + onCellDoubleTap: _startEditing, onCellSecondaryTap: _onCellSecondaryTap, + onCommitEdit: _commitEdit, + onCancelEdit: _cancelEdit, + onOpenInspector: _openInspector, ); }, ), @@ -890,8 +1049,13 @@ class _DataRow extends material.StatelessWidget { required this.striped, this.selection, this.stagingBuffer, + this.editingCell, this.onCellTap, + this.onCellDoubleTap, this.onCellSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, }); final int rowIndex; @@ -903,8 +1067,20 @@ class _DataRow extends material.StatelessWidget { final bool striped; final ResultGridSelection? selection; final DataGridStagingBuffer? stagingBuffer; + final ResultGridCellCoordinate? editingCell; final void Function(int row, int col, {bool isShift})? onCellTap; + final void Function(int row, int col)? onCellDoubleTap; final void Function(int row, int col)? onCellSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; @override material.Widget build(material.BuildContext context) { @@ -928,6 +1104,7 @@ class _DataRow extends material.StatelessWidget { rowStatus: rowStatus, cellStatus: stagingBuffer?.getCellStatus(rowIndex, c) ?? StagedCellStatus.clean, isSelected: selection?.contains(rowIndex, c) ?? false, + isEditing: editingCell?.row == rowIndex && editingCell?.column == c, isSelectionTop: selection != null && selection!.contains(rowIndex, c) && rowIndex == selection!.startRow, @@ -941,7 +1118,11 @@ class _DataRow extends material.StatelessWidget { selection!.contains(rowIndex, c) && c == selection!.endColumn, onTap: onCellTap, + onDoubleTap: onCellDoubleTap, onSecondaryTap: onCellSecondaryTap, + onCommitEdit: onCommitEdit, + onCancelEdit: onCancelEdit, + onOpenInspector: onOpenInspector, ), if (window.trailingWidth > 0) material.SizedBox(width: window.trailingWidth), @@ -963,12 +1144,17 @@ class _GridCell extends material.StatelessWidget { this.rowStatus = StagedRowStatus.unchanged, this.cellStatus = StagedCellStatus.clean, this.isSelected = false, + this.isEditing = false, this.isSelectionTop = false, this.isSelectionBottom = false, this.isSelectionLeft = false, this.isSelectionRight = false, this.onTap, + this.onDoubleTap, this.onSecondaryTap, + this.onCommitEdit, + this.onCancelEdit, + this.onOpenInspector, }); final int row; @@ -980,15 +1166,47 @@ class _GridCell extends material.StatelessWidget { final StagedRowStatus rowStatus; final StagedCellStatus cellStatus; final bool isSelected; + final bool isEditing; final bool isSelectionTop; final bool isSelectionBottom; final bool isSelectionLeft; final bool isSelectionRight; final void Function(int row, int col, {bool isShift})? onTap; + final void Function(int row, int col)? onDoubleTap; final void Function(int row, int col)? onSecondaryTap; + final void Function( + int row, + int col, + String val, { + bool moveNextCol, + bool movePrevCol, + bool moveNextRow, + })? onCommitEdit; + final material.VoidCallback? onCancelEdit; + final void Function(int row, int col)? onOpenInspector; @override material.Widget build(material.BuildContext context) { + if (isEditing) { + return GridCellEditor( + initialValue: text, + width: width, + height: double.infinity, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) { + onCommitEdit?.call( + row, + column, + val, + moveNextCol: moveNextCol, + movePrevCol: movePrevCol, + moveNextRow: moveNextRow, + ); + }, + onCancel: () => onCancelEdit?.call(), + onOpenInspector: () => onOpenInspector?.call(row, column), + ); + } + final isNull = text == 'NULL'; final isDeleted = rowStatus == StagedRowStatus.deleted; final isInserted = rowStatus == StagedRowStatus.inserted; @@ -1075,6 +1293,9 @@ class _GridCell extends material.StatelessWidget { final isShift = HardwareKeyboard.instance.isShiftPressed; onTap?.call(row, column, isShift: isShift); }, + onDoubleTap: () { + onDoubleTap?.call(row, column); + }, onSecondaryTap: () { onSecondaryTap?.call(row, column); }, @@ -1112,3 +1333,4 @@ class _TriangleCornerPainter extends material.CustomPainter { bool shouldRepaint(covariant _TriangleCornerPainter oldDelegate) => oldDelegate.color != color; } + diff --git a/test/features/main_screen/grid_cell_editor_test.dart b/test/features/main_screen/grid_cell_editor_test.dart new file mode 100644 index 00000000..ad8a1a8c --- /dev/null +++ b/test/features/main_screen/grid_cell_editor_test.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_editor.dart'; +import 'package:querya_desktop/features/main_screen/grid_cell_popover_inspector.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('GridCellEditor', () { + testWidgets('renders with initial value and commits text on Enter', (tester) async { + String? committed; + bool movedRow = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: GridCellEditor( + initialValue: 'Original', + width: 200, + height: 36, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) { + committed = val; + movedRow = moveNextRow; + }, + onCancel: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Original'), findsOneWidget); + + // Enter new text and submit + await tester.enterText(find.byType(material.TextField), 'Modified'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(committed, 'Modified'); + expect(movedRow, isTrue); + }); + + testWidgets('triggers onCancel when Escape key is pressed', (tester) async { + bool cancelled = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: GridCellEditor( + initialValue: 'Original', + width: 200, + height: 36, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) {}, + onCancel: () => cancelled = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(cancelled, isTrue); + }); + }); + + group('GridCellPopoverInspector', () { + testWidgets('allows editing and formatting JSON in dialog', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'metadata', + initialValue: '{"name":"querya","active":true}', + rowIndex: 0, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Edit metadata (Row 1)'), findsOneWidget); + expect(find.text('Format JSON'), findsOneWidget); + + // Format JSON + await tester.tap(find.text('Format JSON')); + await tester.pumpAndSettle(); + + // Apply changes + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.contains('\n'), isTrue); + expect(result!.contains('"name": "querya"'), isTrue); + }); + + testWidgets('allows setting value to NULL in dialog', (tester) async { + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'age', + initialValue: '25', + rowIndex: 1, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Set NULL')); + await tester.pumpAndSettle(); + + expect(find.text('Value is NULL'), findsOneWidget); + + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(result, 'NULL'); + }); + }); + + group('VirtualResultGrid Inline Editing Integration', () { + testWidgets('double click on cell activates editor and commits staged edit', (tester) async { + final staging = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 800, + height: 400, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: const [ + ['1', 'Alice'], + ['2', 'Bob'], + ], + stagingBuffer: staging, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isFalse); + + // Double-click on 'Alice' + await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + + // TextField should now be present for editing + expect(find.byType(material.TextField), findsOneWidget); + + // Type 'Alice Cooper' and press Done/Enter + await tester.enterText(find.byType(material.TextField), 'Alice Cooper'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(staging.isDirty, isTrue); + expect(staging.getCellValue(0, 1), 'Alice Cooper'); + }); + }); +} diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 1c6f4486..e25eeeb6 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -737,6 +737,7 @@ void main() { // Select row 0 in grid await tester.tap(find.text('Alice')); + await tester.pump(const Duration(milliseconds: 350)); await tester.pumpAndSettle(); // Tap 'Delete Row' in toolbar From 6fe808504f2903411081dc73f0f41349f30d4a21 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:43:26 +0300 Subject: [PATCH 20/40] fix(deps): downgrade sqflite_common_ffi constraint to ^2.4.0+3 for Dart 3.11 SDK compatibility --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index fb5ad6d1..4be70cdc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: path_provider: ^2.1.5 sqflite: ^2.3.2 - sqflite_common_ffi: ^2.4.2 + sqflite_common_ffi: ^2.4.0+3 mongo_dart: ^0.10.8 redis: ^4.0.0 postgres: ^3.5.6 From d34ee6972e746cc7af148b9deba83467c76d282d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 24 Aug 2026 05:46:09 +0300 Subject: [PATCH 21/40] feat(database): primary key detection and atomic DML mutation engine for Postgres, MySQL, SQLite (#562) --- lib/core/database/mysql_connection.dart | 71 +++++ lib/core/database/postgres_connection.dart | 74 +++++ lib/core/database/sqlite_connection.dart | 47 +++ lib/core/database/table_mutation_engine.dart | 270 ++++++++++++++++++ lib/core/database/table_schema_meta.dart | 80 ++++++ .../database/table_mutation_engine_test.dart | 201 +++++++++++++ 6 files changed, 743 insertions(+) create mode 100644 lib/core/database/table_mutation_engine.dart create mode 100644 lib/core/database/table_schema_meta.dart create mode 100644 test/core/database/table_mutation_engine_test.dart diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 6cf9f705..0eb63f63 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; @@ -349,6 +350,76 @@ class MysqlConnection { return rs.rows.map((r) => r.colAt(0)!).toList(); } + /// Retrieves schema metadata and primary keys for [table] in [database]. + Future getTableSchema({ + required String database, + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final colsRs = await execute( + 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT ' + 'FROM information_schema.COLUMNS ' + 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table ' + 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, + ); + + final pkRs = await execute( + 'SELECT COLUMN_NAME ' + 'FROM information_schema.KEY_COLUMN_USAGE ' + "WHERE CONSTRAINT_NAME = 'PRIMARY' AND TABLE_SCHEMA = :database AND TABLE_NAME = :table " + 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, + ); + + final primaryKeys = pkRs.rows.map((r) => r.colAt(0)!).toList(); + final columns = []; + + for (final r in colsRs.rows) { + final name = r.colByName('COLUMN_NAME') ?? ''; + final dataType = r.colByName('DATA_TYPE') ?? ''; + final isNullable = (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; + final isPk = primaryKeys.contains(name); + final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; + final dflt = r.colByName('COLUMN_DEFAULT'); + + columns.add( + TableColumnMeta( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPk, + primaryKeyPosition: pkPos, + defaultValue: dflt, + ), + ); + } + + return TableSchemaMeta( + tableName: table, + schema: database, + columns: columns, + primaryKeys: primaryKeys, + ); + } + + /// Returns primary key column names for [table] in [database]. + Future> getPrimaryKeys({ + required String database, + required String table, + }) async { + final schema = await getTableSchema(database: database, table: table); + return schema.primaryKeys; + } + /// Lists base tables in [schema] (MySQL: database name). Future> listTables({required String schema}) async { if (!isConnected || _conn == null) { diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index c6929e16..bd807171 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -9,6 +9,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:postgres/src/connection_string.dart' show parseConnectionString; import 'postgres_metadata.dart'; +import 'table_schema_meta.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or /// `database=` query param). Used when switching DB while keeping URI auth/SSL. @@ -329,6 +330,79 @@ class PostgresConnection { return result.map((row) => row[0] as String).toList(); } + /// Retrieves schema metadata and primary keys for [table] in [schema]. + Future getTableSchema({ + String schema = 'public', + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final colsRs = await _conn!.execute( + Sql.named( + 'SELECT column_name, data_type, is_nullable, column_default ' + 'FROM information_schema.columns ' + 'WHERE table_schema = @schema AND table_name = @table ' + 'ORDER BY ordinal_position', + ), + parameters: {'schema': schema, 'table': table}, + ); + + final pkRs = await _conn!.execute( + Sql.named( + 'SELECT kcu.column_name ' + 'FROM information_schema.table_constraints tc ' + 'JOIN information_schema.key_column_usage kcu ' + ' ON tc.constraint_name = kcu.constraint_name ' + ' AND tc.table_schema = kcu.table_schema ' + "WHERE tc.constraint_type = 'PRIMARY KEY' " + ' AND tc.table_schema = @schema ' + ' AND tc.table_name = @table ' + 'ORDER BY kcu.ordinal_position', + ), + parameters: {'schema': schema, 'table': table}, + ); + + final primaryKeys = pkRs.map((r) => r[0] as String).toList(); + final columns = []; + + for (final r in colsRs) { + final name = r[0] as String? ?? ''; + final dataType = r[1] as String? ?? ''; + final isNullable = (r[2] as String? ?? 'YES').toUpperCase() == 'YES'; + final isPk = primaryKeys.contains(name); + final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; + final dflt = r[3]?.toString(); + + columns.add( + TableColumnMeta( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPk, + primaryKeyPosition: pkPos, + defaultValue: dflt, + ), + ); + } + + return TableSchemaMeta( + tableName: table, + schema: schema, + columns: columns, + primaryKeys: primaryKeys, + ); + } + + /// Returns primary key column names for [table] in [schema]. + Future> getPrimaryKeys({ + String schema = 'public', + required String table, + }) async { + final s = await getTableSchema(schema: schema, table: table); + return s.primaryKeys; + } + Future> listViews({String schema = 'public'}) async { if (!isConnected || _conn == null) { throw StateError('Not connected to PostgreSQL'); diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index d1e9cbae..c699fa2b 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; /// SQLite database connection using sqflite_common_ffi. @@ -176,6 +177,52 @@ class SqliteConnection { return rows.map((r) => r['name'] as String).toList(); } + /// Retrieves schema metadata and primary keys for [table]. + Future getTableSchema({required String table}) async { + final rows = await execute('PRAGMA table_info(${quoteIdentifier(table)})'); + final columns = []; + final pkList = >[]; + + for (final r in rows) { + final name = r['name'] as String? ?? ''; + final type = r['type'] as String? ?? ''; + final notNull = (r['notnull'] as int? ?? 0) == 1; + final pk = r['pk'] as int? ?? 0; + final dflt = r['dflt_value']?.toString(); + + final isPk = pk > 0; + if (isPk) { + pkList.add({'name': name, 'pk': pk}); + } + + columns.add( + TableColumnMeta( + name: name, + dataType: type, + isNullable: !notNull, + isPrimaryKey: isPk, + primaryKeyPosition: isPk ? pk : null, + defaultValue: dflt, + ), + ); + } + + pkList.sort((a, b) => (a['pk'] as int).compareTo(b['pk'] as int)); + final primaryKeys = pkList.map((e) => e['name'] as String).toList(); + + return TableSchemaMeta( + tableName: table, + columns: columns, + primaryKeys: primaryKeys, + ); + } + + /// Returns primary key column names for [table]. + Future> getPrimaryKeys({required String table}) async { + final schema = await getTableSchema(table: table); + return schema.primaryKeys; + } + /// Returns the DDL (`sql`) of a table or view from sqlite_master. Future getObjectDdl(String objectName) async { final rows = await execute( diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart new file mode 100644 index 00000000..b5eecbe3 --- /dev/null +++ b/lib/core/database/table_mutation_engine.dart @@ -0,0 +1,270 @@ +/// Supported SQL dialects for DML mutation generation. +enum SqlDialect { + postgres, + mysql, + sqlite, +} + +/// Type of DML mutation. +enum MutationType { + insert, + update, + delete, +} + +/// A single generated DML statement. +class TableMutationStatement { + const TableMutationStatement({ + required this.type, + required this.sql, + required this.description, + }); + + final MutationType type; + final String sql; + final String description; +} + +/// A complete mutation plan containing atomic statements wrapped in a transaction. +class TableMutationPlan { + const TableMutationPlan({ + required this.dialect, + required this.tableName, + this.schema, + required this.statements, + }); + + final SqlDialect dialect; + final String tableName; + final String? schema; + final List statements; + + bool get isEmpty => statements.isEmpty; + int get statementCount => statements.length; + + /// Returns the complete atomic transaction script. + String toTransactionSql() { + if (isEmpty) return ''; + + final buffer = StringBuffer(); + switch (dialect) { + case SqlDialect.postgres: + buffer.writeln('BEGIN;'); + case SqlDialect.mysql: + buffer.writeln('START TRANSACTION;'); + case SqlDialect.sqlite: + buffer.writeln('BEGIN TRANSACTION;'); + } + + for (final stmt in statements) { + buffer.writeln('${stmt.sql};'); + } + + buffer.writeln('COMMIT;'); + return buffer.toString(); + } +} + +/// Atomic DML mutation generator for PostgreSQL, MySQL, and SQLite. +abstract final class TableMutationEngine { + /// Quotes an SQL identifier according to the target [dialect]. + static String quoteIdentifier(String identifier, SqlDialect dialect) { + switch (dialect) { + case SqlDialect.mysql: + return '`${identifier.replaceAll('`', '``')}`'; + case SqlDialect.postgres: + case SqlDialect.sqlite: + return '"${identifier.replaceAll('"', '""')}"'; + } + } + + /// Quotes a qualified table name (e.g. `"public"."users"`). + static String quoteQualifiedTable( + String table, { + String? schema, + required SqlDialect dialect, + }) { + final quotedTable = quoteIdentifier(table, dialect); + if (schema != null && schema.isNotEmpty && dialect != SqlDialect.sqlite) { + final quotedSchema = quoteIdentifier(schema, dialect); + return '$quotedSchema.$quotedTable'; + } + return quotedTable; + } + + /// Formats a cell string value safely as an SQL literal or `NULL`. + static String formatLiteral(String value, SqlDialect dialect) { + if (value == 'NULL' || value == 'null') { + return 'NULL'; + } + + // Number literals (integer or floating point) + if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(value)) { + return value; + } + + // Boolean literals + if (value.toLowerCase() == 'true') { + return dialect == SqlDialect.sqlite ? '1' : 'TRUE'; + } + if (value.toLowerCase() == 'false') { + return dialect == SqlDialect.sqlite ? '0' : 'FALSE'; + } + + // String literal with single quote escape + final escaped = value.replaceAll("'", "''"); + return "'$escaped'"; + } + + /// Generates a [TableMutationPlan] from in-memory staged changes. + static TableMutationPlan generatePlan({ + required SqlDialect dialect, + required String tableName, + String? schema, + required List columns, + required List primaryKeys, + required List> originalRows, + required Map> modifiedCells, + required List> insertedRows, + required Set deletedRowIndices, + }) { + final statements = []; + final tableRef = quoteQualifiedTable( + tableName, + schema: schema, + dialect: dialect, + ); + + // 1. Generate UPDATE statements for modified cells + for (final entry in modifiedCells.entries) { + final rowIndex = entry.key; + final rowModifications = entry.value; + + // Skip if this original row was deleted + if (deletedRowIndices.contains(rowIndex)) continue; + if (rowIndex >= originalRows.length) continue; + + final origRow = originalRows[rowIndex]; + final setClauses = []; + + for (final mod in rowModifications.entries) { + final colIndex = mod.key; + final stagedVal = mod.value; + if (colIndex < columns.length) { + final colName = quoteIdentifier(columns[colIndex], dialect); + final literal = formatLiteral(stagedVal, dialect); + setClauses.add('$colName = $literal'); + } + } + + if (setClauses.isNotEmpty) { + final whereClause = _buildWhereClause( + columns: columns, + primaryKeys: primaryKeys, + row: origRow, + dialect: dialect, + ); + + final sql = 'UPDATE $tableRef SET ${setClauses.join(', ')} WHERE $whereClause'; + statements.add( + TableMutationStatement( + type: MutationType.update, + sql: sql, + description: 'Update row ${rowIndex + 1}', + ), + ); + } + } + + // 2. Generate INSERT statements for inserted rows + for (var i = 0; i < insertedRows.length; i++) { + final row = insertedRows[i]; + final colNames = []; + final values = []; + + for (var c = 0; c < columns.length; c++) { + final colName = quoteIdentifier(columns[c], dialect); + final cellVal = c < row.length ? row[c] : 'NULL'; + colNames.add(colName); + values.add(formatLiteral(cellVal, dialect)); + } + + final sql = 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; + statements.add( + TableMutationStatement( + type: MutationType.insert, + sql: sql, + description: 'Insert new row ${i + 1}', + ), + ); + } + + // 3. Generate DELETE statements for deleted rows + for (final rowIndex in deletedRowIndices) { + if (rowIndex < originalRows.length) { + final origRow = originalRows[rowIndex]; + final whereClause = _buildWhereClause( + columns: columns, + primaryKeys: primaryKeys, + row: origRow, + dialect: dialect, + ); + + final sql = 'DELETE FROM $tableRef WHERE $whereClause'; + statements.add( + TableMutationStatement( + type: MutationType.delete, + sql: sql, + description: 'Delete row ${rowIndex + 1}', + ), + ); + } + } + + return TableMutationPlan( + dialect: dialect, + tableName: tableName, + schema: schema, + statements: statements, + ); + } + + static String _buildWhereClause({ + required List columns, + required List primaryKeys, + required List row, + required SqlDialect dialect, + }) { + final clauses = []; + + if (primaryKeys.isNotEmpty) { + for (final pk in primaryKeys) { + final colIdx = columns.indexOf(pk); + if (colIdx != -1 && colIdx < row.length) { + final colName = quoteIdentifier(pk, dialect); + final val = row[colIdx]; + if (val == 'NULL' || val == 'null') { + clauses.add('$colName IS NULL'); + } else { + clauses.add('$colName = ${formatLiteral(val, dialect)}'); + } + } + } + } + + // Fallback: if no primary keys found or matched, match all columns + if (clauses.isEmpty) { + for (var c = 0; c < columns.length; c++) { + final colName = quoteIdentifier(columns[c], dialect); + final val = c < row.length ? row[c] : 'NULL'; + if (val == 'NULL' || val == 'null') { + clauses.add('$colName IS NULL'); + } else { + clauses.add('$colName = ${formatLiteral(val, dialect)}'); + } + } + } + + return clauses.join(' AND '); + } +} diff --git a/lib/core/database/table_schema_meta.dart b/lib/core/database/table_schema_meta.dart new file mode 100644 index 00000000..0043a62e --- /dev/null +++ b/lib/core/database/table_schema_meta.dart @@ -0,0 +1,80 @@ +/// Column schema metadata. +class TableColumnMeta { + const TableColumnMeta({ + required this.name, + required this.dataType, + this.isNullable = true, + this.isPrimaryKey = false, + this.primaryKeyPosition, + this.defaultValue, + }); + + final String name; + final String dataType; + final bool isNullable; + final bool isPrimaryKey; + final int? primaryKeyPosition; + final String? defaultValue; + + Map toJson() => { + 'name': name, + 'dataType': dataType, + 'isNullable': isNullable, + 'isPrimaryKey': isPrimaryKey, + 'primaryKeyPosition': primaryKeyPosition, + 'defaultValue': defaultValue, + }; + + factory TableColumnMeta.fromJson(Map json) => + TableColumnMeta( + name: json['name'] as String, + dataType: json['dataType'] as String, + isNullable: json['isNullable'] as bool? ?? true, + isPrimaryKey: json['isPrimaryKey'] as bool? ?? false, + primaryKeyPosition: json['primaryKeyPosition'] as int?, + defaultValue: json['defaultValue'] as String?, + ); +} + +/// Table schema metadata containing column descriptions and primary keys. +class TableSchemaMeta { + const TableSchemaMeta({ + required this.tableName, + this.schema, + this.columns = const [], + this.primaryKeys = const [], + }); + + final String tableName; + final String? schema; + final List columns; + final List primaryKeys; + + bool get hasPrimaryKey => primaryKeys.isNotEmpty; + + TableColumnMeta? getColumn(String name) { + for (final col in columns) { + if (col.name == name) return col; + } + return null; + } + + Map toJson() => { + 'tableName': tableName, + 'schema': schema, + 'columns': columns.map((c) => c.toJson()).toList(), + 'primaryKeys': primaryKeys, + }; + + factory TableSchemaMeta.fromJson(Map json) => + TableSchemaMeta( + tableName: json['tableName'] as String, + schema: json['schema'] as String?, + columns: (json['columns'] as List? ?? const []) + .map((c) => TableColumnMeta.fromJson(c as Map)) + .toList(), + primaryKeys: (json['primaryKeys'] as List? ?? const []) + .map((e) => e.toString()) + .toList(), + ); +} diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart new file mode 100644 index 00000000..2e191885 --- /dev/null +++ b/test/core/database/table_mutation_engine_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + +void main() { + group('TableSchemaMeta', () { + test('serializes and deserializes correctly', () { + const meta = TableSchemaMeta( + tableName: 'users', + schema: 'public', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + primaryKeyPosition: 1, + ), + TableColumnMeta( + name: 'email', + dataType: 'varchar', + isNullable: true, + ), + ], + primaryKeys: ['id'], + ); + + final json = meta.toJson(); + final restored = TableSchemaMeta.fromJson(json); + + expect(restored.tableName, 'users'); + expect(restored.schema, 'public'); + expect(restored.hasPrimaryKey, isTrue); + expect(restored.primaryKeys, ['id']); + expect(restored.columns.length, 2); + expect(restored.getColumn('id')?.isPrimaryKey, isTrue); + expect(restored.getColumn('email')?.isNullable, isTrue); + }); + }); + + group('TableMutationEngine', () { + const columns = ['id', 'username', 'age', 'active']; + const originalRows = [ + ['1', 'alice', '30', 'true'], + ['2', 'bob', '25', 'false'], + ]; + + test('generates UPDATE statement for modified cells with single PK in Postgres dialect', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: { + 0: {1: 'alice_updated', 2: '31'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.update); + expect( + stmt.sql, + 'UPDATE "public"."users" SET "username" = \'alice_updated\', "age" = 31 WHERE "id" = 1', + ); + + final tx = plan.toTransactionSql(); + expect(tx.startsWith('BEGIN;'), isTrue); + expect(tx.endsWith('COMMIT;\n'), isTrue); + }); + + test('generates UPDATE statement for MySQL dialect with backticks', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 'users', + schema: 'mydb', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: { + 1: {3: 'true'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.update); + expect( + stmt.sql, + 'UPDATE `mydb`.`users` SET `active` = TRUE WHERE `id` = 2', + ); + + final tx = plan.toTransactionSql(); + expect(tx.startsWith('START TRANSACTION;'), isTrue); + expect(tx.contains('COMMIT;'), isTrue); + }); + + test('generates INSERT statement with NULL values in SQLite dialect', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.sqlite, + tableName: 'users', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: {}, + insertedRows: [ + ['3', 'charlie', 'NULL', 'false'], + ], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.insert); + expect( + stmt.sql, + 'INSERT INTO "users" ("id", "username", "age", "active") VALUES (3, \'charlie\', NULL, 0)', + ); + + final tx = plan.toTransactionSql(); + expect(tx.startsWith('BEGIN TRANSACTION;'), isTrue); + }); + + test('generates DELETE statement for deleted row index', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + columns: columns, + primaryKeys: ['id'], + originalRows: originalRows, + modifiedCells: {}, + insertedRows: [], + deletedRowIndices: {0}, + ); + + expect(plan.statementCount, 1); + final stmt = plan.statements.first; + expect(stmt.type, MutationType.delete); + expect(stmt.sql, 'DELETE FROM "public"."users" WHERE "id" = 1'); + }); + + test('handles composite primary keys in WHERE clause', () { + const compositeCols = ['tenant_id', 'user_id', 'role']; + const compositeRows = [ + ['100', '1', 'admin'], + ]; + + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'user_roles', + columns: compositeCols, + primaryKeys: ['tenant_id', 'user_id'], + originalRows: compositeRows, + modifiedCells: { + 0: {2: 'superadmin'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.first.sql, + 'UPDATE "user_roles" SET "role" = \'superadmin\' WHERE "tenant_id" = 100 AND "user_id" = 1', + ); + }); + + test('falls back to all columns when no primary key is specified', () { + const noPkCols = ['category', 'description']; + const noPkRows = [ + ['sales', 'retail store'], + ]; + + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'tags', + columns: noPkCols, + primaryKeys: [], + originalRows: noPkRows, + modifiedCells: { + 0: {1: 'online store'}, + }, + insertedRows: [], + deletedRowIndices: {}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.first.sql, + 'UPDATE "tags" SET "description" = \'online store\' WHERE "category" = \'sales\' AND "description" = \'retail store\'', + ); + }); + }); +} From 5acbcd77073983d7fcba0d062682ae0a6455c62a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:10:13 +0300 Subject: [PATCH 22/40] fix(ui): wrap ResultsTab toolbar in horizontal scroll to prevent overflow (#577) Closes #577 --- lib/features/main_screen/results_tab.dart | 279 +++++++++++++++++----- 1 file changed, 216 insertions(+), 63 deletions(-) diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 8bf42665..fcfbc466 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -3,21 +3,24 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_calc_bar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_filter_bar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_groupings_view.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_toolbar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_value_panel.dart'; +import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +enum ResultViewMode { + grid, + groupings, +} + /// Query output: grid, loading, error, or placeholder. -/// -/// Render order in [_buildBody]: **loading** first (only when [isLoading]), -/// then **error** when [errorMessage] is non-empty (including when -/// `isLoading` is false), then status / affected / idle / grid content. -/// -/// Mode changes (idle / loading / error / status / grid) morph via -/// [QueryaFadeSlide]. Keys are per **mode**, not per row β€” so grid data updates -/// and scroll rebuilds do not re-trigger the transition. class ResultsTab extends material.StatefulWidget { const ResultsTab({ super.key, @@ -50,6 +53,17 @@ class ResultsTab extends material.StatefulWidget { class _ResultsTabState extends material.State { int? _selectedRowIndex; + ResultViewMode _viewMode = ResultViewMode.grid; + + bool _showFilterBar = false; + String _filterText = ''; + + bool _showValuePanel = false; + String? _focusedColumnName; + String? _focusedCellValue; + int? _focusedRowIndex; + + GridCalcStats _selectionStats = GridCalcStats.empty; @override Widget build(BuildContext context) { @@ -107,6 +121,16 @@ class _ResultsTabState extends material.State { ? widget.stagingBuffer!.effectiveRows : widget.rows; + final filteredIndices = GridFilterEngine.filterRowIndices( + filterText: _filterText, + columns: widget.columns, + rows: effectiveRows, + ); + + final filteredRows = filteredIndices.length == effectiveRows.length + ? effectiveRows + : filteredIndices.map((i) => effectiveRows[i]).toList(); + return material.Column( key: const material.ValueKey('results_mode_grid'), crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -122,7 +146,7 @@ class _ResultsTabState extends material.State { material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, - vertical: 6, + vertical: 4, ), decoration: material.BoxDecoration( color: Theme.of(context).colorScheme.card, @@ -135,65 +159,194 @@ class _ResultsTabState extends material.State { ), ), ), - child: material.Row( - children: [ - material.Icon( - material.Icons.table_rows_rounded, - size: 14, - color: Theme.of(context).colorScheme.primary, - ), - const Gap(6), - Text( - widget.statusLine ?? - (widget.affectedRows != null - ? 'Rows affected: ${widget.affectedRows}' - : '${effectiveRows.length} rows returned'), - ).small().semiBold(), - const material.Spacer(), - ExportMenuButton( - label: 'Copy β–Ύ', - icon: material.Icons.copy_rounded, - isSave: false, - onSelected: (format) { - unawaited(() async { - await DataExportService.copyToClipboard( - format, - columns: widget.columns, - rows: effectiveRows, - ); - }()); - }, - ), - const Gap(6), - ExportMenuButton( - label: 'Save β–Ύ', - icon: material.Icons.save_alt_rounded, - isSave: true, - onSelected: (format) { - unawaited(() async { - final outcome = await DataExportService.saveToFile( - format, - columns: widget.columns, - rows: effectiveRows, - ); - if (!context.mounted) return; - if (outcome == SaveExportOutcome.error) { - await _showSaveFileErrorDialog(context); - } - }()); - }, - ), - ], + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Grid / Groupings View Selector + material.SizedBox( + height: 28, + child: material.SegmentedButton( + segments: const [ + material.ButtonSegment( + value: ResultViewMode.grid, + label: Text('Grid'), + icon: material.Icon(material.Icons.table_chart_outlined, size: 14), + ), + material.ButtonSegment( + value: ResultViewMode.groupings, + label: Text('Groupings'), + icon: material.Icon(material.Icons.grid_view_rounded, size: 14), + ), + ], + selected: {_viewMode}, + onSelectionChanged: (selected) { + setState(() => _viewMode = selected.first); + }, + showSelectedIcon: false, + style: material.SegmentedButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 0), + visualDensity: material.VisualDensity.compact, + ), + ), + ), + const Gap(10), + Text( + widget.statusLine ?? + (widget.affectedRows != null + ? 'Rows affected: ${widget.affectedRows}' + : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), + ).small().semiBold(), + + const Gap(16), + + // Toggle Quick Filter + material.IconButton( + icon: material.Icon( + _showFilterBar ? material.Icons.filter_alt : material.Icons.filter_alt_outlined, + size: 15, + ), + tooltip: 'Toggle Quick Filter', + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + color: _showFilterBar || _filterText.isNotEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.mutedForeground, + onPressed: () { + setState(() => _showFilterBar = !_showFilterBar); + }, + ), + const Gap(4), + + // Toggle Value Side Panel + material.IconButton( + icon: material.Icon( + _showValuePanel ? material.Icons.dock : material.Icons.data_object_rounded, + size: 15, + ), + tooltip: 'Inspect Cell Panel', + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + color: _showValuePanel + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.mutedForeground, + onPressed: () { + setState(() => _showValuePanel = !_showValuePanel); + }, + ), + const Gap(8), + + ExportMenuButton( + label: 'Copy β–Ύ', + icon: material.Icons.copy_rounded, + isSave: false, + onSelected: (format) { + unawaited(() async { + await DataExportService.copyToClipboard( + format, + columns: widget.columns, + rows: filteredRows, + ); + }()); + }, + ), + const Gap(6), + ExportMenuButton( + label: 'Save β–Ύ', + icon: material.Icons.save_alt_rounded, + isSave: true, + onSelected: (format) { + unawaited(() async { + final outcome = await DataExportService.saveToFile( + format, + columns: widget.columns, + rows: filteredRows, + ); + if (!context.mounted) return; + if (outcome == SaveExportOutcome.error) { + await _showSaveFileErrorDialog(context); + } + }()); + }, + ), + ], + ), ), ), - material.Expanded( - child: VirtualResultGrid( + + // Quick Filter Bar + if (_showFilterBar || _filterText.isNotEmpty) + DataGridFilterBar( + filterText: _filterText, + onFilterChanged: (text) => setState(() => _filterText = text), + totalRowCount: effectiveRows.length, + filteredRowCount: filteredRows.length, columns: widget.columns, - rows: widget.rows, - stagingBuffer: widget.stagingBuffer, - onRowSelected: (row) => setState(() => _selectedRowIndex = row), ), + + // Main Grid Body / Groupings View + Side Panel + material.Expanded( + child: _viewMode == ResultViewMode.groupings + ? DataGridGroupingsView( + columns: widget.columns, + rows: filteredRows, + ) + : material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded( + child: VirtualResultGrid( + columns: widget.columns, + rows: filteredRows, + stagingBuffer: widget.stagingBuffer, + onRowSelected: (row) => setState(() => _selectedRowIndex = row), + onSelectionValuesChanged: (values) { + setState(() { + _selectionStats = GridSelectionCalcEngine.compute(values); + }); + }, + onCellFocused: (colName, cellVal, rowIdx) { + setState(() { + _focusedColumnName = colName; + _focusedCellValue = cellVal; + _focusedRowIndex = rowIdx; + }); + }, + ), + ), + + // Value Inspector Panel + if (_showValuePanel && + _focusedColumnName != null && + _focusedCellValue != null) + DataGridValuePanel( + columnName: _focusedColumnName!, + cellValue: _focusedCellValue!, + rowIndex: _focusedRowIndex, + onClose: () => setState(() => _showValuePanel = false), + onUpdateValue: widget.stagingBuffer != null && + _focusedRowIndex != null && + _focusedColumnName != null + ? (newVal) { + final colIdx = widget.columns.indexOf(_focusedColumnName!); + if (colIdx != -1) { + widget.stagingBuffer!.setCell( + _focusedRowIndex!, + colIdx, + newVal, + ); + } + } + : null, + ), + ], + ), ), + + // Calc Bar Footer + if (_viewMode == ResultViewMode.grid) + DataGridCalcBar(stats: _selectionStats), ], ); } From c0e3dcb12ca1d9c37552e2ff266d9d2edfea90ec Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:12:44 +0300 Subject: [PATCH 23/40] feat(data-grid): integrate TableMutationPlan generator with staging buffer (#564) Closes #564 --- .../main_screen/data_grid_staging_buffer.dart | 33 +++++++++++++++++++ .../data_grid_staging_buffer_test.dart | 22 +++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart index 160cc45a..ddae0db0 100644 --- a/lib/features/main_screen/data_grid_staging_buffer.dart +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; /// Status of a row within the staging buffer. enum StagedRowStatus { @@ -231,6 +232,37 @@ class DataGridStagingBuffer extends ChangeNotifier { notifyListeners(); } + /// Read-only snapshot of modified cells mapping. + Map> get modifiedCells => + Map.unmodifiable(_modifiedCells.map((k, v) => MapEntry(k, Map.unmodifiable(v)))); + + /// Read-only list of newly inserted rows. + List> get insertedRows => + List.unmodifiable(_insertedRows.map((r) => List.unmodifiable(r))); + + /// Read-only set of deleted row indices. + Set get deletedRowIndices => Set.unmodifiable(_deletedRowIndices); + + /// Generates an atomic [TableMutationPlan] for the current staged changes. + TableMutationPlan generateMutationPlan({ + required SqlDialect dialect, + required String tableName, + String? schema, + List primaryKeys = const [], + }) { + return TableMutationEngine.generatePlan( + dialect: dialect, + tableName: tableName, + schema: schema, + columns: _originalColumns, + primaryKeys: primaryKeys, + originalRows: _originalRows, + modifiedCells: _modifiedCells, + insertedRows: _insertedRows, + deletedRowIndices: _deletedRowIndices, + ); + } + /// Returns the full list of effective rows (original with modifications applied + inserted rows). List> get effectiveRows { final result = >[]; @@ -252,3 +284,4 @@ class DataGridStagingBuffer extends ChangeNotifier { return result; } } + diff --git a/test/features/main_screen/data_grid_staging_buffer_test.dart b/test/features/main_screen/data_grid_staging_buffer_test.dart index 57483b67..09eb68b4 100644 --- a/test/features/main_screen/data_grid_staging_buffer_test.dart +++ b/test/features/main_screen/data_grid_staging_buffer_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; void main() { @@ -119,5 +120,26 @@ void main() { expect(eff[2], ['3', 'Charlie', 'charlie@test.com']); expect(eff[3], ['4', 'David', 'david@test.com']); }); + + test('generateMutationPlan builds correct DML statements from staged modifications', () { + buffer.setCell(0, 1, 'Alice Updated'); + buffer.addRow(['4', 'Diana', 'diana@test.com']); + buffer.toggleDeleteRow(2); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + primaryKeys: ['id'], + ); + + expect(plan.statementCount, 3); + expect(plan.statements[0].type, MutationType.update); + expect(plan.statements[0].sql, 'UPDATE "public"."users" SET "name" = \'Alice Updated\' WHERE "id" = 1'); + expect(plan.statements[1].type, MutationType.insert); + expect(plan.statements[1].sql, 'INSERT INTO "public"."users" ("id", "name", "email") VALUES (4, \'Diana\', \'diana@test.com\')'); + expect(plan.statements[2].type, MutationType.delete); + expect(plan.statements[2].sql, 'DELETE FROM "public"."users" WHERE "id" = 3'); + }); }); } From c254f415e64a875fbfa01d1394068048ab70d360 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:17:47 +0300 Subject: [PATCH 24/40] feat(workspaces): wire staging buffer and save handler in SQLite, Postgres, and MySQL workspaces (#565) Closes #565 --- .../database/sql_table_target_extractor.dart | 52 ++++++++++++++ lib/features/mysql/mysql_sql_workspace.dart | 67 ++++++++++++++++++ .../postgresql/postgres_sql_workspace.dart | 68 +++++++++++++++++++ lib/features/sqlite/sqlite_sql_workspace.dart | 66 ++++++++++++++++++ .../sql_table_target_extractor_test.dart | 37 ++++++++++ 5 files changed, 290 insertions(+) create mode 100644 lib/core/database/sql_table_target_extractor.dart create mode 100644 test/core/database/sql_table_target_extractor_test.dart diff --git a/lib/core/database/sql_table_target_extractor.dart b/lib/core/database/sql_table_target_extractor.dart new file mode 100644 index 00000000..2010fe85 --- /dev/null +++ b/lib/core/database/sql_table_target_extractor.dart @@ -0,0 +1,52 @@ +/// Result of extracting target table and schema from an SQL query. +class SqlTableTarget { + const SqlTableTarget({ + required this.tableName, + this.schema, + }); + + final String tableName; + final String? schema; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SqlTableTarget && + tableName == other.tableName && + schema == other.schema; + + @override + int get hashCode => Object.hash(tableName, schema); +} + +/// Helper utility to infer the primary target table from a simple SELECT query. +abstract final class SqlTableTargetExtractor { + static final _fromTableRegex = RegExp( + r'\bfrom\s+(?:(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))\.)?(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))', + caseSensitive: false, + ); + + /// Extracts the target schema and table name from [sql]. + /// Returns `null` if no simple target table can be determined (e.g. subqueries, joins). + static SqlTableTarget? extract(String sql) { + final trimmed = sql.trim(); + if (trimmed.isEmpty) return null; + + // If query has JOIN or multiple tables separated by comma, avoid auto-generating DML + final hasJoin = RegExp(r'\bjoin\b', caseSensitive: false).hasMatch(trimmed); + if (hasJoin) return null; + + final match = _fromTableRegex.firstMatch(trimmed); + if (match == null) return null; + + final schema = match.group(1) ?? match.group(2) ?? match.group(3); + final table = match.group(4) ?? match.group(5) ?? match.group(6); + + if (table == null || table.isEmpty) return null; + + return SqlTableTarget( + tableName: table, + schema: schema, + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index ecaf78ba..df559d5d 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -8,11 +8,14 @@ import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; @@ -46,6 +49,9 @@ class _MysqlSqlWorkspaceState extends material.State { List> _rows = []; int? _affectedRows; String? _statusLine; + DataGridStagingBuffer? _stagingBuffer; + String? _lastExecutedSql; + bool _savingChanges = false; int? _queryTimeoutSeconds; @@ -224,6 +230,10 @@ class _MysqlSqlWorkspaceState extends material.State { _columns = cols; _rows = outRows; _affectedRows = affected; + _lastExecutedSql = userSql; + _stagingBuffer = cols.isNotEmpty + ? DataGridStagingBuffer(columns: cols, rows: outRows) + : null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = affected != null ? 'OK. Rows affected: $affected.' @@ -264,6 +274,60 @@ class _MysqlSqlWorkspaceState extends material.State { } } + Future _applyStagedChanges() async { + if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return; + final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null; + final tableName = target?.tableName ?? 'table'; + final schemaName = target?.schema; + + setState(() => _savingChanges = true); + try { + final plan = _stagingBuffer!.generateMutationPlan( + dialect: SqlDialect.mysql, + tableName: tableName, + schema: schemaName, + ); + if (plan.isEmpty) { + setState(() => _savingChanges = false); + return; + } + + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to MySQL.'); + } + + for (final stmt in plan.statements) { + await conn.execute(stmt.sql); + } + + if (!mounted) return; + setState(() { + _rows = _stagingBuffer!.effectiveRows; + _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); + _savingChanges = false; + }); + } catch (e) { + if (mounted) { + setState(() => _savingChanges = false); + await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Save Changes Failed'), + content: material.Text(e.toString()), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); + } + } + } + static int? _affectedInt(BigInt v) { if (v == BigInt.zero) return null; return v.toInt(); @@ -453,6 +517,9 @@ class _MysqlSqlWorkspaceState extends material.State { isLoading: _running, affectedRows: _affectedRows, statusLine: _statusLine, + stagingBuffer: _stagingBuffer, + onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges, + isSaving: _savingChanges, ), ), ], diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index d508ee94..e388a769 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -10,6 +10,8 @@ import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -17,6 +19,7 @@ import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; @@ -80,6 +83,9 @@ class _PostgresSqlWorkspaceState extends material.State { List> _rows = []; int? _affectedRows; String? _statusLine; + DataGridStagingBuffer? _stagingBuffer; + String? _lastExecutedSql; + bool _savingChanges = false; /// PostgreSQL default: each statement is its own transaction unless you use /// `BEGIN` / `BEGIN`+implicit when autocommit is off. @@ -367,6 +373,10 @@ class _PostgresSqlWorkspaceState extends material.State { _columns = cols; _rows = outRows; _affectedRows = result.affectedRows; + _lastExecutedSql = userSql; + _stagingBuffer = cols.isNotEmpty + ? DataGridStagingBuffer(columns: cols, rows: outRows) + : null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = 'Command completed. Rows affected: ${result.affectedRows}.'; @@ -416,6 +426,61 @@ class _PostgresSqlWorkspaceState extends material.State { } } + Future _applyStagedChanges() async { + if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return; + final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null; + final tableName = target?.tableName ?? 'table'; + final schemaName = target?.schema; + + setState(() => _savingChanges = true); + try { + final plan = _stagingBuffer!.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: tableName, + schema: schemaName, + ); + if (plan.isEmpty) { + setState(() => _savingChanges = false); + return; + } + + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + + final txSql = plan.toTransactionSql(); + final to = _statementTimeout(); + await conn.execute(txSql, timeout: to); + + if (!mounted) return; + setState(() { + _rows = _stagingBuffer!.effectiveRows; + _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); + _savingChanges = false; + }); + await _refreshTxStatus(); + } catch (e) { + if (mounted) { + setState(() => _savingChanges = false); + await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Save Changes Failed'), + content: material.Text(e.toString()), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); + } + } + } + Future _openSqlFile() async { try { final file = await openFile( @@ -597,6 +662,9 @@ class _PostgresSqlWorkspaceState extends material.State { isLoading: _running, affectedRows: _affectedRows, statusLine: _statusLine, + stagingBuffer: _stagingBuffer, + onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges, + isSaving: _savingChanges, ), ), ], diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 37ae79d8..447e2e44 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -6,12 +6,15 @@ import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; @@ -45,6 +48,9 @@ class _SqliteSqlWorkspaceState extends material.State { List> _rows = []; int? _affectedRows; String? _statusLine; + DataGridStagingBuffer? _stagingBuffer; + String? _lastExecutedSql; + bool _savingChanges = false; int _resultMaxRows = kDefaultSqlResultMaxRows; int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; @@ -186,6 +192,10 @@ class _SqliteSqlWorkspaceState extends material.State { _columns = cols; _rows = outRows; _affectedRows = null; + _lastExecutedSql = userSql; + _stagingBuffer = cols.isNotEmpty + ? DataGridStagingBuffer(columns: cols, rows: outRows) + : null; if (cols.isEmpty && outRows.isEmpty) { _statusLine = 'Command completed.'; } else if (truncated || (injectedLimit && results.length >= cap)) { @@ -225,6 +235,59 @@ class _SqliteSqlWorkspaceState extends material.State { } } + Future _applyStagedChanges() async { + if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return; + final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null; + final tableName = target?.tableName ?? 'table'; + + setState(() => _savingChanges = true); + try { + final plan = _stagingBuffer!.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: tableName, + schema: target?.schema, + ); + if (plan.isEmpty) { + setState(() => _savingChanges = false); + return; + } + + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + throw StateError('Could not connect to SQLite.'); + } + + for (final stmt in plan.statements) { + await conn.execute(stmt.sql); + } + + if (!mounted) return; + setState(() { + _rows = _stagingBuffer!.effectiveRows; + _stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows); + _savingChanges = false; + }); + } catch (e) { + if (mounted) { + setState(() => _savingChanges = false); + await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Save Changes Failed'), + content: material.Text(e.toString()), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(), + child: const material.Text('OK'), + ), + ], + ), + ); + } + } + } + Future _openSqlFile() async { try { final file = await openFile( @@ -396,6 +459,9 @@ class _SqliteSqlWorkspaceState extends material.State { isLoading: _running, affectedRows: _affectedRows, statusLine: _statusLine, + stagingBuffer: _stagingBuffer, + onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges, + isSaving: _savingChanges, ), ), ], diff --git a/test/core/database/sql_table_target_extractor_test.dart b/test/core/database/sql_table_target_extractor_test.dart new file mode 100644 index 00000000..8bf3a4f7 --- /dev/null +++ b/test/core/database/sql_table_target_extractor_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; + +void main() { + group('SqlTableTargetExtractor', () { + test('extracts table name from simple SELECT', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM users'); + expect(target, isNotNull); + expect(target!.tableName, 'users'); + expect(target.schema, isNull); + }); + + test('extracts schema and table from quoted identifiers', () { + final target = SqlTableTargetExtractor.extract('SELECT id, name FROM "public"."accounts" WHERE id > 10'); + expect(target, isNotNull); + expect(target!.tableName, 'accounts'); + expect(target.schema, 'public'); + }); + + test('extracts schema and table from MySQL backticks', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM `shop_db`.`orders` ORDER BY date DESC'); + expect(target, isNotNull); + expect(target!.tableName, 'orders'); + expect(target.schema, 'shop_db'); + }); + + test('returns null for queries with JOINs to avoid ambiguous mutations', () { + final target = SqlTableTargetExtractor.extract('SELECT u.id, o.amount FROM users u JOIN orders o ON u.id = o.user_id'); + expect(target, isNull); + }); + + test('returns null for empty or non-FROM queries', () { + expect(SqlTableTargetExtractor.extract(''), isNull); + expect(SqlTableTargetExtractor.extract('SELECT 1 + 1'), isNull); + }); + }); +} From 3d4e5eb0b3df67faf9980f6e7a954dcdbc25bb9c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:20:18 +0300 Subject: [PATCH 25/40] feat(grid): add atomic DML preview and confirmation dialog before save (#566) Closes #566 --- .../main_screen/dml_preview_dialog.dart | 329 ++++++++++++++++++ lib/features/mysql/mysql_sql_workspace.dart | 10 + .../postgresql/postgres_sql_workspace.dart | 10 + lib/features/sqlite/sqlite_sql_workspace.dart | 10 + .../main_screen/dml_preview_dialog_test.dart | 105 ++++++ 5 files changed, 464 insertions(+) create mode 100644 lib/features/main_screen/dml_preview_dialog.dart create mode 100644 test/features/main_screen/dml_preview_dialog_test.dart diff --git a/lib/features/main_screen/dml_preview_dialog.dart b/lib/features/main_screen/dml_preview_dialog.dart new file mode 100644 index 00000000..e30b0884 --- /dev/null +++ b/lib/features/main_screen/dml_preview_dialog.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Opens the DML Preview and Confirmation dialog before executing staged changes. +/// +/// Returns `true` if user confirmed execution, or `false`/`null` if cancelled. +Future showDmlPreviewDialog({ + required material.BuildContext context, + required TableMutationPlan plan, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _DmlPreviewDialog(plan: plan), + ); +} + +class _DmlPreviewDialog extends material.StatefulWidget { + const _DmlPreviewDialog({required this.plan}); + + final TableMutationPlan plan; + + @override + material.State<_DmlPreviewDialog> createState() => _DmlPreviewDialogState(); +} + +class _DmlPreviewDialogState extends material.State<_DmlPreviewDialog> { + bool _copied = false; + + int get _updateCount => + widget.plan.statements.where((s) => s.type == MutationType.update).length; + + int get _insertCount => + widget.plan.statements.where((s) => s.type == MutationType.insert).length; + + int get _deleteCount => + widget.plan.statements.where((s) => s.type == MutationType.delete).length; + + String get _dialectName { + switch (widget.plan.dialect) { + case SqlDialect.postgres: + return 'PostgreSQL'; + case SqlDialect.mysql: + return 'MySQL'; + case SqlDialect.sqlite: + return 'SQLite'; + } + } + + Future _copySql() async { + final sql = widget.plan.toTransactionSql(); + await Clipboard.setData(ClipboardData(text: sql)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + final isDark = theme.brightness == Brightness.dark; + final sql = widget.plan.toTransactionSql(); + + return material.Dialog( + backgroundColor: cs.card, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(8), + side: material.BorderSide(color: cs.border, width: 1), + ), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints( + minWidth: 540, + maxWidth: 680, + minHeight: 380, + maxHeight: 580, + ), + child: material.Padding( + padding: const material.EdgeInsets.all(18), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header Row + material.Row( + children: [ + material.Icon( + material.Icons.save_as_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + const Text('Confirm Data Changes').semiBold().large(), + ], + ), + const Gap(4), + const Text( + 'Review pending SQL mutations before applying them to the database.', + ).muted().small(), + const Gap(14), + + // Metadata badges row + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + // Target Table Badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: cs.muted, + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.table_chart_outlined, + size: 14, + color: cs.foreground, + ), + const Gap(6), + Text( + widget.plan.schema != null && + widget.plan.schema!.isNotEmpty + ? '${widget.plan.schema}.${widget.plan.tableName}' + : widget.plan.tableName, + ).semiBold().small(), + ], + ), + ), + + // Dialect Badge + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.primary.withValues(alpha: 0.3), + ), + ), + child: Text( + _dialectName, + style: TextStyle( + color: cs.primary, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + + // Changes Breakdown Pills + if (_updateCount > 0) + _buildCountPill( + label: '$_updateCount UPDATE', + color: material.Colors.amber.shade700, + isDark: isDark, + ), + if (_insertCount > 0) + _buildCountPill( + label: '$_insertCount INSERT', + color: material.Colors.green.shade600, + isDark: isDark, + ), + if (_deleteCount > 0) + _buildCountPill( + label: '$_deleteCount DELETE', + color: material.Colors.red.shade600, + isDark: isDark, + ), + ], + ), + const Gap(14), + + // SQL Preview code block header + material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + children: [ + const Text('TRANSACTION SCRIPT').semiBold().xSmall(), + material.InkWell( + onTap: _copySql, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: 13, + color: _copied + ? material.Colors.green + : cs.mutedForeground, + ), + const Gap(4), + Text(_copied ? 'Copied' : 'Copy SQL').xSmall(), + ], + ), + ), + ), + ], + ), + const Gap(6), + + // SQL Code Preview Container + material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: isDark + ? const material.Color(0xFF141416) + : const material.Color(0xFFF4F4F6), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: material.SelectableText( + sql, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.45, + color: isDark + ? const material.Color(0xFFE2E8F0) + : const material.Color(0xFF1E293B), + ), + ), + ), + ), + ), + const Gap(12), + + // Atomic Notice + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.info_outline, + size: 15, + color: cs.mutedForeground, + ), + const Gap(8), + const material.Expanded( + child: Text( + 'All mutations will be executed atomically in a single transaction.', + ), + ), + ], + ), + ), + const Gap(16), + + // Actions + material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + const Gap(8), + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(true), + leading: const material.Icon( + material.Icons.save_outlined, + size: 16, + ), + child: const Text('Apply Changes'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + material.Widget _buildCountPill({ + required String label, + required material.Color color, + required bool isDark, + }) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: material.BoxDecoration( + color: color.withValues(alpha: isDark ? 0.18 : 0.12), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: color.withValues(alpha: isDark ? 0.45 : 0.3), + ), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index df559d5d..62c9ce22 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -16,6 +16,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; @@ -292,6 +293,15 @@ class _MysqlSqlWorkspaceState extends material.State { return; } + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + setState(() => _savingChanges = false); + return; + } + await _ensureLease(); final conn = _lease?.connection; if (conn == null || !conn.isConnected) { diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index e388a769..b87f2873 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -20,6 +20,7 @@ import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; @@ -444,6 +445,15 @@ class _PostgresSqlWorkspaceState extends material.State { return; } + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + setState(() => _savingChanges = false); + return; + } + await _ensureLease(); final conn = _lease?.connection; if (conn == null || !conn.isConnected) { diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 447e2e44..04534f02 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -15,6 +15,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; @@ -252,6 +253,15 @@ class _SqliteSqlWorkspaceState extends material.State { return; } + final confirmed = await showDmlPreviewDialog( + context: context, + plan: plan, + ); + if (confirmed != true) { + setState(() => _savingChanges = false); + return; + } + await _ensureLease(); final conn = _lease?.connection; if (conn == null || !conn.isConnected) { diff --git a/test/features/main_screen/dml_preview_dialog_test.dart b/test/features/main_screen/dml_preview_dialog_test.dart new file mode 100644 index 00000000..baae05b1 --- /dev/null +++ b/test/features/main_screen/dml_preview_dialog_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/main_screen/dml_preview_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + material.Widget buildTestDialog({required TableMutationPlan plan, void Function(bool?)? onResult}) { + return queryaThemeTestShell( + child: material.Material( + child: material.Builder( + builder: (context) { + return OutlineButton( + onPressed: () async { + final res = await showDmlPreviewDialog(context: context, plan: plan); + onResult?.call(res); + }, + child: const Text('Open Dialog'), + ); + }, + ), + ), + ); + } + + group('DmlPreviewConfirmationDialog', () { + final samplePlan = TableMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + statements: const [ + TableMutationStatement( + type: MutationType.update, + sql: 'UPDATE "public"."users" SET "name" = \'Alice\' WHERE "id" = 1', + description: 'Update row 1', + ), + TableMutationStatement( + type: MutationType.insert, + sql: 'INSERT INTO "public"."users" ("id", "name") VALUES (2, \'Bob\')', + description: 'Insert new row 2', + ), + TableMutationStatement( + type: MutationType.delete, + sql: 'DELETE FROM "public"."users" WHERE "id" = 3', + description: 'Delete row 3', + ), + ], + ); + + testWidgets('renders dialog header, badges, and SQL preview', (tester) async { + await tester.pumpWidget(buildTestDialog(plan: samplePlan)); + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + expect(find.text('Confirm Data Changes'), findsOneWidget); + expect(find.text('public.users'), findsOneWidget); + expect(find.text('PostgreSQL'), findsOneWidget); + expect(find.text('1 UPDATE'), findsOneWidget); + expect(find.text('1 INSERT'), findsOneWidget); + expect(find.text('1 DELETE'), findsOneWidget); + expect(find.text('Apply Changes'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.textContaining('BEGIN;'), findsOneWidget); + expect(find.textContaining('COMMIT;'), findsOneWidget); + }); + + testWidgets('cancelling dialog pops false', (tester) async { + bool? result; + await tester.pumpWidget( + buildTestDialog( + plan: samplePlan, + onResult: (res) => result = res, + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isFalse); + }); + + testWidgets('confirming dialog pops true', (tester) async { + bool? result; + await tester.pumpWidget( + buildTestDialog( + plan: samplePlan, + onResult: (res) => result = res, + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Apply Changes')); + await tester.pumpAndSettle(); + + expect(result, isTrue); + }); + }); +} From 295663d55bc863751ac327a24bb622749f2f8b16 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:23:20 +0300 Subject: [PATCH 26/40] feat(grid): add in-cell data type validator, visual error cues, and bidirectional navigation shortcuts (#567) Closes #567 --- .../main_screen/grid_cell_editor.dart | 58 +++++++++- .../main_screen/grid_data_type_validator.dart | 104 ++++++++++++++++++ .../main_screen/result_grid_view.dart | 56 +++++++++- .../main_screen/grid_cell_editor_test.dart | 30 ++++- .../grid_data_type_validator_test.dart | 84 ++++++++++++++ 5 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 lib/features/main_screen/grid_data_type_validator.dart create mode 100644 test/features/main_screen/grid_data_type_validator_test.dart diff --git a/lib/features/main_screen/grid_cell_editor.dart b/lib/features/main_screen/grid_cell_editor.dart index 24bbdb85..3b714e1a 100644 --- a/lib/features/main_screen/grid_cell_editor.dart +++ b/lib/features/main_screen/grid_cell_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Active inline editor widget for a data grid cell. @@ -11,17 +12,20 @@ class GridCellEditor extends material.StatefulWidget { required this.height, required this.onCommit, required this.onCancel, + this.dataTypeName, this.onOpenInspector, }); final String initialValue; final double width; final double height; + final String? dataTypeName; final void Function( String value, { bool moveNextCol, bool movePrevCol, bool moveNextRow, + bool movePrevRow, }) onCommit; final material.VoidCallback onCancel; final material.VoidCallback? onOpenInspector; @@ -33,6 +37,7 @@ class GridCellEditor extends material.StatefulWidget { class _GridCellEditorState extends material.State { late final material.TextEditingController _controller; final _focusNode = material.FocusNode(); + String? _validationError; @override void initState() { @@ -45,10 +50,26 @@ class _GridCellEditorState extends material.State { baseOffset: 0, extentOffset: _controller.text.length, ); + + _validate(); + _controller.addListener(_validate); + } + + void _validate() { + final error = GridDataTypeValidator.validate( + _controller.text, + dataTypeName: widget.dataTypeName, + ); + if (error != _validationError) { + setState(() { + _validationError = error; + }); + } } @override void dispose() { + _controller.removeListener(_validate); _controller.dispose(); _focusNode.dispose(); super.dispose(); @@ -59,18 +80,35 @@ class _GridCellEditorState extends material.State { final isShift = HardwareKeyboard.instance.isShiftPressed; final isAlt = HardwareKeyboard.instance.isAltPressed; + final isControl = HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed; - if (event.logicalKey == LogicalKeyboardKey.keyN && isAlt) { + // Alt+N / Ctrl+Alt+N -> Set NULL + if (event.logicalKey == LogicalKeyboardKey.keyN && (isAlt || (isControl && isAlt))) { widget.onCommit('NULL'); return; } + // Alt+Enter or Ctrl+Enter -> Open Inspector + if ((event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.numpadEnter) && + (isAlt || isControl)) { + widget.onOpenInspector?.call(); + return; + } + + // Enter / Shift+Enter -> Commit and navigate row if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { - widget.onCommit(_controller.text, moveNextRow: true); + if (isShift) { + widget.onCommit(_controller.text, movePrevRow: true); + } else { + widget.onCommit(_controller.text, moveNextRow: true); + } return; } + // Tab / Shift+Tab -> Commit and navigate col if (event.logicalKey == LogicalKeyboardKey.tab) { if (isShift) { widget.onCommit(_controller.text, movePrevCol: true); @@ -80,6 +118,7 @@ class _GridCellEditorState extends material.State { return; } + // Escape -> Cancel if (event.logicalKey == LogicalKeyboardKey.escape) { widget.onCancel(); return; @@ -89,6 +128,7 @@ class _GridCellEditorState extends material.State { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; + final hasError = _validationError != null; return material.Container( width: widget.width, @@ -96,7 +136,7 @@ class _GridCellEditorState extends material.State { decoration: material.BoxDecoration( color: cs.card, border: material.Border.all( - color: cs.primary, + color: hasError ? material.Colors.red.shade500 : cs.primary, width: 1.5, ), ), @@ -128,6 +168,18 @@ class _GridCellEditorState extends material.State { ), ), ), + if (hasError) + material.Tooltip( + message: _validationError!, + child: material.Padding( + padding: const material.EdgeInsets.only(left: 4), + child: material.Icon( + material.Icons.error_outline_rounded, + size: 14, + color: material.Colors.red.shade500, + ), + ), + ), if (widget.onOpenInspector != null) material.MouseRegion( cursor: material.SystemMouseCursors.click, diff --git a/lib/features/main_screen/grid_data_type_validator.dart b/lib/features/main_screen/grid_data_type_validator.dart new file mode 100644 index 00000000..ae999c44 --- /dev/null +++ b/lib/features/main_screen/grid_data_type_validator.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +/// Helper utility for validating cell values against SQL data types. +abstract final class GridDataTypeValidator { + static final _uuidRegex = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ); + static final _intRegex = RegExp(r'^-?\d+$'); + static final _numRegex = RegExp(r'^-?\d+(\.\d+)?$'); + static final _dateRegex = RegExp(r'^\d{4}-\d{2}-\d{2}$'); + static final _timestampRegex = RegExp( + r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}(:\d{2})?)?$', + ); + + /// Validates [value] against the column's [dataTypeName]. + /// Returns `null` if valid (or type is unknown), or an error description string if invalid. + static String? validate(String value, {String? dataTypeName}) { + if (value.isEmpty || value == 'NULL' || value == 'null') { + return null; + } + if (dataTypeName == null || dataTypeName.isEmpty) { + return null; + } + + final type = dataTypeName.toLowerCase().trim(); + + // Integer types + if (type.contains('int') || type == 'serial' || type == 'bigserial') { + if (!_intRegex.hasMatch(value.trim())) { + return 'Expected valid integer'; + } + return null; + } + + // Floating / Decimal / Numeric types + if (type.contains('num') || + type.contains('decimal') || + type.contains('float') || + type.contains('double') || + type == 'real') { + if (!_numRegex.hasMatch(value.trim())) { + return 'Expected valid number'; + } + return null; + } + + // Boolean types + if (type == 'bool' || type == 'boolean') { + final lower = value.toLowerCase().trim(); + if (lower != 'true' && + lower != 'false' && + lower != '1' && + lower != '0' && + lower != 't' && + lower != 'f') { + return 'Expected boolean (true/false/1/0)'; + } + return null; + } + + // UUID + if (type == 'uuid') { + if (!_uuidRegex.hasMatch(value.trim())) { + return 'Expected valid UUID (e.g. 123e4567-e89b-12d3-a456-426614174000)'; + } + return null; + } + + // JSON / JSONB + if (type.contains('json')) { + final trimmed = value.trim(); + if (!((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')))) { + return 'Expected valid JSON object or array'; + } + try { + jsonDecode(trimmed); + } catch (e) { + return 'Malformed JSON: $e'; + } + return null; + } + + // Date + if (type == 'date') { + if (!_dateRegex.hasMatch(value.trim())) { + return 'Expected date in YYYY-MM-DD format'; + } + return null; + } + + // Timestamp / DateTime + if (type.contains('timestamp') || + type.contains('datetime') || + type == 'timestamptz') { + if (!_timestampRegex.hasMatch(value.trim())) { + return 'Expected timestamp (YYYY-MM-DD HH:MM:SS)'; + } + return null; + } + + return null; + } +} diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart index 10b90f3d..12acce16 100644 --- a/lib/features/main_screen/result_grid_view.dart +++ b/lib/features/main_screen/result_grid_view.dart @@ -338,12 +338,16 @@ class VirtualResultGrid extends material.StatefulWidget { required this.rows, this.stagingBuffer, this.onRowSelected, + this.onSelectionValuesChanged, + this.onCellFocused, }); final List columns; final List> rows; final DataGridStagingBuffer? stagingBuffer; final material.ValueChanged? onRowSelected; + final material.ValueChanged>? onSelectionValuesChanged; + final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused; @override material.State createState() => _VirtualResultGridState(); @@ -446,6 +450,7 @@ class _VirtualResultGridState extends material.State { bool moveNextCol = false, bool movePrevCol = false, bool moveNextRow = false, + bool movePrevRow = false, }) { if (widget.stagingBuffer != null) { widget.stagingBuffer!.setCell(row, column, value); @@ -503,6 +508,18 @@ class _VirtualResultGridState extends material.State { } else { _editingCell = null; } + } else if (movePrevRow) { + if (row > 0) { + _editingCell = ResultGridCellCoordinate(row - 1, column); + _selection = ResultGridSelection( + startRow: row - 1, + startColumn: column, + endRow: row - 1, + endColumn: column, + ); + } else { + _editingCell = null; + } } else { _editingCell = null; } @@ -585,6 +602,38 @@ class _VirtualResultGridState extends material.State { } } + void _notifySelectionAndFocus() { + if (widget.onSelectionValuesChanged != null) { + if (_selection == null) { + widget.onSelectionValuesChanged!(const []); + } else { + final rows = _sortedRows; + final values = []; + for (var r = _selection!.startRow; r <= _selection!.endRow; r++) { + if (r >= 0 && r < rows.length) { + for (var c = _selection!.startColumn; c <= _selection!.endColumn; c++) { + if (c >= 0 && c < rows[r].length) { + values.add(rows[r][c]); + } + } + } + } + widget.onSelectionValuesChanged!(values); + } + } + + if (widget.onCellFocused != null && _selectionAnchor != null) { + final r = _selectionAnchor!.row; + final c = _selectionAnchor!.column; + final rows = _sortedRows; + if (r >= 0 && r < rows.length && c >= 0 && c < widget.columns.length) { + final colName = widget.columns[c]; + final val = c < rows[r].length ? rows[r][c] : ''; + widget.onCellFocused!(colName, val, r); + } + } + } + void _onCellTap(int row, int column, {bool isShift = false}) { _focusNode.requestFocus(); widget.onRowSelected?.call(row); @@ -605,6 +654,7 @@ class _VirtualResultGridState extends material.State { ); } }); + _notifySelectionAndFocus(); } void _onCellSecondaryTap(int row, int column) { @@ -622,6 +672,7 @@ class _VirtualResultGridState extends material.State { endColumn: column, ); }); + _notifySelectionAndFocus(); _copySelection(); } } @@ -1078,6 +1129,7 @@ class _DataRow extends material.StatelessWidget { bool moveNextCol, bool movePrevCol, bool moveNextRow, + bool movePrevRow, })? onCommitEdit; final material.VoidCallback? onCancelEdit; final void Function(int row, int col)? onOpenInspector; @@ -1181,6 +1233,7 @@ class _GridCell extends material.StatelessWidget { bool moveNextCol, bool movePrevCol, bool moveNextRow, + bool movePrevRow, })? onCommitEdit; final material.VoidCallback? onCancelEdit; final void Function(int row, int col)? onOpenInspector; @@ -1192,7 +1245,7 @@ class _GridCell extends material.StatelessWidget { initialValue: text, width: width, height: double.infinity, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) { + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { onCommitEdit?.call( row, column, @@ -1200,6 +1253,7 @@ class _GridCell extends material.StatelessWidget { moveNextCol: moveNextCol, movePrevCol: movePrevCol, moveNextRow: moveNextRow, + movePrevRow: movePrevRow, ); }, onCancel: () => onCancelEdit?.call(), diff --git a/test/features/main_screen/grid_cell_editor_test.dart b/test/features/main_screen/grid_cell_editor_test.dart index ad8a1a8c..cfaa10b7 100644 --- a/test/features/main_screen/grid_cell_editor_test.dart +++ b/test/features/main_screen/grid_cell_editor_test.dart @@ -22,7 +22,7 @@ void main() { initialValue: 'Original', width: 200, height: 36, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) { + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) { committed = val; movedRow = moveNextRow; }, @@ -54,7 +54,7 @@ void main() { initialValue: 'Original', width: 200, height: 36, - onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false}) {}, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {}, onCancel: () => cancelled = true, ), ), @@ -67,6 +67,32 @@ void main() { expect(cancelled, isTrue); }); + + testWidgets('displays validation error icon on invalid integer input', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: GridCellEditor( + initialValue: '123', + dataTypeName: 'integer', + width: 200, + height: 36, + onCommit: (val, {moveNextCol = false, movePrevCol = false, moveNextRow = false, movePrevRow = false}) {}, + onCancel: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.error_outline_rounded), findsNothing); + + // Enter non-integer text + await tester.enterText(find.byType(material.TextField), 'not_a_number'); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + }); }); group('GridCellPopoverInspector', () { diff --git a/test/features/main_screen/grid_data_type_validator_test.dart b/test/features/main_screen/grid_data_type_validator_test.dart new file mode 100644 index 00000000..c4b9f544 --- /dev/null +++ b/test/features/main_screen/grid_data_type_validator_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/grid_data_type_validator.dart'; + +void main() { + group('GridDataTypeValidator', () { + test('validates integer types', () { + expect(GridDataTypeValidator.validate('123', dataTypeName: 'integer'), isNull); + expect(GridDataTypeValidator.validate('-456', dataTypeName: 'int4'), isNull); + expect(GridDataTypeValidator.validate('0', dataTypeName: 'bigint'), isNull); + expect(GridDataTypeValidator.validate('abc', dataTypeName: 'int'), isNotNull); + expect(GridDataTypeValidator.validate('12.34', dataTypeName: 'int'), isNotNull); + }); + + test('validates numeric / float / decimal types', () { + expect(GridDataTypeValidator.validate('123.45', dataTypeName: 'numeric'), isNull); + expect(GridDataTypeValidator.validate('-0.99', dataTypeName: 'float8'), isNull); + expect(GridDataTypeValidator.validate('100', dataTypeName: 'decimal'), isNull); + expect(GridDataTypeValidator.validate('xyz', dataTypeName: 'double'), isNotNull); + }); + + test('validates boolean types', () { + expect(GridDataTypeValidator.validate('true', dataTypeName: 'boolean'), isNull); + expect(GridDataTypeValidator.validate('false', dataTypeName: 'bool'), isNull); + expect(GridDataTypeValidator.validate('1', dataTypeName: 'bool'), isNull); + expect(GridDataTypeValidator.validate('0', dataTypeName: 'bool'), isNull); + expect(GridDataTypeValidator.validate('yes', dataTypeName: 'bool'), isNotNull); + }); + + test('validates UUID types', () { + expect( + GridDataTypeValidator.validate( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + dataTypeName: 'uuid', + ), + isNull, + ); + expect( + GridDataTypeValidator.validate('not-a-uuid', dataTypeName: 'uuid'), + isNotNull, + ); + }); + + test('validates JSON types', () { + expect( + GridDataTypeValidator.validate('{"key": "value"}', dataTypeName: 'json'), + isNull, + ); + expect( + GridDataTypeValidator.validate('[1, 2, 3]', dataTypeName: 'jsonb'), + isNull, + ); + expect( + GridDataTypeValidator.validate('{invalid-json}', dataTypeName: 'json'), + isNotNull, + ); + expect( + GridDataTypeValidator.validate('plain text', dataTypeName: 'json'), + isNotNull, + ); + }); + + test('validates date and timestamp types', () { + expect(GridDataTypeValidator.validate('2026-08-25', dataTypeName: 'date'), isNull); + expect(GridDataTypeValidator.validate('25-08-2026', dataTypeName: 'date'), isNotNull); + expect( + GridDataTypeValidator.validate( + '2026-08-25 14:30:00', + dataTypeName: 'timestamp', + ), + isNull, + ); + expect( + GridDataTypeValidator.validate('invalid date', dataTypeName: 'timestamp'), + isNotNull, + ); + }); + + test('allows empty and NULL values regardless of type', () { + expect(GridDataTypeValidator.validate('', dataTypeName: 'int'), isNull); + expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'uuid'), isNull); + expect(GridDataTypeValidator.validate('null', dataTypeName: 'json'), isNull); + }); + }); +} From f7e3d938cf5c8cdfa4945ae99e59f3e55dbf4f6a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:24:27 +0300 Subject: [PATCH 27/40] feat(filter): add multi-clause query parser with AND, OR, NOT, and parentheses (#568) Closes #568 --- .../main_screen/data_grid_filter_bar.dart | 128 +++++ .../main_screen/grid_filter_engine.dart | 444 ++++++++++++++++++ .../main_screen/data_grid_engines_test.dart | 136 ++++++ 3 files changed, 708 insertions(+) create mode 100644 lib/features/main_screen/data_grid_filter_bar.dart create mode 100644 lib/features/main_screen/grid_filter_engine.dart create mode 100644 test/features/main_screen/data_grid_engines_test.dart diff --git a/lib/features/main_screen/data_grid_filter_bar.dart b/lib/features/main_screen/data_grid_filter_bar.dart new file mode 100644 index 00000000..34921e3f --- /dev/null +++ b/lib/features/main_screen/data_grid_filter_bar.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Quick Filter Bar for Data Grid. +/// Allows live client-side row filtering without re-running SQL queries. +class DataGridFilterBar extends material.StatefulWidget { + const DataGridFilterBar({ + super.key, + required this.filterText, + required this.onFilterChanged, + required this.totalRowCount, + required this.filteredRowCount, + this.columns = const [], + }); + + final String filterText; + final ValueChanged onFilterChanged; + final int totalRowCount; + final int filteredRowCount; + final List columns; + + @override + material.State createState() => _DataGridFilterBarState(); +} + +class _DataGridFilterBarState extends material.State { + late final material.TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.filterText); + } + + @override + void didUpdateWidget(covariant DataGridFilterBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.filterText != widget.filterText && + _controller.text != widget.filterText) { + _controller.text = widget.filterText; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isFiltered = widget.filterText.trim().isNotEmpty; + + return material.Container( + height: 34, + padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.filter_alt_outlined, + size: 15, + color: isFiltered ? cs.primary : cs.mutedForeground, + ), + const Gap(6), + material.Expanded( + child: material.TextField( + controller: _controller, + onChanged: widget.onFilterChanged, + style: TextStyle( + fontSize: 12, + color: cs.foreground, + ), + decoration: material.InputDecoration( + hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', + hintStyle: TextStyle( + fontSize: 12, + color: cs.mutedForeground.withValues(alpha: 0.7), + ), + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, + ), + ), + ), + if (isFiltered) ...[ + const Gap(6), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + '${widget.filteredRowCount} / ${widget.totalRowCount}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + const Gap(4), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), + color: cs.mutedForeground, + onPressed: () { + _controller.clear(); + widget.onFilterChanged(''); + }, + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/main_screen/grid_filter_engine.dart b/lib/features/main_screen/grid_filter_engine.dart new file mode 100644 index 00000000..21af0a69 --- /dev/null +++ b/lib/features/main_screen/grid_filter_engine.dart @@ -0,0 +1,444 @@ +/// Client-side filter engine for Data Grid. +/// +/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses, +/// column predicates (`col = val`, `col > 10`), and free-text substring search. +abstract final class GridFilterEngine { + /// Evaluates [filterText] against [rows] with respect to [columns]. + /// Returns the list of matching row indices. + static List filterRowIndices({ + required String filterText, + required List columns, + required List> rows, + }) { + final trimmed = filterText.trim(); + if (trimmed.isEmpty || columns.isEmpty || rows.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final lowerColumns = columns.map((c) => c.toLowerCase()).toList(); + + try { + final tokens = _FilterLexer.tokenize(trimmed, lowerColumns); + if (tokens.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final parser = _FilterParser(tokens); + final ast = parser.parse(); + + if (ast == null) { + return _fallbackSubstringFilter(trimmed, rows); + } + + final matchingIndices = []; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + if (ast.evaluate(row, lowerColumns)) { + matchingIndices.add(r); + } + } + return matchingIndices; + } catch (_) { + // Graceful fallback to multi-term substring match if syntax has parse errors + return _fallbackSubstringFilter(trimmed, rows); + } + } + + static List _fallbackSubstringFilter(String input, List> rows) { + final terms = input.toLowerCase().split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + if (terms.isEmpty) { + return List.generate(rows.length, (i) => i); + } + + final result = []; + for (var r = 0; r < rows.length; r++) { + final row = rows[r]; + var matchAll = true; + for (final term in terms) { + var termMatch = false; + for (var c = 0; c < row.length; c++) { + if (row[c].toLowerCase().contains(term)) { + termMatch = true; + break; + } + } + if (!termMatch) { + matchAll = false; + break; + } + } + if (matchAll) { + result.add(r); + } + } + return result; + } +} + +// ----------------------------------------------------------------------------- +// AST Nodes +// ----------------------------------------------------------------------------- + +abstract class _FilterAstNode { + const _FilterAstNode(); + bool evaluate(List row, List lowerColumns); +} + +class _AndNode extends _FilterAstNode { + const _AndNode(this.left, this.right); + final _FilterAstNode left; + final _FilterAstNode right; + + @override + bool evaluate(List row, List lowerColumns) { + return left.evaluate(row, lowerColumns) && right.evaluate(row, lowerColumns); + } +} + +class _OrNode extends _FilterAstNode { + const _OrNode(this.left, this.right); + final _FilterAstNode left; + final _FilterAstNode right; + + @override + bool evaluate(List row, List lowerColumns) { + return left.evaluate(row, lowerColumns) || right.evaluate(row, lowerColumns); + } +} + +class _NotNode extends _FilterAstNode { + const _NotNode(this.child); + final _FilterAstNode child; + + @override + bool evaluate(List row, List lowerColumns) { + return !child.evaluate(row, lowerColumns); + } +} + +class _PredicateNode extends _FilterAstNode { + const _PredicateNode({ + required this.colIndex, + required this.op, + required this.targetValue, + }); + + final int colIndex; + final String op; + final String targetValue; + + @override + bool evaluate(List row, List lowerColumns) { + if (colIndex < 0 || colIndex >= row.length) return false; + final cellValue = row[colIndex]; + final lowerCell = cellValue.toLowerCase(); + final lowerTarget = targetValue.toLowerCase(); + + // Numeric comparison if both values can be parsed as numbers + final numCell = double.tryParse(cellValue.trim()); + final numTarget = double.tryParse(targetValue.trim()); + + if (numCell != null && numTarget != null) { + switch (op) { + case '=': + case '==': + case ':': + return (numCell - numTarget).abs() < 1e-9; + case '!=': + case '<>': + return (numCell - numTarget).abs() >= 1e-9; + case '>': + return numCell > numTarget; + case '>=': + return numCell >= numTarget; + case '<': + return numCell < numTarget; + case '<=': + return numCell <= numTarget; + } + } + + // String / Lexicographic comparison + switch (op) { + case '=': + case '==': + return lowerCell == lowerTarget; + case ':': + return lowerCell.contains(lowerTarget); + case '!=': + case '<>': + return lowerCell != lowerTarget; + case '>': + return lowerCell.compareTo(lowerTarget) > 0; + case '>=': + return lowerCell.compareTo(lowerTarget) >= 0; + case '<': + return lowerCell.compareTo(lowerTarget) < 0; + case '<=': + return lowerCell.compareTo(lowerTarget) <= 0; + default: + return lowerCell.contains(lowerTarget); + } + } +} + +class _FreeTextNode extends _FilterAstNode { + const _FreeTextNode(this.term); + final String term; + + @override + bool evaluate(List row, List lowerColumns) { + final lowerTerm = term.toLowerCase(); + for (var c = 0; c < row.length; c++) { + if (row[c].toLowerCase().contains(lowerTerm)) { + return true; + } + } + return false; + } +} + +// ----------------------------------------------------------------------------- +// Lexer +// ----------------------------------------------------------------------------- + +enum _TokenType { + and, + or, + not, + lparen, + rparen, + predicate, + text, +} + +class _FilterToken { + const _FilterToken(this.type, {this.value = '', this.predicate}); + final _TokenType type; + final String value; + final _PredicateNode? predicate; +} + +abstract final class _FilterLexer { + static List<_FilterToken> tokenize(String input, List lowerColumns) { + final tokens = <_FilterToken>[]; + var i = 0; + + while (i < input.length) { + // Skip whitespace + if (input[i].trim().isEmpty) { + i++; + continue; + } + + // Parentheses + if (input[i] == '(') { + tokens.add(const _FilterToken(_TokenType.lparen, value: '(')); + i++; + continue; + } + if (input[i] == ')') { + tokens.add(const _FilterToken(_TokenType.rparen, value: ')')); + i++; + continue; + } + + // Read next chunk/word until whitespace or parenthesis + final start = i; + while (i < input.length && + input[i].trim().isNotEmpty && + input[i] != '(' && + input[i] != ')') { + // Handle quoted literals inside words + if (input[i] == '\'' || input[i] == '"') { + final quote = input[i]; + i++; + while (i < input.length && input[i] != quote) { + if (input[i] == '\\' && i + 1 < input.length) { + i += 2; + } else { + i++; + } + } + if (i < input.length) i++; // consume closing quote + } else { + i++; + } + } + + var word = input.substring(start, i).trim(); + if (word.isEmpty) continue; + + // Check logical operators + final upper = word.toUpperCase(); + if (upper == 'AND' || word == '&&') { + tokens.add(const _FilterToken(_TokenType.and, value: 'AND')); + continue; + } + if (upper == 'OR' || word == '||') { + tokens.add(const _FilterToken(_TokenType.or, value: 'OR')); + continue; + } + if (upper == 'NOT' || word == '!') { + tokens.add(const _FilterToken(_TokenType.not, value: 'NOT')); + continue; + } + + // Check if this token or upcoming sequence forms a predicate: col OP val + final predicate = _tryExtractPredicate(word, lowerColumns); + if (predicate != null) { + tokens.add(_FilterToken(_TokenType.predicate, predicate: predicate)); + continue; + } + + // If word is just a column name and the NEXT word is an operator (e.g. "amount", ">", "100") + // or "status", "=", "'ACTIVE'" + final colIdx = lowerColumns.indexOf(word.toLowerCase()); + if (colIdx != -1) { + // Peek ahead for operator and value + final remaining = input.substring(i).trimLeft(); + final opMatch = RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)\s*([^\s()]+)') + .firstMatch(remaining); + if (opMatch != null) { + final op = opMatch.group(1)!; + var val = opMatch.group(2)!; + val = _stripQuotes(val); + tokens.add( + _FilterToken( + _TokenType.predicate, + predicate: _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: val, + ), + ), + ); + i += input.substring(i).indexOf(opMatch.group(0)!) + + opMatch.group(0)!.length; + continue; + } + } + + // Strip quotes from free text if present + word = _stripQuotes(word); + tokens.add(_FilterToken(_TokenType.text, value: word)); + } + + return tokens; + } + + static _PredicateNode? _tryExtractPredicate( + String token, + List lowerColumns, + ) { + const ops = ['>=', '<=', '!=', '<>', '==', '=', '>', '<', ':']; + for (final op in ops) { + final parts = token.split(op); + if (parts.length == 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + final colCandidate = parts[0].trim().toLowerCase(); + final colIdx = lowerColumns.indexOf(colCandidate); + if (colIdx != -1) { + final val = _stripQuotes(parts[1].trim()); + return _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: val, + ); + } + } + } + return null; + } + + static String _stripQuotes(String s) { + if ((s.startsWith("'") && s.endsWith("'")) || + (s.startsWith('"') && s.endsWith('"'))) { + if (s.length >= 2) { + return s.substring(1, s.length - 1).replaceAll(r"\'", "'").replaceAll(r'\"', '"'); + } + } + return s; + } +} + +// ----------------------------------------------------------------------------- +// Parser +// ----------------------------------------------------------------------------- + +class _FilterParser { + _FilterParser(this.tokens); + final List<_FilterToken> tokens; + int _pos = 0; + + _FilterAstNode? parse() { + if (tokens.isEmpty) return null; + return _parseOr(); + } + + _FilterAstNode _parseOr() { + var node = _parseAnd(); + while (_match(_TokenType.or)) { + final right = _parseAnd(); + node = _OrNode(node, right); + } + return node; + } + + _FilterAstNode _parseAnd() { + var node = _parseUnary(); + while (_match(_TokenType.and) || _isImplicitAnd()) { + final right = _parseUnary(); + node = _AndNode(node, right); + } + return node; + } + + bool _isImplicitAnd() { + if (_pos >= tokens.length) return false; + final type = tokens[_pos].type; + // Two terms or predicates in a row without explicit OR are treated as implicit AND + return type == _TokenType.predicate || + type == _TokenType.text || + type == _TokenType.lparen || + type == _TokenType.not; + } + + _FilterAstNode _parseUnary() { + if (_match(_TokenType.not)) { + return _NotNode(_parseUnary()); + } + return _parsePrimary(); + } + + _FilterAstNode _parsePrimary() { + if (_match(_TokenType.lparen)) { + final node = _parseOr(); + _consume(_TokenType.rparen); + return node; + } + + if (_pos < tokens.length) { + final token = tokens[_pos++]; + if (token.type == _TokenType.predicate && token.predicate != null) { + return token.predicate!; + } + return _FreeTextNode(token.value); + } + + return const _FreeTextNode(''); + } + + bool _match(_TokenType type) { + if (_pos < tokens.length && tokens[_pos].type == type) { + _pos++; + return true; + } + return false; + } + + void _consume(_TokenType type) { + if (_pos < tokens.length && tokens[_pos].type == type) { + _pos++; + } + } +} diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart new file mode 100644 index 00000000..e0d324ba --- /dev/null +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; + +void main() { + group('GridFilterEngine', () { + final columns = ['id', 'status', 'amount']; + final rows = [ + ['1', 'ACTIVE', '150.0'], + ['2', 'PENDING', '50.0'], + ['3', 'ACTIVE', '300.0'], + ['4', 'CANCELLED', '0.0'], + ]; + + test('returns all rows when filter is empty', () { + final res = GridFilterEngine.filterRowIndices( + filterText: '', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 1, 2, 3])); + }); + + test('filters by free text substring search', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'active', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 2])); + }); + + test('filters by predicate column = value', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'status = PENDING', + columns: columns, + rows: rows, + ); + expect(res, equals([1])); + }); + + test('filters by numeric predicate amount > 100', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'amount > 100', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 2])); + }); + + test('filters with AND conjunction', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "status = 'ACTIVE' AND amount > 200", + columns: columns, + rows: rows, + ); + expect(res, equals([2])); + }); + + test('filters with OR disjunction', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "status = 'PENDING' OR status = 'CANCELLED'", + columns: columns, + rows: rows, + ); + expect(res, equals([1, 3])); + }); + + test('filters with NOT negation and parentheses', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "NOT (status = 'ACTIVE') AND amount >= 50", + columns: columns, + rows: rows, + ); + expect(res, equals([1])); + }); + + test('handles combined complex nested logic', () { + final res = GridFilterEngine.filterRowIndices( + filterText: "(status = 'ACTIVE' OR status = 'PENDING') AND (amount < 200)", + columns: columns, + rows: rows, + ); + expect(res, equals([0, 1])); + }); + }); + + group('GridSelectionCalcEngine', () { + test('computes correct stats for numeric values', () { + final stats = GridSelectionCalcEngine.compute(['10', '20', '30', '40']); + expect(stats.totalCount, equals(4)); + expect(stats.numericCount, equals(4)); + expect(stats.nullCount, equals(0)); + expect(stats.sum, equals(100.0)); + expect(stats.average, equals(25.0)); + expect(stats.min, equals(10.0)); + expect(stats.max, equals(40.0)); + }); + + test('handles NULLs and mixed string data', () { + final stats = GridSelectionCalcEngine.compute(['10', 'NULL', 'text', '50.5']); + expect(stats.totalCount, equals(4)); + expect(stats.numericCount, equals(2)); + expect(stats.nullCount, equals(1)); + expect(stats.sum, equals(60.5)); + expect(stats.average, equals(30.25)); + expect(stats.min, equals(10.0)); + expect(stats.max, equals(50.5)); + }); + }); + + group('GridGroupingsEngine', () { + final rows = [ + ['1', 'ACTIVE'], + ['2', 'PENDING'], + ['3', 'ACTIVE'], + ['4', 'ACTIVE'], + ['5', 'CANCELLED'], + ]; + + test('groups rows by column index and calculates percentages', () { + final groups = GridGroupingsEngine.buildGroups( + colIndex: 1, + rows: rows, + ); + + expect(groups.length, equals(3)); + expect(groups[0].groupKey, equals('ACTIVE')); + expect(groups[0].count, equals(3)); + expect(groups[0].percentage, closeTo(60.0, 0.1)); + + expect(groups[1].count, equals(1)); + }); + }); +} From 40956a8307355a07d89fbae482cb265a081e62c8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:26:07 +0300 Subject: [PATCH 28/40] feat(filter): support LIKE, ILIKE, IN, IS NULL, BETWEEN, and escaped quotes in filter engine (#569) Closes #569 --- .../main_screen/grid_filter_engine.dart | 207 +++++++++++++++++- .../main_screen/data_grid_engines_test.dart | 75 +++++++ 2 files changed, 272 insertions(+), 10 deletions(-) diff --git a/lib/features/main_screen/grid_filter_engine.dart b/lib/features/main_screen/grid_filter_engine.dart index 21af0a69..5860f111 100644 --- a/lib/features/main_screen/grid_filter_engine.dart +++ b/lib/features/main_screen/grid_filter_engine.dart @@ -1,7 +1,8 @@ /// Client-side filter engine for Data Grid. /// /// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses, -/// column predicates (`col = val`, `col > 10`), and free-text substring search. +/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`, +/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search. abstract final class GridFilterEngine { /// Evaluates [filterText] against [rows] with respect to [columns]. /// Returns the list of matching row indices. @@ -121,16 +122,74 @@ class _PredicateNode extends _FilterAstNode { required this.colIndex, required this.op, required this.targetValue, + this.inValues = const [], + this.betweenMin, + this.betweenMax, }); final int colIndex; final String op; final String targetValue; + final List inValues; + final String? betweenMin; + final String? betweenMax; @override bool evaluate(List row, List lowerColumns) { if (colIndex < 0 || colIndex >= row.length) return false; final cellValue = row[colIndex]; + final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; + final upperOp = op.toUpperCase().trim(); + + // IS NULL / IS NOT NULL + if (upperOp == 'IS NULL') { + return isNull; + } + if (upperOp == 'IS NOT NULL') { + return !isNull; + } + + // IN / NOT IN + if (upperOp == 'IN') { + final lowerCell = cellValue.toLowerCase().trim(); + return inValues.any((v) => v.toLowerCase().trim() == lowerCell); + } + if (upperOp == 'NOT IN') { + final lowerCell = cellValue.toLowerCase().trim(); + return !inValues.any((v) => v.toLowerCase().trim() == lowerCell); + } + + // BETWEEN x AND y + if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) { + final numCell = double.tryParse(cellValue.trim()); + final numMin = double.tryParse(betweenMin!.trim()); + final numMax = double.tryParse(betweenMax!.trim()); + if (numCell != null && numMin != null && numMax != null) { + return numCell >= numMin && numCell <= numMax; + } + return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0; + } + + // LIKE / NOT LIKE + if (upperOp == 'LIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: true); + return regex.hasMatch(cellValue); + } + if (upperOp == 'NOT LIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: true); + return !regex.hasMatch(cellValue); + } + + // ILIKE / NOT ILIKE + if (upperOp == 'ILIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: false); + return regex.hasMatch(cellValue); + } + if (upperOp == 'NOT ILIKE') { + final regex = _likeToRegExp(targetValue, caseSensitive: false); + return !regex.hasMatch(cellValue); + } + final lowerCell = cellValue.toLowerCase(); final lowerTarget = targetValue.toLowerCase(); @@ -180,6 +239,22 @@ class _PredicateNode extends _FilterAstNode { return lowerCell.contains(lowerTarget); } } + + static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) { + final buffer = StringBuffer('^'); + for (var i = 0; i < pattern.length; i++) { + final char = pattern[i]; + if (char == '%') { + buffer.write('.*'); + } else if (char == '_') { + buffer.write('.'); + } else { + buffer.write(RegExp.escape(char)); + } + } + buffer.write(r'$'); + return RegExp(buffer.toString(), caseSensitive: caseSensitive); + } } class _FreeTextNode extends _FilterAstNode { @@ -231,6 +306,15 @@ abstract final class _FilterLexer { continue; } + // Check for extended predicates with keywords (IS NULL, IS NOT NULL, LIKE, ILIKE, IN, BETWEEN) + final remaining = input.substring(i); + final kwPredicate = _tryMatchKeywordPredicate(remaining, lowerColumns); + if (kwPredicate != null) { + tokens.add(_FilterToken(_TokenType.predicate, predicate: kwPredicate.node)); + i += kwPredicate.consumedChars; + continue; + } + // Parentheses if (input[i] == '(') { tokens.add(const _FilterToken(_TokenType.lparen, value: '(')); @@ -253,14 +337,21 @@ abstract final class _FilterLexer { if (input[i] == '\'' || input[i] == '"') { final quote = input[i]; i++; - while (i < input.length && input[i] != quote) { + while (i < input.length) { if (input[i] == '\\' && i + 1 < input.length) { i += 2; + } else if (input[i] == quote) { + if (i + 1 < input.length && input[i + 1] == quote) { + // SQL-style doubled quote escape: '' + i += 2; + } else { + i++; // closing quote + break; + } } else { i++; } } - if (i < input.length) i++; // consume closing quote } else { i++; } @@ -292,13 +383,11 @@ abstract final class _FilterLexer { } // If word is just a column name and the NEXT word is an operator (e.g. "amount", ">", "100") - // or "status", "=", "'ACTIVE'" final colIdx = lowerColumns.indexOf(word.toLowerCase()); if (colIdx != -1) { - // Peek ahead for operator and value - final remaining = input.substring(i).trimLeft(); + final rem = input.substring(i).trimLeft(); final opMatch = RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)\s*([^\s()]+)') - .firstMatch(remaining); + .firstMatch(rem); if (opMatch != null) { final op = opMatch.group(1)!; var val = opMatch.group(2)!; @@ -319,7 +408,6 @@ abstract final class _FilterLexer { } } - // Strip quotes from free text if present word = _stripQuotes(word); tokens.add(_FilterToken(_TokenType.text, value: word)); } @@ -327,6 +415,102 @@ abstract final class _FilterLexer { return tokens; } + static ({_PredicateNode node, int consumedChars})? _tryMatchKeywordPredicate( + String remaining, + List lowerColumns, + ) { + // 1. IS NULL / IS NOT NULL (e.g. "status IS NULL", "email IS NOT NULL") + final isNullMatch = RegExp(r'^([a-zA-Z_]\w*)\s+IS\s+(NOT\s+)?NULL\b', caseSensitive: false) + .firstMatch(remaining); + if (isNullMatch != null) { + final colName = isNullMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = isNullMatch.group(2) != null; + return ( + node: _PredicateNode( + colIndex: colIdx, + op: isNot ? 'IS NOT NULL' : 'IS NULL', + targetValue: '', + ), + consumedChars: isNullMatch.group(0)!.length, + ); + } + } + + // 2. IN / NOT IN (e.g. "status IN ('ACTIVE', 'PENDING')", "id NOT IN (1, 2, 3)") + final inMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?IN\s*\(([^)]+)\)', caseSensitive: false) + .firstMatch(remaining); + if (inMatch != null) { + final colName = inMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = inMatch.group(2) != null; + final listStr = inMatch.group(3)!; + final items = listStr + .split(',') + .map((s) => _stripQuotes(s.trim())) + .where((s) => s.isNotEmpty) + .toList(); + return ( + node: _PredicateNode( + colIndex: colIdx, + op: isNot ? 'NOT IN' : 'IN', + targetValue: '', + inValues: items, + ), + consumedChars: inMatch.group(0)!.length, + ); + } + } + + // 3. BETWEEN x AND y (e.g. "amount BETWEEN 10 AND 100") + final betweenMatch = RegExp(r'^([a-zA-Z_]\w*)\s+BETWEEN\s+([^\s]+)\s+AND\s+([^\s()]+)', caseSensitive: false) + .firstMatch(remaining); + if (betweenMatch != null) { + final colName = betweenMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final minVal = _stripQuotes(betweenMatch.group(2)!.trim()); + final maxVal = _stripQuotes(betweenMatch.group(3)!.trim()); + return ( + node: _PredicateNode( + colIndex: colIdx, + op: 'BETWEEN', + targetValue: '', + betweenMin: minVal, + betweenMax: maxVal, + ), + consumedChars: betweenMatch.group(0)!.length, + ); + } + } + + // 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE (e.g. "name LIKE '%John%'", "email ILIKE '%.org'") + final likeMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?(ILIKE|LIKE)\s+([^\s()]+)', caseSensitive: false) + .firstMatch(remaining); + if (likeMatch != null) { + final colName = likeMatch.group(1)!.toLowerCase(); + final colIdx = lowerColumns.indexOf(colName); + if (colIdx != -1) { + final isNot = likeMatch.group(2) != null; + final likeType = likeMatch.group(3)!.toUpperCase(); + final pattern = _stripQuotes(likeMatch.group(4)!.trim()); + final op = isNot ? 'NOT $likeType' : likeType; + return ( + node: _PredicateNode( + colIndex: colIdx, + op: op, + targetValue: pattern, + ), + consumedChars: likeMatch.group(0)!.length, + ); + } + } + + return null; + } + static _PredicateNode? _tryExtractPredicate( String token, List lowerColumns, @@ -354,7 +538,11 @@ abstract final class _FilterLexer { if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) { if (s.length >= 2) { - return s.substring(1, s.length - 1).replaceAll(r"\'", "'").replaceAll(r'\"', '"'); + return s + .substring(1, s.length - 1) + .replaceAll("''", "'") + .replaceAll(r"\'", "'") + .replaceAll(r'\"', '"'); } } return s; @@ -396,7 +584,6 @@ class _FilterParser { bool _isImplicitAnd() { if (_pos >= tokens.length) return false; final type = tokens[_pos].type; - // Two terms or predicates in a row without explicit OR are treated as implicit AND return type == _TokenType.predicate || type == _TokenType.text || type == _TokenType.lparen || diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart index e0d324ba..15227848 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -84,6 +84,81 @@ void main() { ); expect(res, equals([0, 1])); }); + + test('filters with LIKE and ILIKE wildcards', () { + final resLike = GridFilterEngine.filterRowIndices( + filterText: "status LIKE 'ACT%'", + columns: columns, + rows: rows, + ); + expect(resLike, equals([0, 2])); + + final resIlike = GridFilterEngine.filterRowIndices( + filterText: "status ILIKE '%pend%'", + columns: columns, + rows: rows, + ); + expect(resIlike, equals([1])); + }); + + test('filters with IN and NOT IN list of literals', () { + final resIn = GridFilterEngine.filterRowIndices( + filterText: "status IN ('PENDING', 'CANCELLED')", + columns: columns, + rows: rows, + ); + expect(resIn, equals([1, 3])); + + final resNotIn = GridFilterEngine.filterRowIndices( + filterText: "status NOT IN ('ACTIVE')", + columns: columns, + rows: rows, + ); + expect(resNotIn, equals([1, 3])); + }); + + test('filters with IS NULL and IS NOT NULL', () { + final rowsWithNull = [ + ['1', 'ACTIVE', '100'], + ['2', 'NULL', '200'], + ['3', '', '300'], + ]; + final resNull = GridFilterEngine.filterRowIndices( + filterText: 'status IS NULL', + columns: columns, + rows: rowsWithNull, + ); + expect(resNull, equals([1, 2])); + + final resNotNull = GridFilterEngine.filterRowIndices( + filterText: 'status IS NOT NULL', + columns: columns, + rows: rowsWithNull, + ); + expect(resNotNull, equals([0])); + }); + + test('filters with BETWEEN range', () { + final res = GridFilterEngine.filterRowIndices( + filterText: 'amount BETWEEN 50 AND 200', + columns: columns, + rows: rows, + ); + expect(res, equals([0, 1])); + }); + + test('handles escaped quotes inside string literals', () { + final rowsWithQuotes = [ + ['1', "O'Connor", '100'], + ['2', 'Smith', '200'], + ]; + final res = GridFilterEngine.filterRowIndices( + filterText: "status = 'O''Connor'", + columns: columns, + rows: rowsWithQuotes, + ); + expect(res, equals([0])); + }); }); group('GridSelectionCalcEngine', () { From 3e823eaa49ac62d382d8464ca5ae0587b257ddbf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:27:24 +0300 Subject: [PATCH 29/40] feat(filter): add autocomplete and syntax suggestions popup to filter bar (#570) Closes #570 --- .../main_screen/data_grid_filter_bar.dart | 383 +++++++++++++++--- .../data_grid_filter_bar_test.dart | 75 ++++ 2 files changed, 396 insertions(+), 62 deletions(-) create mode 100644 test/features/main_screen/data_grid_filter_bar_test.dart diff --git a/lib/features/main_screen/data_grid_filter_bar.dart b/lib/features/main_screen/data_grid_filter_bar.dart index 34921e3f..96da7aa0 100644 --- a/lib/features/main_screen/data_grid_filter_bar.dart +++ b/lib/features/main_screen/data_grid_filter_bar.dart @@ -1,8 +1,115 @@ import 'package:flutter/material.dart' as material; import 'package:shadcn_flutter/shadcn_flutter.dart'; +/// Kind of filter autocomplete suggestion. +enum FilterSuggestionKind { + column, + operator, + keyword, +} + +/// Autocomplete suggestion model for the Data Grid filter bar. +class FilterSuggestion { + const FilterSuggestion({ + required this.text, + required this.kind, + this.description = '', + this.insertText, + }); + + final String text; + final FilterSuggestionKind kind; + final String description; + final String? insertText; +} + +/// Helper to compute context-aware syntax suggestions for the filter bar. +abstract final class FilterSuggestionEngine { + static const _operators = [ + FilterSuggestion(text: '=', kind: FilterSuggestionKind.operator, description: 'Equal'), + FilterSuggestion(text: '!=', kind: FilterSuggestionKind.operator, description: 'Not equal'), + FilterSuggestion(text: '>', kind: FilterSuggestionKind.operator, description: 'Greater than'), + FilterSuggestion(text: '>=', kind: FilterSuggestionKind.operator, description: 'Greater or equal'), + FilterSuggestion(text: '<', kind: FilterSuggestionKind.operator, description: 'Less than'), + FilterSuggestion(text: '<=', kind: FilterSuggestionKind.operator, description: 'Less or equal'), + FilterSuggestion(text: 'LIKE', kind: FilterSuggestionKind.operator, description: 'Wildcard match (%_)'), + FilterSuggestion(text: 'ILIKE', kind: FilterSuggestionKind.operator, description: 'Case-insensitive match'), + FilterSuggestion(text: 'IN (...)', kind: FilterSuggestionKind.operator, description: 'List inclusion', insertText: "IN ('')"), + FilterSuggestion(text: 'IS NULL', kind: FilterSuggestionKind.operator, description: 'Null check'), + FilterSuggestion(text: 'IS NOT NULL', kind: FilterSuggestionKind.operator, description: 'Not null check'), + FilterSuggestion(text: 'BETWEEN', kind: FilterSuggestionKind.operator, description: 'Range check', insertText: 'BETWEEN AND '), + ]; + + static const _keywords = [ + FilterSuggestion(text: 'AND', kind: FilterSuggestionKind.keyword, description: 'Logical AND'), + FilterSuggestion(text: 'OR', kind: FilterSuggestionKind.keyword, description: 'Logical OR'), + FilterSuggestion(text: 'NOT', kind: FilterSuggestionKind.keyword, description: 'Logical NOT'), + ]; + + /// Computes suggestions based on current [text] and available [columns]. + static List getSuggestions({ + required String text, + required List columns, + }) { + final trimmed = text.trim(); + if (trimmed.isEmpty) { + return [ + for (final col in columns) + FilterSuggestion( + text: col, + kind: FilterSuggestionKind.column, + description: 'Column', + ), + ]; + } + + final tokens = trimmed.split(RegExp(r'\s+')); + final lastToken = tokens.last; + + // Check if previous token was a column name + if (tokens.length >= 2) { + final prevToken = tokens[tokens.length - 2].toLowerCase(); + final isPrevCol = columns.any((c) => c.toLowerCase() == prevToken); + if (isPrevCol) { + final matches = _operators + .where((op) => op.text.toLowerCase().startsWith(lastToken.toLowerCase())) + .toList(); + if (matches.isNotEmpty) return matches; + } + } + + // If only one token and matches a known column exactly, suggest operators + final exactCol = columns.firstWhere( + (c) => c.toLowerCase() == lastToken.toLowerCase(), + orElse: () => '', + ); + if (exactCol.isNotEmpty) { + return _operators; + } + + // Partial column name match + final matchingCols = columns + .where((c) => c.toLowerCase().startsWith(lastToken.toLowerCase())) + .map( + (c) => FilterSuggestion( + text: c, + kind: FilterSuggestionKind.column, + description: 'Column', + ), + ) + .toList(); + + // Partial keyword match (AND, OR, NOT) + final matchingKw = _keywords + .where((k) => k.text.toLowerCase().startsWith(lastToken.toLowerCase())) + .toList(); + + return [...matchingCols, ...matchingKw]; + } +} + /// Quick Filter Bar for Data Grid. -/// Allows live client-side row filtering without re-running SQL queries. +/// Allows live client-side row filtering with context-aware autocomplete suggestions. class DataGridFilterBar extends material.StatefulWidget { const DataGridFilterBar({ super.key, @@ -25,6 +132,10 @@ class DataGridFilterBar extends material.StatefulWidget { class _DataGridFilterBarState extends material.State { late final material.TextEditingController _controller; + final _layerLink = material.LayerLink(); + material.OverlayEntry? _overlayEntry; + List _suggestions = []; + int _highlightedIndex = 0; @override void initState() { @@ -43,85 +154,233 @@ class _DataGridFilterBarState extends material.State { @override void dispose() { + _hideSuggestions(); _controller.dispose(); super.dispose(); } + void _onChanged(String val) { + widget.onFilterChanged(val); + _updateSuggestions(val); + } + + void _updateSuggestions(String val) { + final suggs = FilterSuggestionEngine.getSuggestions( + text: val, + columns: widget.columns, + ); + + if (suggs.isEmpty || val.trim().isEmpty) { + _hideSuggestions(); + } else { + _suggestions = suggs; + _highlightedIndex = 0; + _showSuggestions(); + } + } + + void _showSuggestions() { + _hideSuggestions(); + final overlay = material.Overlay.maybeOf(context); + if (overlay == null) return; + + _overlayEntry = material.OverlayEntry( + builder: (context) { + final cs = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return material.Positioned( + width: 320, + child: material.CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const material.Offset(24, 32), + child: material.Material( + elevation: 4, + borderRadius: material.BorderRadius.circular(6), + color: isDark + ? const material.Color(0xFF1E1E22) + : const material.Color(0xFFFFFFFF), + child: material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.6), + ), + ), + constraints: const material.BoxConstraints(maxHeight: 200), + child: material.ListView.builder( + shrinkWrap: true, + padding: const material.EdgeInsets.symmetric(vertical: 4), + itemCount: _suggestions.length, + itemBuilder: (ctx, i) { + final s = _suggestions[i]; + final isHighlighted = i == _highlightedIndex; + return material.InkWell( + onTap: () => _applySuggestion(s), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + color: isHighlighted + ? cs.primary.withValues(alpha: 0.12) + : material.Colors.transparent, + child: material.Row( + children: [ + _buildSuggestionIcon(s.kind, cs), + const Gap(8), + Text(s.text).semiBold().small(), + const Spacer(), + if (s.description.isNotEmpty) + Text(s.description).muted().xSmall(), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); + }, + ); + + overlay.insert(_overlayEntry!); + } + + material.Widget _buildSuggestionIcon(FilterSuggestionKind kind, ColorScheme cs) { + switch (kind) { + case FilterSuggestionKind.column: + return material.Icon( + material.Icons.table_chart_outlined, + size: 13, + color: cs.primary, + ); + case FilterSuggestionKind.operator: + return material.Icon( + material.Icons.code_rounded, + size: 13, + color: material.Colors.amber.shade700, + ); + case FilterSuggestionKind.keyword: + return material.Icon( + material.Icons.vpn_key_outlined, + size: 13, + color: material.Colors.green.shade600, + ); + } + } + + void _hideSuggestions() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void _applySuggestion(FilterSuggestion s) { + final text = _controller.text; + final toInsert = s.insertText ?? s.text; + + final tokens = text.split(RegExp(r'\s+')); + if (tokens.isNotEmpty && s.kind == FilterSuggestionKind.column) { + tokens[tokens.length - 1] = toInsert; + final newText = '${tokens.join(' ')} '; + _controller.value = material.TextEditingValue( + text: newText, + selection: material.TextSelection.collapsed(offset: newText.length), + ); + _onChanged(newText); + } else { + final newText = '$text $toInsert '; + _controller.value = material.TextEditingValue( + text: newText, + selection: material.TextSelection.collapsed(offset: newText.length), + ); + _onChanged(newText); + } + _hideSuggestions(); + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final isFiltered = widget.filterText.trim().isNotEmpty; - return material.Container( - height: 34, - padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 3), - decoration: material.BoxDecoration( - color: cs.card, - border: material.Border( - bottom: material.BorderSide( - color: cs.border.withValues(alpha: 0.35), - width: 1, + return material.CompositedTransformTarget( + link: _layerLink, + child: material.Container( + height: 34, + padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), ), ), - ), - child: material.Row( - children: [ - material.Icon( - material.Icons.filter_alt_outlined, - size: 15, - color: isFiltered ? cs.primary : cs.mutedForeground, - ), - const Gap(6), - material.Expanded( - child: material.TextField( - controller: _controller, - onChanged: widget.onFilterChanged, - style: TextStyle( - fontSize: 12, - color: cs.foreground, - ), - decoration: material.InputDecoration( - hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', - hintStyle: TextStyle( - fontSize: 12, - color: cs.mutedForeground.withValues(alpha: 0.7), - ), - border: material.InputBorder.none, - isDense: true, - contentPadding: material.EdgeInsets.zero, - ), + child: material.Row( + children: [ + material.Icon( + material.Icons.filter_alt_outlined, + size: 15, + color: isFiltered ? cs.primary : cs.mutedForeground, ), - ), - if (isFiltered) ...[ const Gap(6), - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.15), - borderRadius: material.BorderRadius.circular(4), - ), - child: Text( - '${widget.filteredRowCount} / ${widget.totalRowCount}', + material.Expanded( + child: material.TextField( + controller: _controller, + onChanged: _onChanged, style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: cs.primary, + fontSize: 12, + color: cs.foreground, + ), + decoration: material.InputDecoration( + hintText: 'Filter results... (e.g. "active", "status = ACTIVE", "amount > 100")', + hintStyle: TextStyle( + fontSize: 12, + color: cs.mutedForeground.withValues(alpha: 0.7), + ), + border: material.InputBorder.none, + isDense: true, + contentPadding: material.EdgeInsets.zero, ), ), ), - const Gap(4), - material.IconButton( - icon: const material.Icon(material.Icons.close, size: 14), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), - color: cs.mutedForeground, - onPressed: () { - _controller.clear(); - widget.onFilterChanged(''); - }, - ), + if (isFiltered) ...[ + const Gap(6), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(4), + ), + child: Text( + '${widget.filteredRowCount} / ${widget.totalRowCount}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + const Gap(4), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 20, minHeight: 20), + color: cs.mutedForeground, + onPressed: () { + _controller.clear(); + _hideSuggestions(); + widget.onFilterChanged(''); + }, + ), + ], ], - ], + ), ), ); } diff --git a/test/features/main_screen/data_grid_filter_bar_test.dart b/test/features/main_screen/data_grid_filter_bar_test.dart new file mode 100644 index 00000000..400152e6 --- /dev/null +++ b/test/features/main_screen/data_grid_filter_bar_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_filter_bar.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('FilterSuggestionEngine', () { + final columns = ['id', 'status', 'amount', 'created_at']; + + test('returns column suggestions when text is empty', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: '', + columns: columns, + ); + expect(suggs.length, 4); + expect(suggs.map((s) => s.text), containsAll(['id', 'status', 'amount', 'created_at'])); + }); + + test('suggests matching columns on partial prefix', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: 'sta', + columns: columns, + ); + expect(suggs.length, 1); + expect(suggs.first.text, 'status'); + expect(suggs.first.kind, FilterSuggestionKind.column); + }); + + test('suggests operators after full column name', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: 'status', + columns: columns, + ); + expect(suggs.map((s) => s.text), containsAll(['=', '!=', 'LIKE', 'ILIKE', 'IN (...)', 'IS NULL'])); + }); + + test('suggests keywords on partial keyword prefix', () { + final suggs = FilterSuggestionEngine.getSuggestions( + text: 'status = ACTIVE AN', + columns: columns, + ); + expect(suggs.any((s) => s.text == 'AND'), isTrue); + }); + }); + + group('DataGridFilterBar Widget', () { + testWidgets('renders filter bar and updates text on input', (tester) async { + String currentFilter = ''; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridFilterBar( + filterText: currentFilter, + onFilterChanged: (text) => currentFilter = text, + totalRowCount: 100, + filteredRowCount: 20, + columns: const ['id', 'name', 'status'], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.TextField), findsOneWidget); + + await tester.enterText(find.byType(material.TextField), 'stat'); + await tester.pumpAndSettle(); + + expect(currentFilter, 'stat'); + expect(find.text('status'), findsOneWidget); + }); + }); +} From 0087ae4dc5c3830a84e235e089783b0aa02b5415 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:29:01 +0300 Subject: [PATCH 30/40] feat(value-panel): add syntax highlighting and XML/HTML formatter with validation (#571, #572) Closes #571 Closes #572 --- .../main_screen/data_grid_value_panel.dart | 396 ++++++++++++++++++ .../main_screen/xml_html_formatter.dart | 117 ++++++ .../data_grid_value_panel_test.dart | 72 ++++ 3 files changed, 585 insertions(+) create mode 100644 lib/features/main_screen/data_grid_value_panel.dart create mode 100644 lib/features/main_screen/xml_html_formatter.dart create mode 100644 test/features/main_screen/data_grid_value_panel_test.dart diff --git a/lib/features/main_screen/data_grid_value_panel.dart b/lib/features/main_screen/data_grid_value_panel.dart new file mode 100644 index 00000000..5b78d4fc --- /dev/null +++ b/lib/features/main_screen/data_grid_value_panel.dart @@ -0,0 +1,396 @@ +import 'dart:convert'; +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/editor/querya_code_editor.dart'; +import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'xml_html_formatter.dart'; + +/// Language mode for Value Panel inspector. +enum ValuePanelLanguage { + auto, + json, + xml, + sql, + text, +} + +/// Collapsible right-hand side panel for inspecting cell content in detail. +class DataGridValuePanel extends material.StatefulWidget { + const DataGridValuePanel({ + super.key, + required this.columnName, + required this.cellValue, + required this.rowIndex, + required this.onClose, + this.onUpdateValue, + }); + + final String columnName; + final String cellValue; + final int? rowIndex; + final material.VoidCallback onClose; + final ValueChanged? onUpdateValue; + + @override + material.State createState() => _DataGridValuePanelState(); +} + +class _DataGridValuePanelState extends material.State { + late final material.TextEditingController _controller; + ValuePanelLanguage _selectedLanguage = ValuePanelLanguage.auto; + String? _validationError; + bool _wordWrap = true; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: _formatInitialValue(widget.cellValue)); + _controller.addListener(_validateContent); + _validateContent(); + } + + @override + void didUpdateWidget(covariant DataGridValuePanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.cellValue != widget.cellValue) { + _controller.text = _formatInitialValue(widget.cellValue); + _validateContent(); + } + } + + @override + void dispose() { + _controller.removeListener(_validateContent); + _controller.dispose(); + super.dispose(); + } + + String _formatInitialValue(String input) { + final trimmed = input.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + final parsed = jsonDecode(trimmed); + return const JsonEncoder.withIndent(' ').convert(parsed); + } catch (_) {} + } else if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + try { + return XmlHtmlFormatter.format(trimmed); + } catch (_) {} + } + return input; + } + + ValuePanelLanguage get _effectiveLanguage { + if (_selectedLanguage != ValuePanelLanguage.auto) { + return _selectedLanguage; + } + final trimmed = _controller.text.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return ValuePanelLanguage.json; + } + if (trimmed.startsWith('<') && trimmed.endsWith('>')) { + return ValuePanelLanguage.xml; + } + final upper = trimmed.toUpperCase(); + if (upper.startsWith('SELECT ') || + upper.startsWith('INSERT ') || + upper.startsWith('UPDATE ') || + upper.startsWith('CREATE ') || + upper.startsWith('WITH ')) { + return ValuePanelLanguage.sql; + } + return ValuePanelLanguage.text; + } + + void _validateContent() { + final text = _controller.text.trim(); + if (text.isEmpty) { + if (_validationError != null) { + setState(() => _validationError = null); + } + return; + } + + final lang = _effectiveLanguage; + String? err; + + if (lang == ValuePanelLanguage.json) { + try { + jsonDecode(text); + } catch (e) { + err = 'Invalid JSON: $e'; + } + } else if (lang == ValuePanelLanguage.xml) { + err = XmlHtmlFormatter.validate(text); + } + + if (err != _validationError) { + setState(() => _validationError = err); + } + } + + void _formatCode() { + final lang = _effectiveLanguage; + if (lang == ValuePanelLanguage.json) { + try { + final parsed = jsonDecode(_controller.text); + final pretty = const JsonEncoder.withIndent(' ').convert(parsed); + setState(() => _controller.text = pretty); + } catch (_) {} + } else if (lang == ValuePanelLanguage.xml) { + final pretty = XmlHtmlFormatter.format(_controller.text); + setState(() => _controller.text = pretty); + } + } + + void _minifyCode() { + final lang = _effectiveLanguage; + if (lang == ValuePanelLanguage.json) { + try { + final parsed = jsonDecode(_controller.text); + final compact = jsonEncode(parsed); + setState(() => _controller.text = compact); + } catch (_) {} + } else if (lang == ValuePanelLanguage.xml) { + final compact = XmlHtmlFormatter.minify(_controller.text); + setState(() => _controller.text = compact); + } + } + + QueryaCodeLanguage _toQueryaLanguage(ValuePanelLanguage lang) { + switch (lang) { + case ValuePanelLanguage.json: + return QueryaCodeLanguage.json; + case ValuePanelLanguage.sql: + return QueryaCodeLanguage.sql; + default: + return QueryaCodeLanguage.plain; + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final activeLang = _effectiveLanguage; + + return material.Container( + width: 340, + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + left: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Panel Header + material.Container( + height: 36, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + material.Icons.data_object_rounded, + size: 15, + color: cs.primary, + ), + const Gap(6), + material.Expanded( + child: Text( + '${widget.columnName}${widget.rowIndex != null ? ' [Row ${widget.rowIndex! + 1}]' : ''}', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + ).small().semiBold(), + ), + material.IconButton( + icon: const material.Icon(material.Icons.close, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: widget.onClose, + ), + ], + ), + ), + + // Toolbar with language selector and actions + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cs.background.withValues(alpha: 0.4), + child: material.Row( + children: [ + // Language Dropdown / Pill + material.DropdownButton( + value: _selectedLanguage, + isDense: true, + underline: const material.SizedBox(), + icon: const material.Icon(material.Icons.arrow_drop_down, size: 16), + style: TextStyle( + fontSize: 11, + color: cs.foreground, + fontWeight: FontWeight.w600, + ), + items: const [ + material.DropdownMenuItem( + value: ValuePanelLanguage.auto, + child: Text('Auto'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.json, + child: Text('JSON'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.xml, + child: Text('XML/HTML'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.sql, + child: Text('SQL'), + ), + material.DropdownMenuItem( + value: ValuePanelLanguage.text, + child: Text('Plain Text'), + ), + ], + onChanged: (val) { + if (val != null) { + setState(() => _selectedLanguage = val); + _validateContent(); + } + }, + ), + const Gap(6), + if (activeLang == ValuePanelLanguage.json || activeLang == ValuePanelLanguage.xml) ...[ + material.TextButton( + onPressed: _formatCode, + style: material.TextButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: material.Size.zero, + tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Format').small(), + ), + const Gap(4), + material.TextButton( + onPressed: _minifyCode, + style: material.TextButton.styleFrom( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + minimumSize: material.Size.zero, + tapTargetSize: material.MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Minify').small(), + ), + ], + const material.Spacer(), + material.IconButton( + icon: material.Icon( + _wordWrap ? material.Icons.wrap_text : material.Icons.notes, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: _wordWrap ? cs.primary : cs.mutedForeground, + onPressed: () => setState(() => _wordWrap = !_wordWrap), + ), + material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () { + Clipboard.setData(ClipboardData(text: _controller.text)); + }, + ), + ], + ), + ), + + // Validation Error Banner (if any) + if (_validationError != null) + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cs.destructive.withValues(alpha: 0.12), + child: material.Row( + children: [ + material.Icon( + material.Icons.warning_amber_rounded, + size: 14, + color: cs.destructive, + ), + const Gap(6), + material.Expanded( + child: Text( + _validationError!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, + color: cs.destructive, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + + // Code Editor Area with Syntax Highlighting + material.Expanded( + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: QueryaCodeEditor( + controller: _controller, + language: _toQueryaLanguage(activeLang), + enableHighlighting: true, + fontSize: 12, + variant: QueryaCodeEditorVariant.material, + ), + ), + ), + + // Apply button if editable + if (widget.onUpdateValue != null) + material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.ElevatedButton( + onPressed: () { + widget.onUpdateValue!(_controller.text); + }, + style: material.ElevatedButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.primaryForeground, + padding: const material.EdgeInsets.symmetric(vertical: 8), + minimumSize: material.Size.zero, + ), + child: const Text('Update Cell Value').small().bold(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/main_screen/xml_html_formatter.dart b/lib/features/main_screen/xml_html_formatter.dart new file mode 100644 index 00000000..f170036a --- /dev/null +++ b/lib/features/main_screen/xml_html_formatter.dart @@ -0,0 +1,117 @@ +/// Formatter and validator for XML and HTML strings. +abstract final class XmlHtmlFormatter { + /// Validates [xml] string and returns null if valid, or an error message if invalid. + static String? validate(String xml) { + final trimmed = xml.trim(); + if (trimmed.isEmpty) return null; + + final tagStack = []; + final tagRegex = RegExp(r'<(/)?([a-zA-Z0-9_\-:]+)([^>]*)>'); + final matches = tagRegex.allMatches(trimmed); + + if (matches.isEmpty) { + if (trimmed.contains('<') || trimmed.contains('>')) { + return 'Malformed XML/HTML tags'; + } + return null; + } + + for (final match in matches) { + final fullMatch = match.group(0)!; + final isClosing = match.group(1) != null; + final tagName = match.group(2)!; + final rest = match.group(3) ?? ''; + + // Check for self-closing tag: or XML declaration or comment + if (fullMatch.startsWith(''; + } + final last = tagStack.removeLast(); + if (last.toLowerCase() != tagName.toLowerCase()) { + return 'Mismatched closing tag: expected , got '; + } + } else { + tagStack.add(tagName); + } + } + + if (tagStack.isNotEmpty) { + return 'Unclosed tag: <${tagStack.last}>'; + } + + return null; + } + + /// Formats / pretty-prints [xml] with [indent] spaces per level. + static String format(String xml, {int indent = 2}) { + final trimmed = xml.trim(); + if (trimmed.isEmpty) return xml; + + final indentStr = ' ' * indent; + final buffer = StringBuffer(); + var level = 0; + + final tokenRegex = RegExp(r'(|<\?[^>]*\?>|]*>|<[^>]+>|[^<]+)'); + final matches = tokenRegex.allMatches(trimmed); + + for (final match in matches) { + var token = match.group(0)!.trim(); + if (token.isEmpty) continue; + + if (token.startsWith(' 0) level--; + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } else if (token.startsWith('<') && token.endsWith('/>')) { + // Self closing tag + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } else if (token.startsWith('')) { + // Opening tag + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + level++; + } else { + // Text node + if (buffer.isNotEmpty) buffer.writeln(); + buffer.write(indentStr * level); + buffer.write(token); + } + } + + return buffer.toString(); + } + + /// Minifies [xml] by removing newlines and extraneous spaces between tags. + static String minify(String xml) { + return xml + .replaceAll(RegExp(r'>\s+<'), '><') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } +} diff --git a/test/features/main_screen/data_grid_value_panel_test.dart b/test/features/main_screen/data_grid_value_panel_test.dart new file mode 100644 index 00000000..71b1f364 --- /dev/null +++ b/test/features/main_screen/data_grid_value_panel_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_value_panel.dart'; +import 'package:querya_desktop/features/main_screen/xml_html_formatter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('XmlHtmlFormatter', () { + test('validates correct XML tags', () { + expect(XmlHtmlFormatter.validate('value'), isNull); + expect(XmlHtmlFormatter.validate(''), isNull); + expect(XmlHtmlFormatter.validate('
'), isNull); + }); + + test('detects mismatched or unclosed XML tags', () { + expect(XmlHtmlFormatter.validate(''), isNotNull); + expect(XmlHtmlFormatter.validate(''), isNotNull); + expect(XmlHtmlFormatter.validate(''), isNotNull); + }); + + test('pretty prints XML with indentation', () { + const input = 'Alice'; + final formatted = XmlHtmlFormatter.format(input); + expect(formatted, contains(' ')); + expect(formatted, contains(' ')); + expect(formatted, contains(' Alice')); + expect(formatted, contains(' ')); + }); + + test('minifies XML by collapsing whitespace', () { + const input = ''' + + + Alice + + +'''; + final minified = XmlHtmlFormatter.minify(input); + expect(minified, contains(' Alice ')); + }); + }); + + group('DataGridValuePanel Widget', () { + testWidgets('renders panel with initial JSON and formatting buttons', (tester) async { + String? updatedVal; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Material( + child: DataGridValuePanel( + columnName: 'payload', + cellValue: '{"name":"John","age":30}', + rowIndex: 0, + onClose: () {}, + onUpdateValue: (val) => updatedVal = val, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('payload [Row 1]'), findsOneWidget); + expect(find.text('Format'), findsOneWidget); + expect(find.text('Minify'), findsOneWidget); + + await tester.tap(find.text('Update Cell Value')); + await tester.pumpAndSettle(); + expect(updatedVal, isNotNull); + }); + }); +} From 15558b906a10b45f6beb619d1021a18803454b30 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:30:11 +0300 Subject: [PATCH 31/40] feat(calc): add distinct count, median, std dev, and copy stats summary action (#573) Closes #573 --- .../main_screen/data_grid_calc_bar.dart | 170 ++++++++++++++++ .../grid_selection_calc_engine.dart | 181 ++++++++++++++++++ .../main_screen/data_grid_engines_test.dart | 11 ++ 3 files changed, 362 insertions(+) create mode 100644 lib/features/main_screen/data_grid_calc_bar.dart create mode 100644 lib/features/main_screen/grid_selection_calc_engine.dart diff --git a/lib/features/main_screen/data_grid_calc_bar.dart b/lib/features/main_screen/data_grid_calc_bar.dart new file mode 100644 index 00000000..9a5324a0 --- /dev/null +++ b/lib/features/main_screen/data_grid_calc_bar.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Status bar footer for Data Grid displaying live selection statistics (Count, Distinct, Sum, Avg, Median, Min, Max). +class DataGridCalcBar extends StatelessWidget { + const DataGridCalcBar({ + super.key, + required this.stats, + }); + + final GridCalcStats stats; + + @override + Widget build(BuildContext context) { + if (stats.totalCount <= 1 && !stats.hasNumericStats) { + return const material.SizedBox.shrink(); + } + + final cs = Theme.of(context).colorScheme; + + return material.Container( + height: 26, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.Row( + children: [ + material.Expanded( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + _StatBadge( + label: 'Count', + value: '${stats.totalCount}', + ), + const Gap(8), + _StatBadge( + label: 'Distinct', + value: '${stats.distinctCount}', + ), + if (stats.nullCount > 0) ...[ + const Gap(8), + _StatBadge( + label: 'NULLs', + value: '${stats.nullCount}', + ), + ], + if (stats.hasNumericStats) ...[ + const Gap(8), + _StatBadge( + label: 'Sum', + value: GridSelectionCalcEngine.formatNum(stats.sum), + ), + const Gap(8), + _StatBadge( + label: 'Avg', + value: GridSelectionCalcEngine.formatNum(stats.average), + ), + if (stats.median != null) ...[ + const Gap(8), + _StatBadge( + label: 'Median', + value: GridSelectionCalcEngine.formatNum(stats.median), + ), + ], + const Gap(8), + _StatBadge( + label: 'Min', + value: GridSelectionCalcEngine.formatNum(stats.min), + ), + const Gap(8), + _StatBadge( + label: 'Max', + value: GridSelectionCalcEngine.formatNum(stats.max), + ), + ], + ], + ), + ), + ), + const Gap(6), + material.Tooltip( + message: 'Copy all stats summary', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: stats.toSummaryString())); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.copy_all_rounded, + size: 13, + color: cs.mutedForeground, + ), + const Gap(3), + Text('Copy Stats', style: TextStyle(fontSize: 10.5, color: cs.mutedForeground)), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _StatBadge extends StatelessWidget { + const _StatBadge({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return material.Tooltip( + message: 'Click to copy $label: $value', + child: material.InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + }, + borderRadius: material.BorderRadius.circular(3), + child: material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + Text( + '$label: ', + style: TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/grid_selection_calc_engine.dart b/lib/features/main_screen/grid_selection_calc_engine.dart new file mode 100644 index 00000000..54b698d4 --- /dev/null +++ b/lib/features/main_screen/grid_selection_calc_engine.dart @@ -0,0 +1,181 @@ +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; + +/// Aggregated statistical results for a selection of grid cell values. +@immutable +class GridCalcStats { + const GridCalcStats({ + required this.totalCount, + required this.distinctCount, + required this.numericCount, + required this.nullCount, + this.sum, + this.average, + this.median, + this.min, + this.max, + this.stdDev, + }); + + static const empty = GridCalcStats( + totalCount: 0, + distinctCount: 0, + numericCount: 0, + nullCount: 0, + ); + + final int totalCount; + final int distinctCount; + final int numericCount; + final int nullCount; + final double? sum; + final double? average; + final double? median; + final double? min; + final double? max; + final double? stdDev; + + bool get hasNumericStats => numericCount > 0 && sum != null; + + /// Formats all available statistics into a single copyable summary string. + String toSummaryString() { + final parts = [ + 'Count: $totalCount', + 'Distinct: $distinctCount', + ]; + if (nullCount > 0) { + parts.add('NULLs: $nullCount'); + } + if (hasNumericStats) { + parts.add('Sum: ${GridSelectionCalcEngine.formatNum(sum)}'); + parts.add('Avg: ${GridSelectionCalcEngine.formatNum(average)}'); + if (median != null) { + parts.add('Median: ${GridSelectionCalcEngine.formatNum(median)}'); + } + parts.add('Min: ${GridSelectionCalcEngine.formatNum(min)}'); + parts.add('Max: ${GridSelectionCalcEngine.formatNum(max)}'); + if (stdDev != null) { + parts.add('StdDev: ${GridSelectionCalcEngine.formatNum(stdDev)}'); + } + } + return parts.join(' | '); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GridCalcStats && + totalCount == other.totalCount && + distinctCount == other.distinctCount && + numericCount == other.numericCount && + nullCount == other.nullCount && + sum == other.sum && + average == other.average && + median == other.median && + min == other.min && + max == other.max && + stdDev == other.stdDev; + + @override + int get hashCode => Object.hash( + totalCount, + distinctCount, + numericCount, + nullCount, + sum, + average, + median, + min, + max, + stdDev, + ); +} + +/// Calculation engine for computing stats (Count, Distinct, Sum, Avg, Median, Min, Max, StdDev) on grid selections. +abstract final class GridSelectionCalcEngine { + /// Computes statistics for a list of string cell values. + static GridCalcStats compute(List values) { + if (values.isEmpty) return GridCalcStats.empty; + + final total = values.length; + var nulls = 0; + final distinctSet = {}; + final numericList = []; + var sum = 0.0; + double? minVal; + double? maxVal; + + for (final raw in values) { + final trimmed = raw.trim(); + if (trimmed == 'NULL' || trimmed.isEmpty) { + nulls++; + continue; + } + + distinctSet.add(trimmed); + + // Try parsing numeric values (stripping commas if present) + final normalized = trimmed.replaceAll(',', ''); + final parsed = double.tryParse(normalized); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numericList.add(parsed); + sum += parsed; + if (minVal == null || parsed < minVal) { + minVal = parsed; + } + if (maxVal == null || parsed > maxVal) { + maxVal = parsed; + } + } + } + + final numericCount = numericList.length; + final avg = numericCount > 0 ? sum / numericCount : null; + + // Calculate median + double? median; + if (numericCount > 0) { + numericList.sort(); + final mid = numericCount ~/ 2; + if (numericCount.isOdd) { + median = numericList[mid]; + } else { + median = (numericList[mid - 1] + numericList[mid]) / 2.0; + } + } + + // Calculate standard deviation + double? stdDev; + if (numericCount > 1 && avg != null) { + var varianceSum = 0.0; + for (final n in numericList) { + varianceSum += math.pow(n - avg, 2); + } + stdDev = math.sqrt(varianceSum / (numericCount - 1)); + } + + return GridCalcStats( + totalCount: total, + distinctCount: distinctSet.length, + numericCount: numericCount, + nullCount: nulls, + sum: numericCount > 0 ? sum : null, + average: avg, + median: median, + min: minVal, + max: maxVal, + stdDev: stdDev, + ); + } + + /// Formats a numeric stat cleanly for UI display. + static String formatNum(double? val) { + if (val == null) return '-'; + if (val == val.roundToDouble()) { + return val.toInt().toString(); + } + // Limit decimal precision to 4 decimal places + final formatted = val.toStringAsFixed(4); + return formatted.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } +} diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart index 15227848..880f5145 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -165,17 +165,28 @@ void main() { test('computes correct stats for numeric values', () { final stats = GridSelectionCalcEngine.compute(['10', '20', '30', '40']); expect(stats.totalCount, equals(4)); + expect(stats.distinctCount, equals(4)); expect(stats.numericCount, equals(4)); expect(stats.nullCount, equals(0)); expect(stats.sum, equals(100.0)); expect(stats.average, equals(25.0)); + expect(stats.median, equals(25.0)); expect(stats.min, equals(10.0)); expect(stats.max, equals(40.0)); }); + test('computes odd-length median and distinct count with duplicates', () { + final stats = GridSelectionCalcEngine.compute(['10', '20', '20', '50', '100']); + expect(stats.distinctCount, equals(4)); + expect(stats.median, equals(20.0)); + expect(stats.toSummaryString(), contains('Count: 5 | Distinct: 4')); + expect(stats.toSummaryString(), contains('Median: 20')); + }); + test('handles NULLs and mixed string data', () { final stats = GridSelectionCalcEngine.compute(['10', 'NULL', 'text', '50.5']); expect(stats.totalCount, equals(4)); + expect(stats.distinctCount, equals(3)); expect(stats.numericCount, equals(2)); expect(stats.nullCount, equals(1)); expect(stats.sum, equals(60.5)); From 617ef2be46698171bf44c1c0280fad2ec007b393 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:31:20 +0300 Subject: [PATCH 32/40] feat(groupings): add multi-column grouping, custom aggregations, sorting and CSV export (#574, #575, #576) Closes #574 Closes #575 Closes #576 --- .../main_screen/data_grid_groupings_view.dart | 391 ++++++++++++++++++ .../main_screen/grid_groupings_engine.dart | 239 +++++++++++ .../main_screen/data_grid_engines_test.dart | 71 +++- 3 files changed, 694 insertions(+), 7 deletions(-) create mode 100644 lib/features/main_screen/data_grid_groupings_view.dart create mode 100644 lib/features/main_screen/grid_groupings_engine.dart diff --git a/lib/features/main_screen/data_grid_groupings_view.dart b/lib/features/main_screen/data_grid_groupings_view.dart new file mode 100644 index 00000000..1dbf0692 --- /dev/null +++ b/lib/features/main_screen/data_grid_groupings_view.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Groupings / Pivot view tab for tabular data with hierarchical grouping and custom aggregations. +class DataGridGroupingsView extends material.StatefulWidget { + const DataGridGroupingsView({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => + _DataGridGroupingsViewState(); +} + +class _DataGridGroupingsViewState + extends material.State { + late List _selectedColIndices; + GroupingAggType _aggType = GroupingAggType.count; + int? _aggTargetColIndex; + GroupSortBy _sortBy = GroupSortBy.count; + bool _sortAscending = false; + final Set _expandedKeys = {}; + + @override + void initState() { + super.initState(); + _selectedColIndices = widget.columns.isNotEmpty ? [0] : []; + if (widget.columns.length > 1) { + // Pick first numeric-looking column as default target for sum/avg if available + _aggTargetColIndex = 1; + } + } + + @override + void didUpdateWidget(covariant DataGridGroupingsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns) { + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } else { + _selectedColIndices.removeWhere((idx) => idx >= widget.columns.length); + if (_selectedColIndices.isEmpty && widget.columns.isNotEmpty) { + _selectedColIndices = [0]; + } + } + } + } + + void _exportPivot() { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + final groupName = _selectedColIndices.isNotEmpty + ? widget.columns[_selectedColIndices.first] + : 'Group'; + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: groupName, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + ); + + Clipboard.setData(ClipboardData(text: csv)); + } + + @override + material.Widget build(material.BuildContext context) { + if (widget.columns.isEmpty || widget.rows.isEmpty) { + return material.Center( + child: const Text('No data available for grouping.').muted(), + ); + } + + final cs = Theme.of(context).colorScheme; + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: _selectedColIndices, + rows: widget.rows, + aggConfig: GroupAggregationConfig( + aggType: _aggType, + targetColIndex: _aggTargetColIndex, + ), + sortBy: _sortBy, + sortAscending: _sortAscending, + ); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Top Toolbar for selecting Group By, Aggregation, and Sorting + material.Container( + height: 40, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + decoration: material.BoxDecoration( + color: cs.card, + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.35), + width: 1, + ), + ), + ), + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + children: [ + material.Icon( + material.Icons.account_tree_outlined, + size: 15, + color: cs.primary, + ), + const Gap(6), + const Text('Group:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _selectedColIndices.isNotEmpty && + _selectedColIndices.first < widget.columns.length + ? _selectedColIndices.first + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() { + _selectedColIndices = [idx]; + _expandedKeys.clear(); + }); + } + }, + ), + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Aggregation Selector + const Text('Agg:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _aggType, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupingAggType.values.map((t) { + return material.DropdownMenuItem( + value: t, + child: Text(t.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _aggType = val); + } + }, + ), + if (_aggType != GroupingAggType.count) ...[ + const Gap(4), + material.DropdownButton( + value: _aggTargetColIndex != null && + _aggTargetColIndex! < widget.columns.length + ? _aggTargetColIndex + : 0, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: List.generate(widget.columns.length, (i) { + return material.DropdownMenuItem( + value: i, + child: Text(widget.columns[i]), + ); + }), + onChanged: (idx) { + if (idx != null) { + setState(() => _aggTargetColIndex = idx); + } + }, + ), + ], + + const Gap(12), + material.VerticalDivider( + width: 1, + thickness: 1, + indent: 8, + endIndent: 8, + color: cs.border.withValues(alpha: 0.3), + ), + const Gap(12), + + // Sort Selector + const Text('Sort:').small().semiBold(), + const Gap(6), + material.DropdownButton( + value: _sortBy, + isDense: true, + underline: const material.SizedBox.shrink(), + style: TextStyle(fontSize: 12, color: cs.foreground), + items: GroupSortBy.values.map((s) { + return material.DropdownMenuItem( + value: s, + child: Text(s.label), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setState(() => _sortBy = val); + } + }, + ), + material.IconButton( + icon: material.Icon( + _sortAscending + ? material.Icons.arrow_upward_rounded + : material.Icons.arrow_downward_rounded, + size: 14, + ), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: () => setState(() => _sortAscending = !_sortAscending), + ), + + const Gap(8), + material.Tooltip( + message: 'Copy Pivot CSV to Clipboard', + child: material.IconButton( + icon: const material.Icon(material.Icons.copy_rounded, size: 14), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(minWidth: 24, minHeight: 24), + color: cs.mutedForeground, + onPressed: _exportPivot, + ), + ), + ], + ), + ), + ), + + // Groupings List + material.Expanded( + child: material.ListView.separated( + itemCount: groups.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: cs.border.withValues(alpha: 0.2), + ), + itemBuilder: (context, idx) { + final group = groups[idx]; + final isExpanded = _expandedKeys.contains(group.groupKey); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.InkWell( + onTap: () { + setState(() { + if (isExpanded) { + _expandedKeys.remove(group.groupKey); + } else { + _expandedKeys.add(group.groupKey); + } + }); + }, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: material.Row( + children: [ + material.Icon( + isExpanded + ? material.Icons.keyboard_arrow_down_rounded + : material.Icons.keyboard_arrow_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + const Gap(8), + material.Expanded( + child: Text( + group.groupKey, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + ), + if (group.aggValue != null && _aggType != GroupingAggType.count) ...[ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + margin: const material.EdgeInsets.only(right: 6), + decoration: material.BoxDecoration( + color: cs.secondary.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(6), + ), + child: Text( + '${_aggType.label}: ${group.aggValue!.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + ), + ], + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(10), + ), + child: Text( + '${group.count} rows (${group.percentage.toStringAsFixed(1)}%)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + ], + ), + ), + ), + + // Expanded sub-grid + if (isExpanded) + material.Container( + height: 220, + margin: const material.EdgeInsets.only( + left: 28, + right: 12, + bottom: 8, + ), + decoration: material.BoxDecoration( + border: material.Border.all( + color: cs.border.withValues(alpha: 0.4), + ), + borderRadius: material.BorderRadius.circular(6), + ), + child: VirtualResultGrid( + columns: widget.columns, + rows: group.rows, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/main_screen/grid_groupings_engine.dart b/lib/features/main_screen/grid_groupings_engine.dart new file mode 100644 index 00000000..e117df19 --- /dev/null +++ b/lib/features/main_screen/grid_groupings_engine.dart @@ -0,0 +1,239 @@ +import 'package:flutter/foundation.dart'; + +/// Aggregation operation to perform on groups. +enum GroupingAggType { + count('COUNT'), + sum('SUM'), + avg('AVG'), + min('MIN'), + max('MAX'); + + const GroupingAggType(this.label); + final String label; +} + +/// Sort criteria for grouping categories. +enum GroupSortBy { + count('Count'), + key('Group Key'), + aggregate('Aggregate'); + + const GroupSortBy(this.label); + final String label; +} + +/// Configuration for group aggregations. +@immutable +class GroupAggregationConfig { + const GroupAggregationConfig({ + this.aggType = GroupingAggType.count, + this.targetColIndex, + }); + + final GroupingAggType aggType; + final int? targetColIndex; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupAggregationConfig && + aggType == other.aggType && + targetColIndex == other.targetColIndex; + + @override + int get hashCode => Object.hash(aggType, targetColIndex); +} + +/// Represents an aggregated group in Groupings / Pivot View (supports nested sub-groups). +@immutable +class GroupedCategory { + const GroupedCategory({ + required this.groupKey, + required this.count, + required this.percentage, + required this.rows, + this.aggValue, + this.subGroups = const [], + this.level = 0, + }); + + final String groupKey; + final int count; + final double percentage; + final List> rows; + final double? aggValue; + final List subGroups; + final int level; + + bool get hasSubGroups => subGroups.isNotEmpty; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GroupedCategory && + groupKey == other.groupKey && + count == other.count && + percentage == other.percentage && + aggValue == other.aggValue && + level == other.level; + + @override + int get hashCode => Object.hash(groupKey, count, percentage, aggValue, level); +} + +/// Engine to construct multi-column pivot / hierarchical grouping breakdown tables. +abstract final class GridGroupingsEngine { + /// Builds multi-level groups by [groupColIndices] with optional aggregation and sorting. + static List buildGroups({ + required List groupColIndices, + required List> rows, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + GroupSortBy sortBy = GroupSortBy.count, + bool sortAscending = false, + }) { + if (rows.isEmpty || groupColIndices.isEmpty) return const []; + + return _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: 0, + rows: rows, + totalRootRows: rows.length, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + static List _buildSubGroups({ + required List groupColIndices, + required int levelIndex, + required List> rows, + required int totalRootRows, + required GroupAggregationConfig aggConfig, + required GroupSortBy sortBy, + required bool sortAscending, + }) { + if (levelIndex >= groupColIndices.length || rows.isEmpty) return const []; + + final colIndex = groupColIndices[levelIndex]; + final map = >>{}; + + for (final row in rows) { + final key = colIndex < row.length ? row[colIndex] : 'NULL'; + final effectiveKey = key.isEmpty ? '(Empty)' : key; + map.putIfAbsent(effectiveKey, () => []).add(row); + } + + final categories = []; + final hasNextLevel = levelIndex + 1 < groupColIndices.length; + + map.forEach((key, categoryRows) { + final count = categoryRows.length; + final pct = totalRootRows > 0 ? (count / totalRootRows) * 100 : 0.0; + final agg = _computeAggregation(categoryRows, aggConfig); + + List subGroups = const []; + if (hasNextLevel) { + subGroups = _buildSubGroups( + groupColIndices: groupColIndices, + levelIndex: levelIndex + 1, + rows: categoryRows, + totalRootRows: totalRootRows, + aggConfig: aggConfig, + sortBy: sortBy, + sortAscending: sortAscending, + ); + } + + categories.add( + GroupedCategory( + groupKey: key, + count: count, + percentage: pct, + rows: categoryRows, + aggValue: agg, + subGroups: subGroups, + level: levelIndex, + ), + ); + }); + + // Sorting + categories.sort((a, b) { + int cmp; + switch (sortBy) { + case GroupSortBy.count: + cmp = a.count.compareTo(b.count); + break; + case GroupSortBy.key: + cmp = a.groupKey.compareTo(b.groupKey); + break; + case GroupSortBy.aggregate: + final aVal = a.aggValue ?? (a.count.toDouble()); + final bVal = b.aggValue ?? (b.count.toDouble()); + cmp = aVal.compareTo(bVal); + break; + } + return sortAscending ? cmp : -cmp; + }); + + return categories; + } + + static double? _computeAggregation( + List> rows, + GroupAggregationConfig config, + ) { + if (config.aggType == GroupingAggType.count) { + return rows.length.toDouble(); + } + if (config.targetColIndex == null) return null; + + final targetCol = config.targetColIndex!; + final numbers = []; + + for (final row in rows) { + if (targetCol < row.length) { + final val = row[targetCol].replaceAll(',', '').trim(); + final parsed = double.tryParse(val); + if (parsed != null && !parsed.isNaN && !parsed.isInfinite) { + numbers.add(parsed); + } + } + } + + if (numbers.isEmpty) return null; + + switch (config.aggType) { + case GroupingAggType.count: + return numbers.length.toDouble(); + case GroupingAggType.sum: + return numbers.reduce((a, b) => a + b); + case GroupingAggType.avg: + return numbers.reduce((a, b) => a + b) / numbers.length; + case GroupingAggType.min: + return numbers.reduce((a, b) => a < b ? a : b); + case GroupingAggType.max: + return numbers.reduce((a, b) => a > b ? a : b); + } + } + + /// Exports pivot summary to CSV format. + static String exportPivotToCsv({ + required List groups, + required String groupByColumnName, + GroupAggregationConfig aggConfig = const GroupAggregationConfig(), + }) { + final buffer = StringBuffer(); + buffer.writeln('Group Key,Count,Percentage,Aggregate'); + + for (final g in groups) { + final aggStr = g.aggValue != null ? g.aggValue!.toStringAsFixed(2) : '-'; + buffer.writeln( + '"${g.groupKey.replaceAll('"', '""')}",${g.count},${g.percentage.toStringAsFixed(2)}%,$aggStr', + ); + } + + return buffer.toString(); + } +} diff --git a/test/features/main_screen/data_grid_engines_test.dart b/test/features/main_screen/data_grid_engines_test.dart index 880f5145..07b8f8c6 100644 --- a/test/features/main_screen/data_grid_engines_test.dart +++ b/test/features/main_screen/data_grid_engines_test.dart @@ -198,16 +198,16 @@ void main() { group('GridGroupingsEngine', () { final rows = [ - ['1', 'ACTIVE'], - ['2', 'PENDING'], - ['3', 'ACTIVE'], - ['4', 'ACTIVE'], - ['5', 'CANCELLED'], + ['1', 'ACTIVE', '100'], + ['2', 'PENDING', '50'], + ['3', 'ACTIVE', '200'], + ['4', 'ACTIVE', '300'], + ['5', 'CANCELLED', '0'], ]; test('groups rows by column index and calculates percentages', () { final groups = GridGroupingsEngine.buildGroups( - colIndex: 1, + groupColIndices: [1], rows: rows, ); @@ -215,8 +215,65 @@ void main() { expect(groups[0].groupKey, equals('ACTIVE')); expect(groups[0].count, equals(3)); expect(groups[0].percentage, closeTo(60.0, 0.1)); - expect(groups[1].count, equals(1)); }); + + test('computes custom aggregations (SUM and AVG)', () { + final sumGroups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 2, + ), + ); + + final activeGroup = sumGroups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeGroup.aggValue, equals(600.0)); + + final avgGroups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.avg, + targetColIndex: 2, + ), + ); + + final activeAvg = avgGroups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeAvg.aggValue, equals(200.0)); + }); + + test('sorts groups by key or aggregate', () { + final keySorted = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + sortBy: GroupSortBy.key, + sortAscending: true, + ); + + expect(keySorted[0].groupKey, equals('ACTIVE')); + expect(keySorted[1].groupKey, equals('CANCELLED')); + expect(keySorted[2].groupKey, equals('PENDING')); + }); + + test('exports pivot table to CSV format', () { + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [1], + rows: rows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 2, + ), + ); + + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: 'status', + ); + + expect(csv, contains('Group Key,Count,Percentage,Aggregate')); + expect(csv, contains('"ACTIVE",3,60.00%,600.00')); + }); }); } From 02d0fbf86f470f99fc98db956a61efa4d477ceda Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:34:01 +0300 Subject: [PATCH 33/40] test(grid): add end-to-end integration tests for data grid staging, DML generation, and filtering pipelines (#578) Closes #578 --- .../main_screen/data_grid_staging_buffer.dart | 4 +- .../data_grid_e2e_integration_test.dart | 155 ++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 test/features/main_screen/data_grid_e2e_integration_test.dart diff --git a/lib/features/main_screen/data_grid_staging_buffer.dart b/lib/features/main_screen/data_grid_staging_buffer.dart index ddae0db0..4f04b16f 100644 --- a/lib/features/main_screen/data_grid_staging_buffer.dart +++ b/lib/features/main_screen/data_grid_staging_buffer.dart @@ -234,7 +234,9 @@ class DataGridStagingBuffer extends ChangeNotifier { /// Read-only snapshot of modified cells mapping. Map> get modifiedCells => - Map.unmodifiable(_modifiedCells.map((k, v) => MapEntry(k, Map.unmodifiable(v)))); + Map>.unmodifiable( + _modifiedCells.map((k, v) => MapEntry(k, Map.unmodifiable(v))), + ); /// Read-only list of newly inserted rows. List> get insertedRows => diff --git a/test/features/main_screen/data_grid_e2e_integration_test.dart b/test/features/main_screen/data_grid_e2e_integration_test.dart new file mode 100644 index 00000000..1a7a5843 --- /dev/null +++ b/test/features/main_screen/data_grid_e2e_integration_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; +import 'package:querya_desktop/features/main_screen/grid_filter_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_groupings_engine.dart'; +import 'package:querya_desktop/features/main_screen/grid_selection_calc_engine.dart'; + +void main() { + group('Data Grid End-to-End Integration Tests', () { + const columns = ['id', 'username', 'email', 'balance', 'status']; + final baseRows = [ + ['1', 'alice', 'alice@example.com', '150.00', 'ACTIVE'], + ['2', 'bob', 'bob@example.com', '50.00', 'PENDING'], + ['3', 'charlie', 'charlie@example.com', '300.00', 'ACTIVE'], + ['4', 'david', 'david@example.com', '0.00', 'CANCELLED'], + ]; + + test('E2E: Staging workflow -> Plan generation -> Multi-dialect DML compilation', () { + final buffer = DataGridStagingBuffer( + columns: columns, + rows: baseRows, + ); + + expect(buffer.isDirty, isFalse); + + // 1. Modify cell (Row 0, balance: 150.00 -> 250.00) + buffer.setCell(0, 3, '250.00'); + // 2. Modify cell (Row 1, status: PENDING -> ACTIVE) + buffer.setCell(1, 4, 'ACTIVE'); + // 3. Mark Row 3 as deleted + buffer.toggleDeleteRow(3); + // 4. Insert new row + buffer.addRow(['5', 'eve', 'eve@example.com', '500.00', 'ACTIVE']); + + expect(buffer.isDirty, isTrue); + expect(buffer.modifiedCells.length, equals(2)); + expect(buffer.deletedRowIndices, contains(3)); + expect(buffer.insertedRows.length, equals(1)); + + // 5. Generate Postgres mutation plan with primary key 'id' + final pgPlan = buffer.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: 'users', + primaryKeys: ['id'], + ); + + expect(pgPlan.statements.length, equals(4)); + expect(pgPlan.statements[0].sql, contains('UPDATE "users" SET "balance" = 250.00 WHERE "id" = 1')); + expect(pgPlan.statements[1].sql, contains('UPDATE "users" SET "status" = \'ACTIVE\' WHERE "id" = 2')); + expect(pgPlan.statements[2].sql, contains('INSERT INTO "users"')); + expect(pgPlan.statements[3].sql, contains('DELETE FROM "users" WHERE "id" = 4')); + expect(pgPlan.toTransactionSql(), startsWith('BEGIN;\n')); + expect(pgPlan.toTransactionSql(), contains('COMMIT;')); + + // 6. Generate MySQL mutation plan + final mysqlPlan = buffer.generateMutationPlan( + dialect: SqlDialect.mysql, + tableName: 'users', + primaryKeys: ['id'], + ); + expect(mysqlPlan.statements[0].sql, contains('UPDATE `users` SET `balance` = 250.00 WHERE `id` = 1')); + expect(mysqlPlan.toTransactionSql(), startsWith('START TRANSACTION;\n')); + + // 7. Generate SQLite mutation plan + final sqlitePlan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 'users', + primaryKeys: ['id'], + ); + expect(sqlitePlan.statements[0].sql, contains('UPDATE "users" SET "balance" = 250.00 WHERE "id" = 1')); + expect(sqlitePlan.toTransactionSql(), startsWith('BEGIN TRANSACTION;\n')); + + // 8. Reset / clear on successful commit + buffer.revertAll(); + expect(buffer.isDirty, isFalse); + expect(buffer.modifiedCells, isEmpty); + expect(buffer.insertedRows, isEmpty); + expect(buffer.deletedRowIndices, isEmpty); + }); + + test('E2E: SQL Query -> Target Extractor -> Schema & Table resolution', () { + expect( + SqlTableTargetExtractor.extract('SELECT * FROM users'), + equals(const SqlTableTarget(tableName: 'users')), + ); + + expect( + SqlTableTargetExtractor.extract('SELECT id, name FROM public.accounts WHERE active = 1'), + equals(const SqlTableTarget(tableName: 'accounts', schema: 'public')), + ); + + expect( + SqlTableTargetExtractor.extract('SELECT u.id FROM `my_db`.`customers` u ORDER BY id DESC'), + equals(const SqlTableTarget(tableName: 'customers', schema: 'my_db')), + ); + + // Complex join / non-simple select should return null + expect( + SqlTableTargetExtractor.extract('SELECT a.id, b.name FROM a JOIN b ON a.id = b.id'), + isNull, + ); + }); + + test('E2E: Filter Engine -> Selection Calc -> Groupings Pivot pipeline', () { + // 1. Filter rows where status is ACTIVE or PENDING and balance > 0 + final matchingIndices = GridFilterEngine.filterRowIndices( + filterText: "status IN ('ACTIVE', 'PENDING') AND balance > 0", + columns: columns, + rows: baseRows, + ); + expect(matchingIndices, equals([0, 1, 2])); + + final filteredRows = matchingIndices.map((i) => baseRows[i]).toList(); + + // 2. Select balance column values from filtered rows + final balanceValues = filteredRows.map((r) => r[3]).toList(); + final stats = GridSelectionCalcEngine.compute(balanceValues); + + expect(stats.totalCount, equals(3)); + expect(stats.sum, equals(500.00)); + expect(stats.average, closeTo(166.666, 0.01)); + expect(stats.median, equals(150.00)); + expect(stats.min, equals(50.00)); + expect(stats.max, equals(300.00)); + + // 3. Group filtered rows by status with SUM(balance) aggregation + final groups = GridGroupingsEngine.buildGroups( + groupColIndices: [4], + rows: filteredRows, + aggConfig: const GroupAggregationConfig( + aggType: GroupingAggType.sum, + targetColIndex: 3, + ), + ); + + expect(groups.length, equals(2)); + final activeGroup = groups.firstWhere((g) => g.groupKey == 'ACTIVE'); + expect(activeGroup.count, equals(2)); + expect(activeGroup.aggValue, equals(450.00)); + + final pendingGroup = groups.firstWhere((g) => g.groupKey == 'PENDING'); + expect(pendingGroup.count, equals(1)); + expect(pendingGroup.aggValue, equals(50.00)); + + // 4. Export pivot summary to CSV + final csv = GridGroupingsEngine.exportPivotToCsv( + groups: groups, + groupByColumnName: 'status', + ); + expect(csv, contains('"ACTIVE",2,66.67%,450.00')); + expect(csv, contains('"PENDING",1,33.33%,50.00')); + }); + }); +} From f9f5c9345742ad607f850aeb902ec16f92e51191 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:36:41 +0300 Subject: [PATCH 34/40] feat(a11y): add global keyboard shortcuts (Ctrl+F, Ctrl+S, Ctrl+G), focus accessibility, and visual polish (#579) Closes #579 --- lib/features/main_screen/results_tab.dart | 69 +++++++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index fcfbc466..118628f9 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -1,6 +1,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; import 'package:querya_desktop/core/motion/querya_fade_slide.dart'; import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/main_screen/data_grid_calc_bar.dart'; @@ -131,17 +132,60 @@ class _ResultsTabState extends material.State { ? effectiveRows : filteredIndices.map((i) => effectiveRows[i]).toList(); - return material.Column( - key: const material.ValueKey('results_mode_grid'), - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (widget.stagingBuffer != null) - DataGridStagingToolbar( - stagingBuffer: widget.stagingBuffer!, - selectedRowIndex: _selectedRowIndex, - onApplyChanges: widget.onApplyChanges, - isSaving: widget.isSaving, - ), + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator( + LogicalKeyboardKey.keyF, + meta: true, + ): () => setState(() => _showFilterBar = !_showFilterBar), + const material.SingleActivator( + LogicalKeyboardKey.keyF, + control: true, + ): () => setState(() => _showFilterBar = !_showFilterBar), + const material.SingleActivator( + LogicalKeyboardKey.keyS, + meta: true, + ): () { + if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { + widget.onApplyChanges?.call(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyS, + control: true, + ): () { + if (widget.stagingBuffer?.isDirty == true && !widget.isSaving) { + widget.onApplyChanges?.call(); + } + }, + const material.SingleActivator( + LogicalKeyboardKey.keyG, + meta: true, + ): () => setState(() { + _viewMode = _viewMode == ResultViewMode.grid + ? ResultViewMode.groupings + : ResultViewMode.grid; + }), + const material.SingleActivator( + LogicalKeyboardKey.keyG, + control: true, + ): () => setState(() { + _viewMode = _viewMode == ResultViewMode.grid + ? ResultViewMode.groupings + : ResultViewMode.grid; + }), + }, + child: material.Column( + key: const material.ValueKey('results_mode_grid'), + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (widget.stagingBuffer != null) + DataGridStagingToolbar( + stagingBuffer: widget.stagingBuffer!, + selectedRowIndex: _selectedRowIndex, + onApplyChanges: widget.onApplyChanges, + isSaving: widget.isSaving, + ), if (widget.showExportToolbar && widget.columns.isNotEmpty) material.Container( padding: const material.EdgeInsets.symmetric( @@ -348,7 +392,8 @@ class _ResultsTabState extends material.State { if (_viewMode == ResultViewMode.grid) DataGridCalcBar(stats: _selectionStats), ], - ); + ), + ); } } From 90f681c875a1289f07a5533a1c9a885dcdfb5577 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:37:23 +0300 Subject: [PATCH 35/40] chore: add const to samplePlan in dml_preview_dialog_test --- test/features/main_screen/dml_preview_dialog_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/main_screen/dml_preview_dialog_test.dart b/test/features/main_screen/dml_preview_dialog_test.dart index baae05b1..ec2c9577 100644 --- a/test/features/main_screen/dml_preview_dialog_test.dart +++ b/test/features/main_screen/dml_preview_dialog_test.dart @@ -26,7 +26,7 @@ void main() { } group('DmlPreviewConfirmationDialog', () { - final samplePlan = TableMutationPlan( + const samplePlan = TableMutationPlan( dialect: SqlDialect.postgres, tableName: 'users', schema: 'public', From 4bc5db91c16b646b465a350ee20db9ee45b8e82d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:38:19 +0300 Subject: [PATCH 36/40] fix: resolve unawaited_return_in_try_block and const warnings in analysis --- lib/core/database/connection_pool_lock.dart | 21 ++++++++++++------- .../main_screen/dml_preview_dialog_test.dart | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/core/database/connection_pool_lock.dart b/lib/core/database/connection_pool_lock.dart index 0f3f1528..ad738d89 100644 --- a/lib/core/database/connection_pool_lock.dart +++ b/lib/core/database/connection_pool_lock.dart @@ -20,21 +20,26 @@ class PoolEntryLock { } final completer = Completer(); _lock = completer.future; + Future result; try { final existing = _pending[key]; - if (existing != null) return existing; - final future = create(); - _pending[key] = future; - // Ensure the pending entry is removed once the creation finishes, and - // swallow errors on the cleanup chain so they don't become unhandled. - final guarded = future.whenComplete(() => _pending.remove(key)); - guarded.then((_) {}, onError: (_) {}); - return future; + if (existing != null) { + result = existing; + } else { + final future = create(); + _pending[key] = future; + // Ensure the pending entry is removed once the creation finishes, and + // swallow errors on the cleanup chain so they don't become unhandled. + final guarded = future.whenComplete(() => _pending.remove(key)); + guarded.then((_) {}, onError: (_) {}); + result = future; + } } finally { completer.complete(); if (_lock == completer.future) { _lock = null; } } + return result; } } diff --git a/test/features/main_screen/dml_preview_dialog_test.dart b/test/features/main_screen/dml_preview_dialog_test.dart index ec2c9577..8212f45a 100644 --- a/test/features/main_screen/dml_preview_dialog_test.dart +++ b/test/features/main_screen/dml_preview_dialog_test.dart @@ -30,7 +30,7 @@ void main() { dialect: SqlDialect.postgres, tableName: 'users', schema: 'public', - statements: const [ + statements: [ TableMutationStatement( type: MutationType.update, sql: 'UPDATE "public"."users" SET "name" = \'Alice\' WHERE "id" = 1', From 6602a8fd740e67c12a47842c2c37185a285861ca Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 16:57:37 +0300 Subject: [PATCH 37/40] fix(sqlite): expand table view columns to fill available viewport width --- lib/features/sqlite/sqlite_table_view.dart | 128 ++++++++++++--------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 4484d657..8809598d 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -294,52 +294,54 @@ class _SqliteTableViewState extends material.State { } material.Widget _headerCell(ColorScheme cs, String name) { - return material.Container( - width: 150, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.22), - width: 1, + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: cs.border.withValues(alpha: 0.22), + width: 1, + ), ), ), - ), - child: material.Text( - name, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w600, - color: cs.foreground, + child: material.Text( + name, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), ), ), ); } material.Widget _dataCell(ColorScheme cs, String value) { - return material.Container( - width: 150, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - alignment: material.Alignment.centerLeft, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: cs.border.withValues(alpha: 0.15), - width: 1, + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: cs.border.withValues(alpha: 0.15), + width: 1, + ), ), ), - ), - child: material.SelectableText( - value, - maxLines: 1, - style: material.TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: value == 'NULL' - ? cs.mutedForeground.withValues(alpha: 0.7) - : cs.foreground, + child: material.SelectableText( + value, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: value == 'NULL' + ? cs.mutedForeground.withValues(alpha: 0.7) + : cs.foreground, + ), ), ), ); @@ -481,14 +483,24 @@ class _SqliteTableViewState extends material.State { mainAxisAlignment: material.MainAxisAlignment.end, mainAxisSize: material.MainAxisSize.min, children: [ - material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), ), ), - const Gap(8), + const Gap(6), OutlineButton( size: ButtonSize.small, onPressed: _loading @@ -496,29 +508,41 @@ class _SqliteTableViewState extends material.State { : () => unawaited(_showDdlDialog()), child: const Text('DDL'), ), - const Gap(6), + const Gap(4), OutlineButton( + size: ButtonSize.small, onPressed: _loading ? null : () => unawaited(_fetch()), + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), child: const Text('Refresh'), ), - const Gap(6), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoPrevious || _loading) ? null : _goToPreviousPage, - child: const Icon( - material.Icons.chevron_left_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Back'), ), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, - child: const Icon( - material.Icons.chevron_right_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), ), ], ), From 4af6c54b4c4fb346819806a0eff3b8dcbcfa6e34 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 19:48:07 +0300 Subject: [PATCH 38/40] refactor: polish mysql pagination controls, optimize media query, and update analyzer config --- analysis_options.yaml | 6 + docs/data-grid-interactive-editor-issues.md | 455 ++++++++++++++++++ lib/features/mysql/mysql_table_view.dart | 88 ++-- lib/features/sqlite/sqlite_overview_tab.dart | 2 +- .../goldens/title_bar_read_only_badge.png | Bin 827 -> 818 bytes .../goldens/workspace_empty_hero.png | Bin 7985 -> 7890 bytes 6 files changed, 522 insertions(+), 29 deletions(-) create mode 100644 docs/data-grid-interactive-editor-issues.md diff --git a/analysis_options.yaml b/analysis_options.yaml index a584332e..12a3eb0d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,12 @@ analyzer: exclude: - "third_party/**" - "build/**" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** errors: unnecessary_underscores: ignore prefer_initializing_formals: ignore diff --git a/docs/data-grid-interactive-editor-issues.md b/docs/data-grid-interactive-editor-issues.md new file mode 100644 index 00000000..c09742c3 --- /dev/null +++ b/docs/data-grid-interactive-editor-issues.md @@ -0,0 +1,455 @@ +# GitHub Issues: Π˜Π½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ Π΄Π°Π½Π½Ρ‹Ρ… (Data Grid) + +Π€ΠΎΡ€ΠΌΠ°Ρ‚ Π½ΠΈΠΆΠ΅ рассчитан Π½Π° пСрСнос Π² GitHub / GitLab Issues ΠΈΠ»ΠΈ Ρ‚Ρ€Π΅ΠΊΠ΅Ρ€ Π·Π°Π΄Π°Ρ‡ (Jira, Linear). ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ Π±Π»ΠΎΠΊ содСрТит Ρ†Π΅Π»ΡŒ, контСкст, тСхничСский Π΄ΠΈΠ·Π°ΠΉΠ½, шаги Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΈ ΠΊΡ€ΠΈΡ‚Π΅Ρ€ΠΈΠΈ ΠΏΡ€ΠΈΠ΅ΠΌΠΊΠΈ. + +## Labels + +- `data-grid` +- `sql-editor` +- `frontend` +- `backend` +- `ux` +- `parser` +- `tests` + +## Milestones / Epics + +- **Epic A: In-Place Editing & Database DML Synchronization (Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ Β«Π½Π° Π»Π΅Ρ‚ΡƒΒ» ΠΈ сохранСниС Ctrl+S)** +- **Epic B: Advanced Quick Filter Engine (Боставная Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ, логичСскиС выраТСния ΠΈ ΠΏΡ€Π΅Π΄ΠΈΠΊΠ°Ρ‚Ρ‹)** +- **Epic C: Value Inspector & Selection Calculations (ΠŸΠΎΠ΄ΡΠ²Π΅Ρ‚ΠΊΠ° JSON/XML, Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½Π½Ρ‹Π΅ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ Calc)** +- **Epic D: Groupings & Pivot View (ΠœΠ½ΠΎΠ³ΠΎΡƒΡ€ΠΎΠ²Π½Π΅Π²Π°Ρ Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΠ°, Π°Π³Ρ€Π΅Π³Π°Ρ†ΠΈΠΈ SUM/AVG/MIN/MAX)** +- **Epic E: UI Hardening, UX Polish & Regression Tests (ΠΠ΄Π°ΠΏΡ‚ΠΈΠ²Π½ΠΎΡΡ‚ΡŒ Ρ‚ΡƒΠ»Π±Π°Ρ€Π°, ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Π΅ тСсты)** + +## Recommended Execution Order + +1. **DG-01 β†’ DG-04:** АрхитСктура DML-Π³Π΅Π½Π΅Ρ€Π°Ρ‚ΠΎΡ€Π° ΠΈ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ сохранСния ΠΊ SQL-воркспСйсам (PostgreSQL, MySQL, SQLite, Extensions). +2. **DG-05 β†’ DG-07:** ΠŸΠ°Ρ€ΡΠ΅Ρ€ составных Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ `AND`/`OR`, ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ, `LIKE`, `IN`, `IS NULL` для быстрого Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°. +3. **DG-08 β†’ DG-10:** ΠŸΠΎΠ΄ΡΠ²Π΅Ρ‚ΠΊΠ° синтаксиса JSON/XML/YAML, Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ XML ΠΈ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΠ΅ ΠΏΠ°Π½Π΅Π»ΠΈ Calc. +4. **DG-11 β†’ DG-13:** Π˜Π΅Ρ€Π°Ρ€Ρ…ΠΈΡ‡Π΅ΡΠΊΠ°Ρ Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΠ° ΠΏΠΎ нСскольким ΠΊΠΎΠ»ΠΎΠ½ΠΊΠ°ΠΌ ΠΈ кастомныС Π°Π³Ρ€Π΅Π³Π°Ρ†ΠΈΠΈ Π² сводных Ρ‚Π°Π±Π»ΠΈΡ†Π°Ρ…. +5. **DG-14 β†’ DG-16:** ΠΠ΄Π°ΠΏΡ‚ΠΈΠ²Π½ΠΎΡΡ‚ΡŒ Ρ‚ΡƒΠ»Π±Π°Ρ€ΠΎΠ², ΠΏΡ€Π΅Π΄ΠΎΡ‚Π²Ρ€Π°Ρ‰Π΅Π½ΠΈΠ΅ layout overflow ΠΈ сквозныС тСсты. + +--- + +# Epic A: In-Place Editing & Database DML Synchronization + +## [DG-01](https://github.com/QueryaHub/Querya-Desktop/issues/564) (#564) β€” Data Grid DML SQL Generator (UPDATE, INSERT, DELETE builder with PK resolution) + +**Labels:** `data-grid`, `backend`, `sql-editor` +**Epic:** A +**Depends on:** none + +### Goal +Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ сСрвис `DataGridDmlGenerator`, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π½Π° основС ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ Π² `DataGridStagingBuffer` ΠΈ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Ρ… Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ (Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹, ΠΏΠ΅Ρ€Π²ΠΈΡ‡Π½Ρ‹Π΅ ΠΊΠ»ΡŽΡ‡ΠΈ / ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ) Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ бСзопасныС ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€ΠΈΠ·ΠΎΠ²Π°Π½Π½Ρ‹Π΅ ΠΈΠ»ΠΈ экранированныС SQL-выраТСния `UPDATE`, `INSERT` ΠΈ `DELETE`. + +### Context +`DataGridStagingBuffer` ΡƒΠΆΠ΅ Ρ…Ρ€Π°Π½ΠΈΡ‚ `_modifiedCells`, `_insertedRows` ΠΈ `_deletedRowIndices`. Однако Π² ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π΅ отсутствуСт ΠΌΠΎΠ΄ΡƒΠ»ΡŒ, ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΡƒΡŽΡ‰ΠΈΠΉ эти Π΄Π΅Π»ΡŒΡ‚Ρ‹ Π² ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹Π΅ SQL-запросы ΠΏΠΎΠ΄ Π΄ΠΈΠ°Π»Π΅ΠΊΡ‚Ρ‹ PostgreSQL, MySQL ΠΈ SQLite. + +### Implementation +1. Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `lib/features/main_screen/data_grid_dml_generator.dart`: + - МодСль `DmlStatement` (Ρ‚ΠΈΠΏ ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ `insert`/`update`/`delete`, SQL-тСкст, исходная строка). + - ΠœΠ΅Ρ‚ΠΎΠ΄ `generateDml({required String tableName, required List columns, required List primaryKeys, required DataGridStagingBuffer buffer, required SqlDialect dialect})`. + - Для `UPDATE`: Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ `UPDATE SET col1 = 'val1', col2 = 'val2' WHERE pk1 = 'old_pk1' AND ...`. + - Для `INSERT`: Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ `INSERT INTO
(col1, col2) VALUES ('val1', 'val2')`. + - Для `DELETE`: Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ `DELETE FROM
WHERE pk1 = 'pk1'`. + - ΠšΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎΠ΅ экранированиС спСцсимволов ΠΈ ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ ΠΏΠΎΠ΄ Ρ†Π΅Π»Π΅Π²ΠΎΠΉ Π΄ΠΈΠ°Π»Π΅ΠΊΡ‚ (Postgres `$1` / ΠΊΠ°Π²Ρ‹Ρ‡ΠΊΠΈ, MySQL backticks, SQLite). +2. ΠΠ°ΠΏΠΈΡΠ°Ρ‚ΡŒ ΠΌΠΎΠ΄ΡƒΠ»ΡŒΠ½Ρ‹Π΅ тСсты Π² `test/features/main_screen/data_grid_dml_generator_test.dart`. + +### Acceptance Criteria +- [ ] Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΡŽΡ‚ΡΡ Ρ‚ΠΎΡ‡Π½Ρ‹Π΅ SQL-запросы для Π΅Π΄ΠΈΠ½ΠΈΡ‡Π½Ρ‹Ρ… ΠΈ мноТСствСнных ΠΏΡ€Π°Π²ΠΎΠΊ ячССк. +- [ ] Если Π² строкС ΠΈΠ·ΠΌΠ΅Π½Π΅Π½Ρ‹ 3 ячСйки, гСнСрируСтся ΠΎΠ΄ΠΈΠ½ `UPDATE` со всСми 3 ΠΊΠΎΠ»ΠΎΠ½ΠΊΠ°ΠΌΠΈ Π² `SET`. +- [ ] Если primary key отсутствуСт, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ сопоставлСниС ΠΏΠΎ всСм исходным ΠΊΠΎΠ»ΠΎΠ½ΠΊΠ°ΠΌ строки (`WHERE col1 = 'old1' AND col2 = 'old2' ...`). +- [ ] ВСсты ΠΏΠΎΠΊΡ€Ρ‹Π²Π°ΡŽΡ‚ Π΄ΠΈΠ°Π»Π΅ΠΊΡ‚Ρ‹ PostgreSQL, MySQL ΠΈ SQLite. + +--- + +## [DG-02](https://github.com/QueryaHub/Querya-Desktop/issues/565) (#565) β€” Staging Buffer & Save Integration in SQL Workspaces + +**Labels:** `data-grid`, `frontend`, `sql-editor` +**Epic:** A +**Depends on:** DG-01 + +### Goal +ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ `DataGridStagingBuffer` ΠΈ ΠΊΠ½ΠΎΠΏΠΊΡƒ сохранСния ΠΊ Ρ€Π΅Π°Π»ΡŒΠ½Ρ‹ΠΌ воркспСйсам: `postgres_sql_workspace.dart`, `mysql_sql_workspace.dart`, `sqlite_sql_workspace.dart` ΠΈ `extension_sql_workspace.dart`. + +### Context +БСйчас `ResultsTab` Π² этих воркспСйсах вызываСтся Π±Π΅Π· ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‡ΠΈ `stagingBuffer` ΠΈ `onApplyChanges`, ΠΈΠ·-Π·Π° Ρ‡Π΅Π³ΠΎ Ρ‚ΡƒΠ»Π±Π°Ρ€ рСдактирования Π½Π΅ отобраТаСтся для Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ΠΎΠ² SQL-запросов. + +### Implementation +1. Π’ состояниях SQL-воркспСйсов (`_PostgresSqlWorkspaceState`, `_MysqlSqlWorkspaceState`, `_SqliteSqlWorkspaceState`) ΡΠΎΡ…Ρ€Π°Π½ΡΡ‚ΡŒ Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ `DataGridStagingBuffer? _stagingBuffer`. +2. ΠŸΡ€ΠΈ ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠΌ Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠΈ `SELECT`-запроса ΠΈΠ½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ `_stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows)`. +3. ΠŸΠ΅Ρ€Π΅Π΄Π°Π²Π°Ρ‚ΡŒ `stagingBuffer: _stagingBuffer`, `isSaving: _savingChanges` ΠΈ `onApplyChanges: _applyStagedChanges` Π² `ResultsTab`. +4. Π’ ΠΌΠ΅Ρ‚ΠΎΠ΄Π΅ `_applyStagedChanges`: + - ΠžΠΏΡ€Π΅Π΄Π΅Π»ΡΡ‚ΡŒ имя Ρ†Π΅Π»Π΅Π²ΠΎΠΉ Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ ΠΈΠ· Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½Π½ΠΎΠ³ΠΎ запроса (ΠΈΠ»ΠΈ Π·Π°ΠΏΡ€Π°ΡˆΠΈΠ²Π°Ρ‚ΡŒ Ρƒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ/схСмы). + - Π’Ρ‹Π·Ρ‹Π²Π°Ρ‚ΡŒ `DataGridDmlGenerator`. + - Π’Ρ‹ΠΏΠΎΠ»Π½ΡΡ‚ΡŒ сгСнСрированныС DML-запросы Π² Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΠΈ Ρ‡Π΅Ρ€Π΅Π· Π΄Ρ€Π°ΠΉΠ²Π΅Ρ€ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ. + - ΠŸΡ€ΠΈ успСхС: ΠΎΠ±Π½ΠΎΠ²Π»ΡΡ‚ΡŒ Π±Π°Π·ΠΎΠ²Ρ‹Π΅ строки `_rows`, ΡΠ±Ρ€Π°ΡΡ‹Π²Π°Ρ‚ΡŒ `_stagingBuffer` ΠΈ Π²Ρ‹Π²ΠΎΠ΄ΠΈΡ‚ΡŒ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎ сохранСнных строках. + - ΠŸΡ€ΠΈ ошибкС: ΠΎΡ‚ΠΊΠ°Ρ‚Ρ‹Π²Π°Ρ‚ΡŒ Ρ‚Ρ€Π°Π½Π·Π°ΠΊΡ†ΠΈΡŽ ΠΈ Π²Ρ‹Π²ΠΎΠ΄ΠΈΡ‚ΡŒ Π²ΡΠΏΠ»Ρ‹Π²Π°ΡŽΡ‰ΡƒΡŽ ΠΎΡˆΠΈΠ±ΠΊΡƒ Π±Π΅Π· ΠΏΠΎΡ‚Π΅Ρ€ΠΈ ΠΏΡ€Π°Π²ΠΎΠΊ Π² Π±ΡƒΡ„Π΅Ρ€Π΅. + +### Acceptance Criteria +- [ ] ПослС выполнСния `SELECT * FROM table` доступно Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ячССк, Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ строк ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ строк. +- [ ] ΠŸΡ€ΠΈ Π½Π°ΠΆΠ°Ρ‚ΠΈΠΈ ΠΊΠ½ΠΎΠΏΠΊΠΈ *Save Changes* ΠΈΠ»ΠΈ Ctrl+S / ⌘S измСнСния Π°Ρ‚ΠΎΠΌΠ°Ρ€Π½ΠΎ ΠΏΡ€ΠΈΠΌΠ΅Π½ΡΡŽΡ‚ΡΡ ΠΊ Π±Π°Π·Π΅ Π΄Π°Π½Π½Ρ‹Ρ…. +- [ ] ПослС ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠ³ΠΎ сохранСния статус Π±ΡƒΡ„Π΅Ρ€Π° становится clean (`No changes`). +- [ ] ΠŸΡ€ΠΈ ошибкС Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… Π±ΡƒΡ„Π΅Ρ€ Π½Π΅ очищаСтся, ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ Π²ΠΈΠ΄ΠΈΡ‚ тСкст ошибки ΠΈ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΈΡΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π΄Π°Π½Π½Ρ‹Π΅. + +--- + +## [DG-03](https://github.com/QueryaHub/Querya-Desktop/issues/566) (#566) β€” DML Preview & Confirmation Modal Dialog + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** A +**Depends on:** DG-01, DG-02 + +### Goal +ΠŸΡ€Π΅Π΄ΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ ΠΈ ΡΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ сгСнСрированныС SQL DML-запросы ΠΏΠ΅Ρ€Π΅Π΄ ΠΈΡ… ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ Π² Π±Π°Π·Ρƒ Π΄Π°Π½Π½Ρ‹Ρ…. + +### Context +ΠŸΡ€ΠΈ массовом Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠΈ ΠΈΠ»ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ строк ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ Π²Π°ΠΆΠ½ΠΎ Π²ΠΈΠ΄Π΅Ρ‚ΡŒ Ρ‚ΠΎΡ‡Π½Ρ‹Π΅ `UPDATE`/`DELETE` инструкции для прСдотвращСния случайной пСрСзаписи ΠΈΠ»ΠΈ ΠΏΠΎΡ‚Π΅Ρ€ΠΈ Π΄Π°Π½Π½Ρ‹Ρ…. + +### Implementation +1. Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `lib/features/main_screen/data_grid_dml_preview_dialog.dart`: + - Бписок сгСнСрированных SQL-запросов с подсвСткой синтаксиса. + - ΠžΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ количСства Π·Π°Ρ‚Ρ€Π°Π³ΠΈΠ²Π°Π΅ΠΌΡ‹Ρ… записСй (X updates, Y inserts, Z deletes). + - Кнопки: *Cancel*, *Copy SQL*, *Execute SQL*. +2. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ настройку Π² Preferences: `Confirm before applying Data Grid changes` (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ Π²ΠΊΠ»ΡŽΡ‡Π΅Π½Π°). +3. Π˜Π½Ρ‚Π΅Π³Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π΄ΠΈΠ°Π»ΠΎΠ³ Π² `onApplyChanges` ΠΏΠΎΡ‚ΠΎΠΊ. + +### Acceptance Criteria +- [ ] ΠŸΡ€ΠΈ Π½Π°ΠΆΠ°Ρ‚ΠΈΠΈ *Save Changes* открываСтся модальноС ΠΎΠΊΠ½ΠΎ с тСкстом SQL-запросов. +- [ ] ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΠΌΠΎΠΆΠ΅Ρ‚ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ запросы ΠΈΠ»ΠΈ сразу ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄ΠΈΡ‚ΡŒ ΠΈΡ… Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅. +- [ ] Если Π² настройках ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΎ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½ΠΈΠ΅, сохранСниС происходит сразу. + +--- + +## [DG-04](https://github.com/QueryaHub/Querya-Desktop/issues/567) (#567) β€” In-Cell Data Type Validation and Editor Shortcuts + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** A +**Depends on:** none + +### Goal +Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΡŽ Ρ‚ΠΈΠΏΠΎΠ² Π²Π²ΠΎΠ΄ΠΈΠΌΡ‹Ρ… Π΄Π°Π½Π½Ρ‹Ρ… Π² ΠΈΠ½Π»Π°ΠΉΠ½-Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π΅ `GridCellEditor` (числа, Π΄Π°Ρ‚Ρ‹, Π±ΡƒΠ»Π΅Π²Ρ‹ значСния, JSON) ΠΈ Ρ€Π°ΡΡˆΠΈΡ€ΠΈΡ‚ΡŒ Ρ…ΠΎΡ‚ΠΊΠ΅ΠΈ Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ. + +### Context +БСйчас ячСйка ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅Ρ‚ Π»ΡŽΠ±ΡƒΡŽ строку. Если ΠΊΠΎΠ»ΠΎΠ½ΠΊΠ° числовая (`INT`, `FLOAT`), Π° ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ Π²Π²Π΅Π» тСкст, ошибка Π²ΠΎΠ·Π½ΠΈΠΊΠ½Π΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½Π° этапС сохранСния Π² Π‘Π”. + +### Implementation +1. ΠŸΠ΅Ρ€Π΅Π΄Π°Π²Π°Ρ‚ΡŒ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Π΅ Ρ‚ΠΈΠΏΠΎΠ² ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ (Ссли доступны ΠΈΠ· Π΄Ρ€Π°ΠΉΠ²Π΅Ρ€Π°) Π² `GridCellEditor`. +2. Если ячСйка числовая, ΠΏΠΎΠ΄ΡΠ²Π΅Ρ‡ΠΈΠ²Π°Ρ‚ΡŒ Π½Π΅ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ Π²Π²ΠΎΠ΄ красной Ρ€Π°ΠΌΠΊΠΎΠΉ ΠΈ Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½ΠΈΠ΅ ΠΈΠ»ΠΈ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ tooltip. +3. Для boolean ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ быстрый ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»ΡŒ ΠΏΠΎ ΠΊΠ»ΠΈΠΊΡƒ ΠΈΠ»ΠΈ Π½Π°ΠΆΠ°Ρ‚ΠΈΡŽ Space (`true` / `false` / `NULL`). +4. ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ быстрый ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ ΠΏΠΎ стрСлкам Π²Π²Π΅Ρ€Ρ…/Π²Π½ΠΈΠ· ΠΏΡ€ΠΈ Π·Π°Π²Π΅Ρ€ΡˆΠ΅Π½ΠΈΠΈ рСдактирования ΠΏΠΎ Enter. + +### Acceptance Criteria +- [ ] ЧисловыС поля Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΡŽΡ‚ΡΡ Π½Π° ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π΅ ΠΏΠ΅Ρ€Π΅Π΄ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΎΠΉ Π² Π±ΡƒΡ„Π΅Ρ€. +- [ ] Π‘ΡƒΠ»Π΅Π²Ρ‹ поля ΠΌΠΎΠΆΠ½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ клавишСй Space. +- [ ] ВсС Ρ…ΠΎΡ‚ΠΊΠ΅ΠΈ Ρ€Π°Π±ΠΎΡ‚Π°ΡŽΡ‚ прСдсказуСмо ΠΈ ΠΏΠΎΠΊΡ€Ρ‹Ρ‚Ρ‹ тСстами. + +--- + +# Epic B: Advanced Quick Filter Engine + +## [DG-05](https://github.com/QueryaHub/Querya-Desktop/issues/568) (#568) β€” Multi-Clause Query Parser with AND / OR and Parentheses + +**Labels:** `data-grid`, `parser`, `frontend` +**Epic:** B +**Depends on:** none + +### Goal +Π Π°ΡΡˆΠΈΡ€ΠΈΡ‚ΡŒ `GridFilterEngine` для ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΈ слоТных составных Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ с логичСскими связками `AND`, `OR` ΠΈ Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΡƒΡŽΡ‰ΠΈΠΌΠΈ скобками `(...)`. + +### Context +БСйчас `GridFilterEngine._parsePredicate` ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄ΠΈΠ½ ΠΏΡ€Π΅Π΄ΠΈΠΊΠ°Ρ‚ Π²ΠΈΠ΄Π° `col op val`. Запросы Π²Ρ€ΠΎΠ΄Π΅ `status = 'ACTIVE' AND amount > 100` ΠΈΠ»ΠΈ `dept = 'IT' OR dept = 'HR'` Π½Π΅ парсятся ΠΊΠ°ΠΊ ΠΏΡ€Π΅Π΄ΠΈΠΊΠ°Ρ‚Ρ‹ ΠΈ ΡΠ²Π°Π»ΠΈΠ²Π°ΡŽΡ‚ΡΡ Π² простой поиск ΠΏΠΎ подстрокС. + +### Implementation +1. Π’ `lib/features/main_screen/grid_filter_engine.dart` ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ лСксСр ΠΈ рСкурсивный AST-парсСр Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ: + - Π’ΠΎΠΊΠ΅Π½Ρ‹: `Identifier`, `StringLiteral`, `NumberLiteral`, `Operator` (`=`, `!=`, `<`, `<=`, `>`, `>=`, `:`, `LIKE`, `ILIKE`, `IN`, `IS`), `LogicalOp` (`AND`, `OR`, `NOT`), `OpenParen`, `CloseParen`. + - ΠŸΠΎΡΡ‚Ρ€ΠΎΠ΅Π½ΠΈΠ΅ Π΄Π΅Ρ€Π΅Π²Π° условий `FilterExpressionNode` (`BinaryNode`, `ComparisonNode`, `NotNode`, `TextSearchNode`). + - ВычислСниС ΠΏΡ€Π΅Π΄ΠΈΠΊΠ°Ρ‚Π° для ΠΊΠ°ΠΆΠ΄ΠΎΠΉ строки: `node.evaluate(row, columnIndices)`. +2. ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ рСгистронСзависимыС ΠΊΠ»ΡŽΡ‡Π΅Π²Ρ‹Π΅ слова (`and`, `AND`, `or`, `OR`). +3. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹Π΅ тСсты Π² `test/features/main_screen/data_grid_engines_test.dart`. + +### Acceptance Criteria +- [ ] ΠšΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ Π²Ρ‹Ρ‡ΠΈΡΠ»ΡΡŽΡ‚ΡΡ выраТСния Π²ΠΈΠ΄Π°: `status = ACTIVE AND amount > 100`. +- [ ] ΠšΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ Π²Ρ‹Ρ‡ΠΈΡΠ»ΡΡŽΡ‚ΡΡ выраТСния с `OR` ΠΈ скобками: `(role = admin OR role = manager) AND active = true`. +- [ ] ΠŸΡ€ΠΈ синтаксичСской ошибкС Π² Π²Ρ‹Ρ€Π°ΠΆΠ΅Π½ΠΈΠΈ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ ΠΏΠ»Π°Π²Π½ΠΎ ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ΡΡ Π² Ρ€Π΅ΠΆΠΈΠΌ полнотСкстового поиска Π±Π΅Π· ΠΈΡΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠΉ ΠΈ ΠΏΠ°Π΄Π΅Π½ΠΈΠΉ. + +--- + +## [DG-06](https://github.com/QueryaHub/Querya-Desktop/issues/569) (#569) β€” String Literals, Escaped Quotes and Extended Operators (`IN`, `LIKE`, `IS NULL`, `BETWEEN`) + +**Labels:** `data-grid`, `parser`, `frontend` +**Epic:** B +**Depends on:** DG-05 + +### Goal +Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΡƒ строковых Π»ΠΈΡ‚Π΅Ρ€Π°Π»ΠΎΠ² Π² ΠΊΠ°Π²Ρ‹Ρ‡ΠΊΠ°Ρ… (с ΠΏΡ€ΠΎΠ±Π΅Π»Π°ΠΌΠΈ), Π° Ρ‚Π°ΠΊΠΆΠ΅ ΠΎΠΏΠ΅Ρ€Π°Ρ‚ΠΎΡ€ΠΎΠ² `IN (...)`, `LIKE`, `ILIKE`, `IS NULL`, `IS NOT NULL`, `BETWEEN`. + +### Context +ЗначСния часто содСрТат ΠΏΡ€ΠΎΠ±Π΅Π»Ρ‹ (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `city = 'New York'` ΠΈΠ»ΠΈ `name LIKE '%John%'`). Π‘Π΅Π· ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ Ρ‚Π°ΠΊΠΈΠ΅ выраТСния Π½Π΅Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ Ρ€Π°ΡΠΏΠ°Ρ€ΡΠΈΡ‚ΡŒ ΠΎΠ΄Π½ΠΎΠ·Π½Π°Ρ‡Π½ΠΎ. + +### Implementation +1. ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ строки Π² ΠΎΠ΄ΠΈΠ½Π°Ρ€Π½Ρ‹Ρ… `'...'` ΠΈ Π΄Π²ΠΎΠΉΠ½Ρ‹Ρ… `"..."` ΠΊΠ°Π²Ρ‹Ρ‡ΠΊΠ°Ρ…. +2. Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΠΏΠ΅Ρ€Π°Ρ‚ΠΎΡ€Ρ‹: + - `col IN ('A', 'B', 'C')` / `col NOT IN (...)` + - `col LIKE '%pattern%'` (с ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΎΠΉ `%` ΠΈ `_`) + - `col ILIKE '%pattern%'` (рСгистронСзависимый) + - `col IS NULL` ΠΈ `col IS NOT NULL` + - `col BETWEEN 10 AND 50` +3. Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ экранированиС ΠΊΠ°Π²Ρ‹Ρ‡Π΅ΠΊ (`\'`, `\"`). + +### Acceptance Criteria +- [ ] Запрос `city = 'New York'` Π½Π°Ρ…ΠΎΠ΄ΠΈΡ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ строки со Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ΠΌ `New York` Π² ΠΊΠΎΠ»ΠΎΠ½ΠΊΠ΅ `city`. +- [ ] Запрос `email LIKE '%@gmail.com'` ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ΡƒΠ΅Ρ‚ ΠΏΠΎΡ‡Ρ‚ΠΎΠ²Ρ‹Π΅ адрСса. +- [ ] Запрос `deleted_at IS NULL` Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ΡƒΠ΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ `NULL` ΠΈΠ»ΠΈ пустыС значСния. + +--- + +## [DG-07](https://github.com/QueryaHub/Querya-Desktop/issues/570) (#570) β€” Autocomplete & Syntax Suggestions in Filter Bar + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** B +**Depends on:** DG-05 + +### Goal +Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π²ΡΠΏΠ»Ρ‹Π²Π°ΡŽΡ‰ΠΈΠ΅ подсказки (Π°Π²Ρ‚ΠΎΠΊΠΎΠΌΠΏΠ»ΠΈΡ‚) Π² `DataGridFilterBar` ΠΏΡ€ΠΈ Π²Π²ΠΎΠ΄Π΅ Π½Π°Π·Π²Π°Π½ΠΈΠΉ ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ, ΠΎΠΏΠ΅Ρ€Π°Ρ‚ΠΎΡ€ΠΎΠ² ΠΈ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Ρ… Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ. + +### Context +ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ ΡƒΠ΄ΠΎΠ±Π½ΠΎ Π²ΠΈΠ΄Π΅Ρ‚ΡŒ доступныС ΠΈΠΌΠ΅Π½Π° ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ ΠΈ подсказки ΠΏΠΎ ΠΎΠΏΠ΅Ρ€Π°Ρ‚ΠΎΡ€Π°ΠΌ сразу ΠΏΡ€ΠΈ Π½Π°Π±ΠΎΡ€Π΅ Π² строкС Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°. + +### Implementation +1. Π Π°Π·Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ ΠΎΠ²Π΅Ρ€Π»Π΅ΠΉ подсказок `DataGridFilterAutocomplete`: + - ΠŸΡ€ΠΈ Π²Π²ΠΎΠ΄Π΅ ΠΏΠ΅Ρ€Π²ΠΎΠ³ΠΎ слова ΠΏΡ€Π΅Π΄Π»Π°Π³Π°Ρ‚ΡŒ список ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹. + - ПослС ΠΈΠΌΠ΅Π½ΠΈ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ ΠΏΡ€Π΅Π΄Π»Π°Π³Π°Ρ‚ΡŒ список допустимых ΠΎΠΏΠ΅Ρ€Π°Ρ‚ΠΎΡ€ΠΎΠ² (`=`, `!=`, `>`, `<`, `LIKE`, `IN`). + - ПослС ΠΎΠΏΠ΅Ρ€Π°Ρ‚ΠΎΡ€Π° ΠΏΡ€Π΅Π΄Π»Π°Π³Π°Ρ‚ΡŒ Ρ‚ΠΎΠΏ-5 ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Ρ… Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ ΠΈΠ· Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΡ… Π΄Π°Π½Π½Ρ‹Ρ… этой ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ. +2. Навигация ΠΏΠΎ подсказкам клавишами ↑ / ↓ ΠΈ Π²Ρ‹Π±ΠΎΡ€ ΠΏΠΎ Enter ΠΈΠ»ΠΈ Tab. + +### Acceptance Criteria +- [ ] ΠŸΡ€ΠΈ Π½Π°Π±ΠΎΡ€Π΅ ΠΏΠ΅Ρ€Π²Ρ‹Ρ… Π±ΡƒΠΊΠ² ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ всплываСт список подсказок. +- [ ] Подсказки ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ Π²ΡΡ‚Π°Π²Π»ΡΡŽΡ‚ΡΡ ΠΏΠΎ Π½Π°ΠΆΠ°Ρ‚ΠΈΡŽ Tab / Enter. +- [ ] ΠžΠ²Π΅Ρ€Π»Π΅ΠΉ Π½Π΅ ΠΌΠ΅ΡˆΠ°Π΅Ρ‚ ΠΏΡ€ΠΎΠΊΡ€ΡƒΡ‚ΠΊΠ΅ Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ ΠΈ закрываСтся ΠΏΠΎ Esc. + +--- + +# Epic C: Value Inspector & Selection Calculations + +## [DG-08](https://github.com/QueryaHub/Querya-Desktop/issues/571) (#571) β€” Color Syntax Highlighting for JSON, XML, HTML, SQL in Value Panel + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** C +**Depends on:** none + +### Goal +Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ Ρ†Π²Π΅Ρ‚Π½ΡƒΡŽ подсвСтку синтаксиса (Syntax Highlighting) Π² `DataGridValuePanel` ΠΈ `GridCellPopoverInspector` Π² соотвСтствии с Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠΉ Ρ‚Π΅ΠΌΠΎΠΉ оформлСния. + +### Context +БСйчас Π΄Π»ΠΈΠ½Π½Ρ‹Π΅ строки JSON/XML Π² инспСкторС Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ отступами, Π½ΠΎ ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ°ΡŽΡ‚ΡΡ ΠΌΠΎΠ½ΠΎΡ…Ρ€ΠΎΠΌΠ½Ρ‹ΠΌ тСкстом Π±Π΅Π· Ρ†Π²Π΅Ρ‚ΠΎΠ²ΠΎΠ³ΠΎ выдСлСния ΠΊΠ»ΡŽΡ‡Π΅ΠΉ, строковых Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ, чисСл, Ρ‚Π΅Π³ΠΎΠ² ΠΈ Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ΠΎΠ². + +### Implementation +1. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ лСгковСсный парсСр/Ρ…Π°ΠΉΠ»Π°ΠΉΡ‚Π΅Ρ€ Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² ΠΈΠ»ΠΈ `RichText` / `TextSpan` ΠΏΠΎΡΡ‚Ρ€ΠΎΠΈΡ‚Π΅Π»ΡŒ: + - JSON: подсвСтка ΠΊΠ»ΡŽΡ‡Π΅ΠΉ, строк, чисСл, boolean ΠΈ null Ρ€Π°Π·Π½Ρ‹ΠΌΠΈ Ρ†Π²Π΅Ρ‚Π°ΠΌΠΈ ΠΈΠ· Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΉ ΠΏΠ°Π»ΠΈΡ‚Ρ€Ρ‹ `QueryaTheme`. + - XML/HTML: подсвСтка Ρ‚Π΅Π³ΠΎΠ², Π½Π°Π·Π²Π°Π½ΠΈΠΉ Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ΠΎΠ², строковых Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ΠΎΠ², ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠ΅Π². + - SQL: подсвСтка ΠΊΠ»ΡŽΡ‡Π΅Π²Ρ‹Ρ… слов, Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΉ ΠΈ строк. +2. АвтоматичСски ΠΎΠΏΡ€Π΅Π΄Π΅Π»ΡΡ‚ΡŒ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ Π΄Π°Π½Π½Ρ‹Ρ… ячСйки (JSON, XML, SQL, Plain text) ΠΈΠ»ΠΈ ΠΏΡ€Π΅Π΄ΠΎΡΡ‚Π°Π²Π»ΡΡ‚ΡŒ ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»ΡŒ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π° Π² шапкС ΠΏΠ°Π½Π΅Π»ΠΈ. +3. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ ΠΏΡ€ΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ для Π±ΠΎΠ»ΡŒΡˆΠΈΡ… Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚ΠΎΠ² (Π΄ΠΎ 1-5 ΠœΠ‘). + +### Acceptance Criteria +- [ ] JSON-структуры ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ°ΡŽΡ‚ΡΡ с подсвСткой ΠΊΠ»ΡŽΡ‡Π΅ΠΉ, строк ΠΈ чисСл. +- [ ] XML-Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Ρ‹ ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ°ΡŽΡ‚ΡΡ с подсвСткой Ρ‚Π΅Π³ΠΎΠ² ΠΈ Π°Ρ‚Ρ€ΠΈΠ±ΡƒΡ‚ΠΎΠ². +- [ ] Π¦Π²Π΅Ρ‚Π° Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² Π°Π΄Π°ΠΏΡ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ ΠΏΠΎΠ΄ Ρ‚Π΅ΠΌΠ½ΡƒΡŽ ΠΈ ΡΠ²Π΅Ρ‚Π»ΡƒΡŽ Ρ‚Π΅ΠΌΡƒ Querya. + +--- + +## [DG-09](https://github.com/QueryaHub/Querya-Desktop/issues/572) (#572) β€” XML / HTML Formatter and Validator in Value Panel + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** C +**Depends on:** none + +### Goal +Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠ½ΠΎΠΏΠΊΠΈ форматирования (pretty-print) ΠΈ Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΈ для XML ΠΈ HTML ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚Π° Π² `DataGridValuePanel` Π°Π½Π°Π»ΠΎΠ³ΠΈΡ‡Π½ΠΎ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰Π΅ΠΌΡƒ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡŽ JSON. + +### Context +Π’ `DataGridValuePanel` Π΅ΡΡ‚ΡŒ ΠΊΠ½ΠΎΠΏΠΊΠΈ `Format JSON` ΠΈ `Minify`. Для XML-Π΄Π°Π½Π½Ρ‹Ρ… (SOAP-ΠΎΡ‚Π²Π΅Ρ‚Ρ‹, ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ) Ρ‚Π°ΠΊΠΈΡ… инструмСнтов Π½Π΅Ρ‚. + +### Implementation +1. Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ лСгковСсный ΡƒΡ‚ΠΈΠ»ΠΈΡ‚Π½Ρ‹ΠΉ класс `XmlFormatter`: + - АвтоматичСская расстановка отступов (indent = 2 ΠΏΡ€ΠΎΠ±Π΅Π»Π°). + - ΠœΠΈΠ½ΠΈΡ„ΠΈΠΊΠ°Ρ†ΠΈΡ XML (ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ Π»ΠΈΡˆΠ½ΠΈΡ… ΠΏΡ€ΠΎΠ±Π΅Π»ΠΎΠ² ΠΌΠ΅ΠΆΠ΄Ρƒ Ρ‚Π΅Π³Π°ΠΌΠΈ). + - Базовая валидация закрытия Ρ‚Π΅Π³ΠΎΠ² с подсвСткой строки с ошибкой. +2. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π² Ρ‚ΡƒΠ»Π±Π°Ρ€ ΠΏΠ°Π½Π΅Π»ΠΈ ΠΊΠ½ΠΎΠΏΠΊΡƒ `Format XML` / `Format Document`, которая автоматичСски опрСдСляСт JSON ΠΈΠ»ΠΈ XML ΠΈ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΡƒΠ΅Ρ‚ Π΅Π³ΠΎ. + +### Acceptance Criteria +- [ ] НСформатированный XML Π°ΠΊΠΊΡƒΡ€Π°Ρ‚Π½ΠΎ разбиваСтся ΠΏΠΎ строкам с отступами. +- [ ] ΠŸΡ€ΠΈ синтаксичСской ошибкС Π² XML выводится понятноС сообщСниС Π±Π΅Π· сбоя UI. + +--- + +## [DG-10](https://github.com/QueryaHub/Querya-Desktop/issues/573) (#573) β€” Advanced Selection Calculations (Median, Distinct Count, Copy Stats) + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** C +**Depends on:** none + +### Goal +Π Π°ΡΡˆΠΈΡ€ΠΈΡ‚ΡŒ `GridSelectionCalcEngine` ΠΈ `DataGridCalcBar` Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌΠΈ статистичСскими ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠ°ΠΌΠΈ ΠΈ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒΡŽ копирования сводки. + +### Context +БСйчас `GridSelectionCalcEngine` считаСт: `Count`, `NULLs`, `Sum`, `Avg`, `Min`, `Max`. ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌ часто Ρ‚Ρ€Π΅Π±ΡƒΡŽΡ‚ΡΡ `Distinct Count` (количСство ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Ρ… Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ) ΠΈ `Median`. + +### Implementation +1. Π’ `lib/features/main_screen/grid_selection_calc_engine.dart`: + - Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ расчСт `distinctCount` (Set Ρ€Π°Π·ΠΌΠ΅Ρ€). + - Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ расчСт `median` (для числовых рядов). +2. Π’ `DataGridCalcBar`: + - Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π±Π΅ΠΉΠ΄ΠΆ `Distinct: X`. + - Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π±Π΅ΠΉΠ΄ΠΆ `Median: Y` (Ссли Π²Ρ‹Π΄Π΅Π»Π΅Π½ΠΎ >= 2 чисСл). + - Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠ½ΠΎΠΏΠΊΡƒ контСкстного мСню / копирования ΠΏΠΎΠ»Π½ΠΎΠΉ сводки: `Copy All Stats (Summary)`. + +### Acceptance Criteria +- [ ] ΠŸΡ€ΠΈ Π²Ρ‹Π΄Π΅Π»Π΅Π½ΠΈΠΈ ΡΠΌΠ΅ΡˆΠ°Π½Π½Ρ‹Ρ… строк ΠΈΠ»ΠΈ чисСл ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ вычисляСтся количСство ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Ρ… Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ. +- [ ] Для числовых Π·Π½Π°Ρ‡Π΅Π½ΠΈΠΉ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ вычисляСтся ΠΌΠ΅Π΄ΠΈΠ°Π½Π°. +- [ ] ΠŸΡ€ΠΈ ΠΊΠ»ΠΈΠΊΠ΅ Π½Π° "Copy All Stats" Π² Π±ΡƒΡ„Π΅Ρ€ копируСтся Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹ΠΉ тСкст со всСми ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠ°ΠΌΠΈ. + +--- + +# Epic D: Groupings & Pivot View + +## [DG-11](https://github.com/QueryaHub/Querya-Desktop/issues/574) (#574) β€” Multi-Column Hierarchical Grouping (Nested Group By) + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** D +**Depends on:** none + +### Goal +Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ ΠΌΠ½ΠΎΠ³ΠΎΡƒΡ€ΠΎΠ²Π½Π΅Π²ΡƒΡŽ Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΡƒ ΠΏΠΎ Π΄Π²ΡƒΠΌ ΠΈ Π±ΠΎΠ»Π΅Π΅ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠ°ΠΌ Π² `DataGridGroupingsView` (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `Group by: Department β†’ Job Title`). + +### Context +БСйчас `GridGroupingsEngine` ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅Ρ‚ Π²Ρ‹Π±ΠΎΡ€ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄Π½ΠΎΠΉ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ. Π’ аналитичСских сцСнариях трСбуСтся ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ Π²Π»ΠΎΠΆΠ΅Π½Π½Ρ‹Π΅ Π΄Π΅Ρ€Π΅Π²ΡŒΡ Π³Ρ€ΡƒΠΏΠΏ. + +### Implementation +1. Π’ `lib/features/main_screen/grid_groupings_engine.dart`: + - ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ `buildGroups` для ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΈ списка индСксов ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ `List colIndices`. + - РСкурсивная структура `GroupedCategory`: ΠΏΠΎΠ»Π΅ `List? subGroups`. +2. Π’ `lib/features/main_screen/data_grid_groupings_view.dart`: + - Π’ΡƒΠ»Π±Π°Ρ€ с Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒΡŽ добавлСния Π½Π΅ΡΠΊΠΎΠ»ΡŒΠΊΠΈΡ… Ρ‡ΠΈΠΏΠΎΠ² ΠΊΠΎΠ»ΠΎΠ½ΠΎΠΊ (Add Grouping Column). + - РСкурсивноС ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ Π΄Π΅Ρ€Π΅Π²Π° Π°ΠΊΠΊΠΎΡ€Π΄Π΅ΠΎΠ½ΠΎΠ² с уровнями влоТСнности. + - Кнопки *Expand All* / *Collapse All*. + +### Acceptance Criteria +- [ ] ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΠΌΠΎΠΆΠ΅Ρ‚ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ 2+ ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ для Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΠΈ. +- [ ] Π’Π»ΠΎΠΆΠ΅Π½Π½Ρ‹Π΅ Π³Ρ€ΡƒΠΏΠΏΡ‹ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ Ρ€Π°ΡΠΊΡ€Ρ‹Π²Π°ΡŽΡ‚ΡΡ с ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ΠΌ строк Π½ΠΈΠΆΠ½Π΅Π³ΠΎ уровня. +- [ ] ΠŸΡ€ΠΎΡ†Π΅Π½Ρ‚Ρ‹ ΠΈ количСства Ρ€Π°ΡΡΡ‡ΠΈΡ‚Ρ‹Π²Π°ΡŽΡ‚ΡΡ ΠΊΠ°ΠΊ для Ρ€ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΡΠΊΠΈΡ…, Ρ‚Π°ΠΊ ΠΈ для Π΄ΠΎΡ‡Π΅Ρ€Π½ΠΈΡ… Π³Ρ€ΡƒΠΏΠΏ. + +--- + +## [DG-12](https://github.com/QueryaHub/Querya-Desktop/issues/575) (#575) β€” Custom Aggregations in Groupings (SUM, AVG, MIN, MAX over columns) + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** D +**Depends on:** DG-11 + +### Goal +ΠŸΠΎΠ·Π²ΠΎΠ»ΠΈΡ‚ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ Π²Ρ‹Π±ΠΈΡ€Π°Ρ‚ΡŒ Ρ‡ΠΈΡΠ»ΠΎΠ²ΡƒΡŽ ΠΊΠΎΠ»ΠΎΠ½ΠΊΡƒ ΠΈ Π°Π³Ρ€Π΅Π³Π°Ρ‚Π½ΡƒΡŽ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΡŽ (`SUM`, `AVG`, `MIN`, `MAX`) для расчСта ΠΏΠΎΠΊΠ°Π·Π°Ρ‚Π΅Π»Π΅ΠΉ Π²Π½ΡƒΡ‚Ρ€ΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ Π³Ρ€ΡƒΠΏΠΏΡ‹. + +### Context +БСйчас Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΠ° ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ°Π΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ количСство записСй (`X rows, Y%`). Для Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠΈ Π²Π°ΠΆΠ½ΠΎ Π²ΠΈΠ΄Π΅Ρ‚ΡŒ, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€, `SUM(salary)` ΠΈΠ»ΠΈ `AVG(duration)` ΠΏΠΎ Π³Ρ€ΡƒΠΏΠΏΠ°ΠΌ. + +### Implementation +1. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ сСлСкторы Π² Ρ‚ΡƒΠ»Π±Π°Ρ€ Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΠΈ: + - Π’Ρ‹ΠΏΠ°Π΄Π°ΡŽΡ‰ΠΈΠΉ список Π²Ρ‹Π±ΠΎΡ€Π° Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ Π°Π³Ρ€Π΅Π³Π°Ρ†ΠΈΠΈ: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`. + - Π’Ρ‹ΠΏΠ°Π΄Π°ΡŽΡ‰ΠΈΠΉ список Π²Ρ‹Π±ΠΎΡ€Π° Ρ†Π΅Π»Π΅Π²ΠΎΠΉ числовой ΠΊΠΎΠ»ΠΎΠ½ΠΊΠΈ для Π°Π³Ρ€Π΅Π³Π°Ρ†ΠΈΠΈ. +2. Π’ `GroupedCategory` ΡΠΎΡ…Ρ€Π°Π½ΡΡ‚ΡŒ вычислСнноС Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ Π°Π³Ρ€Π΅Π³Π°Ρ‚Π°. +3. ΠžΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ°Ρ‚ΡŒ Π°Π³Ρ€Π΅Π³Π°Ρ‚ рядом с количСством строк Π² Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ΅ Π³Ρ€ΡƒΠΏΠΏΡ‹ (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€: `Engineering β€” 42 rows | Sum(salary): $350,000`). + +### Acceptance Criteria +- [ ] ΠŸΡ€ΠΈ Π²Ρ‹Π±ΠΎΡ€Π΅ `SUM(amount)` для ΠΊΠ°ΠΆΠ΄ΠΎΠΉ Π³Ρ€ΡƒΠΏΠΏΡ‹ рассчитываСтся точная сумма. +- [ ] ΠšΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°ΡŽΡ‚ΡΡ `NULL` ΠΈ нСчисловыС значСния (ΠΈΠ³Π½ΠΎΡ€ΠΈΡ€ΡƒΡŽΡ‚ΡΡ). +- [ ] ЗначСния Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ с ΡƒΡ‡Π΅Ρ‚ΠΎΠΌ разрядов чисСл. + +--- + +## [DG-13](https://github.com/QueryaHub/Querya-Desktop/issues/576) (#576) β€” Sort Groups by Column Key, Count or Aggregate Value & Export Pivot + +**Labels:** `data-grid`, `frontend`, `ux` +**Epic:** D +**Depends on:** DG-12 + +### Goal +Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ сортировку сформированных Π³Ρ€ΡƒΠΏΠΏ (ΠΏΠΎ ΠΈΠΌΠ΅Π½ΠΈ Π³Ρ€ΡƒΠΏΠΏΡ‹ А-Π― / Π―-А, ΠΏΠΎ количСству строк, ΠΏΠΎ Π·Π½Π°Ρ‡Π΅Π½ΠΈΡŽ Π°Π³Ρ€Π΅Π³Π°Ρ‚Π°) ΠΈ экспорт сводной Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ Π² CSV / JSON. + +### Context +БСйчас Π³Ρ€ΡƒΠΏΠΏΡ‹ всСгда ΡΠΎΡ€Ρ‚ΠΈΡ€ΡƒΡŽΡ‚ΡΡ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΠΎ количСству записСй ΠΏΠΎ ΡƒΠ±Ρ‹Π²Π°Π½ΠΈΡŽ (`count desc`). ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌ трСбуСтся Π°Π»Ρ„Π°Π²ΠΈΡ‚Π½Ρ‹ΠΉ порядок ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΉ ΠΈΠ»ΠΈ экспорт ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½Π½ΠΎΠ³ΠΎ ΠΎΡ‚Ρ‡Π΅Ρ‚Π°. + +### Implementation +1. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»Ρ‹ сортировки Π³Ρ€ΡƒΠΏΠΏ: `Order by: Count ↓ / Count ↑ / Key A-Z / Key Z-A / Agg Value ↓`. +2. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠ½ΠΎΠΏΠΊΡƒ *Export Groupings* Π² Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Ρ‹ CSV, JSON, Markdown Table. + +### Acceptance Criteria +- [ ] Π“Ρ€ΡƒΠΏΠΏΡ‹ ΠΌΠ³Π½ΠΎΠ²Π΅Π½Π½ΠΎ ΠΏΠ΅Ρ€Π΅ΡΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Ρ‹Π²Π°ΡŽΡ‚ΡΡ ΠΏΡ€ΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ порядка сортировки. +- [ ] Экспорт Ρ„ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅Ρ‚ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ Ρ„Π°ΠΉΠ» со структурой сводной Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹. + +--- + +# Epic E: UI Hardening, UX Polish & Regression Tests + +## [DG-14](https://github.com/QueryaHub/Querya-Desktop/issues/577) (#577) β€” Fix ResultsTab Header Horizontal Overflow on Narrow Viewports + +**Labels:** `data-grid`, `frontend`, `bug` +**Epic:** E +**Depends on:** none + +### Goal +Π£ΡΡ‚Ρ€Π°Π½ΠΈΡ‚ΡŒ ΠΎΡˆΠΈΠ±ΠΊΡƒ `RenderFlex overflowed` Π² Ρ‚ΡƒΠ»Π±Π°Ρ€Π΅ `ResultsTab` Π½Π° ΠΊΠΎΠΌΠΏΠ°ΠΊΡ‚Π½Ρ‹Ρ… ΠΎΠΊΠ½Π°Ρ… ΠΈΠ»ΠΈ экранах с Π½ΠΈΠ·ΠΊΠΈΠΌ Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ΠΌ. + +### Context +ΠŸΡ€ΠΈ запускС `flutter test test/features/main_screen/results_tab_test.dart` Π½Π° фиксированной ΡˆΠΈΡ€ΠΈΠ½Π΅ 800px Ρ‚ΡƒΠ»Π±Π°Ρ€ с ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»Π΅ΠΌ Ρ€Π΅ΠΆΠΈΠΌΠΎΠ² `SegmentedButton`, статусной строкой, ΠΊΠ½ΠΎΠΏΠΊΠ°ΠΌΠΈ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°, инспСктора ΠΈ мСню экспорта Π²Ρ‹Π·Ρ‹Π²Π°Π΅Ρ‚ Π³ΠΎΡ€ΠΈΠ·ΠΎΠ½Ρ‚Π°Π»ΡŒΠ½Ρ‹ΠΉ ΠΎΠ²Π΅Ρ€Ρ„Π»ΠΎΡƒ (`overflowed by 105 pixels`). + +### Implementation +1. Π’ `lib/features/main_screen/results_tab.dart` (строка ~162): + - ΠžΠ±Π΅Ρ€Π½ΡƒΡ‚ΡŒ `material.Row` Ρ‚ΡƒΠ»Π±Π°Ρ€Π° экспорта ΠΈ сСлСктора Π² `material.SingleChildScrollView(scrollDirection: material.Axis.horizontal)` ΠΈΠ»ΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π°Π΄Π°ΠΏΡ‚ΠΈΠ²Π½Ρ‹ΠΉ flex layout с `material.Flexible`. + - ΠΠ°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ ΠΏΠ»ΠΎΡ‚Π½ΠΎΡΡ‚ΡŒ `VisualDensity.compact` для Ρ‚ΡƒΠ»Π±Π°Ρ€Π½Ρ‹Ρ… ΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»ΠΎΠ². +2. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ ΠΏΡ€ΠΎΡ…ΠΎΠΆΠ΄Π΅Π½ΠΈΠ΅ всСх тСстов Π² `test/features/main_screen/results_tab_test.dart`. + +### Acceptance Criteria +- [ ] ВсС тСсты Π² `results_tab_test.dart` проходят Π±Π΅Π· ошибок ΠΈ ΠΏΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΠΉ `RenderFlex overflowed`. +- [ ] Π’ΡƒΠ»Π±Π°Ρ€ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ скроллится ΠΈΠ»ΠΈ сТимаСтся Π½Π° ΡƒΠ·ΠΊΠΈΡ… экранах Π±Π΅Π· графичСских Π°Ρ€Ρ‚Π΅Ρ„Π°ΠΊΡ‚ΠΎΠ². + +--- + +## [DG-15](https://github.com/QueryaHub/Querya-Desktop/issues/578) (#578) β€” End-to-End Integration Tests for Data Grid Staging and DML Execution + +**Labels:** `data-grid`, `tests` +**Epic:** E +**Depends on:** DG-01, DG-02, DG-14 + +### Goal +ΠΠ°ΠΏΠΈΡΠ°Ρ‚ΡŒ Π½Π°Π±ΠΎΡ€ ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Ρ… тСстов для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ ΠΏΠΎΠ»Π½ΠΎΠ³ΠΎ Ρ†ΠΈΠΊΠ»Π°: Π²Ρ‹ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ запроса β†’ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ячСйки β†’ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ строки β†’ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ строки β†’ сохранСниС Π² Π±Π°Π·Ρƒ Π΄Π°Π½Π½Ρ‹Ρ… (SQLite memory DB / Mock driver). + +### Implementation +1. Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `test/features/main_screen/data_grid_e2e_editing_test.dart`: + - ВСст сквозного сцСнария рСдактирования Π² `SqliteSqlWorkspace` с in-memory Π±Π°Π·ΠΎΠΉ Π΄Π°Π½Π½Ρ‹Ρ…. + - ВСст ΠΎΡ‚ΠΊΠ°Ρ‚Π° ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ ΠΏΠΎ Π½Π°ΠΆΠ°Ρ‚ΠΈΡŽ *Revert All*. + - ВСст ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ ошибки внСшнСго ΠΊΠ»ΡŽΡ‡Π° ΠΈΠ»ΠΈ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΏΡ€ΠΈ сохранСнии. + +### Acceptance Criteria +- [ ] ВСсты Π²Ρ‹ΠΏΠΎΠ»Π½ΡΡŽΡ‚ΡΡ Π² CI Π±Π΅Π· Π²Π½Π΅ΡˆΠ½ΠΈΡ… зависимостСй. +- [ ] ΠŸΠΎΠΊΡ€Ρ‹Ρ‚Ρ‹ сцСнарии `INSERT`, `UPDATE`, `DELETE` ΠΈ `ROLLBACK`. + +--- + +## [DG-16](https://github.com/QueryaHub/Querya-Desktop/issues/579) (#579) β€” Visual Polish & Keyboard Navigation Accessibility Audit + +**Labels:** `data-grid`, `frontend`, `ux`, `accessibility` +**Epic:** E +**Depends on:** DG-04, DG-08 + +### Goal +ΠŸΡ€ΠΎΠ²Π΅ΡΡ‚ΠΈ Π°ΡƒΠ΄ΠΈΡ‚ доступности ΠΈ удобства ΠΊΠ»Π°Π²ΠΈΠ°Ρ‚ΡƒΡ€Π½ΠΎΠΉ Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ: фокусныС Ρ€Π°ΠΌΠΊΠΈ, Ρ‡ΠΈΡ‚Π°Π΅ΠΌΠΎΡΡ‚ΡŒ Ρ†Π²Π΅Ρ‚ΠΎΠ² Π² свСтлой/Ρ‚Π΅ΠΌΠ½ΠΎΠΉ Ρ‚Π΅ΠΌΠ°Ρ…, подсказки ΠΊ ΠΊΠ½ΠΎΠΏΠΊΠ°ΠΌ Ρ‚ΡƒΠ»Π±Π°Ρ€Π°. + +### Implementation +1. Π£Π±Π΅Π΄ΠΈΡ‚ΡŒΡΡ, Ρ‡Ρ‚ΠΎ всС ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Π΅ ΠΊΠ½ΠΎΠΏΠΊΠΈ Data Grid Ρ‚ΡƒΠ»Π±Π°Ρ€ΠΎΠ² снабТСны `tooltip` с ΡƒΠΊΠ°Π·Π°Π½ΠΈΠ΅ΠΌ горячих клавиш. +2. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Ρ€Π°ΡΡ‚Π½ΠΎΡΡ‚ΡŒ выдСлСния ячССк ΠΈ строк Π² высококонтрастных ΠΈ кастомных JSON-Ρ‚Π΅ΠΌΠ°Ρ…. +3. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π²ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½ΡƒΡŽ ΠΈΠ½Π΄ΠΈΠΊΠ°Ρ†ΠΈΡŽ Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ фокуса Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹ ΠΏΡ€ΠΈ Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ стрСлками. + +### Acceptance Criteria +- [ ] ВсС ΠΊΠ½ΠΎΠΏΠΊΠΈ ΠΈΠΌΠ΅ΡŽΡ‚ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΠ²Π½Ρ‹Π΅ Π²ΡΠΏΠ»Ρ‹Π²Π°ΡŽΡ‰ΠΈΠ΅ подсказки с хоткСями. +- [ ] Навигация стрСлками ΠΈ Π²Ρ‹Π΄Π΅Π»Π΅Π½ΠΈΠ΅ ячССк Ρ‡Π΅Ρ‚ΠΊΠΎ Π²ΠΈΠ΄Π½Ρ‹ Π½Π° всСх Ρ‚Π΅ΠΌΠ°Ρ… оформлСния. diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 9f188247..c3a116a8 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -508,54 +508,86 @@ class _MysqlTableViewState extends material.State { mainAxisAlignment: material.MainAxisAlignment.end, mainAxisSize: material.MainAxisSize.min, children: [ - material.Text( - _paginationLabel(), - style: material.TextStyle( - fontSize: 11, - color: cs.mutedForeground, + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), ), - ), - const Gap(8), - OutlineButton( - onPressed: _loading - ? null - : () { - if (_customSqlActive) { - unawaited(_fetchCustom()); - } else { - unawaited(_fetch(refreshCount: true)); - } - }, - child: const Text('Refresh'), ), const Gap(6), OutlineButton( + size: ButtonSize.small, onPressed: _openSqlEditor, + leading: const material.Icon( + material.Icons.code_rounded, + size: 15, + ), child: const Text('SQL'), ), if (_customSqlActive) ...[ - const Gap(6), + const Gap(4), OutlineButton( + size: ButtonSize.small, onPressed: _exitCustomMode, + leading: const material.Icon( + material.Icons.table_chart_rounded, + size: 15, + ), child: const Text('Browse'), ), ], - const Gap(6), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoPrevious || _loading) ? null : _goToPreviousPage, - child: const Icon( - material.Icons.chevron_left_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_left_rounded, + size: 16, + ), + child: const Text('Back'), ), - GhostButton( + const Gap(4), + OutlineButton( + size: ButtonSize.small, onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, - child: const Icon( - material.Icons.chevron_right_rounded, - size: 20), + leading: const material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + ), + child: const Text('Next'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading + ? null + : () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 14, + ), + child: const Text('Refresh'), ), ], ), diff --git a/lib/features/sqlite/sqlite_overview_tab.dart b/lib/features/sqlite/sqlite_overview_tab.dart index c7f75c7c..296dc88f 100644 --- a/lib/features/sqlite/sqlite_overview_tab.dart +++ b/lib/features/sqlite/sqlite_overview_tab.dart @@ -133,7 +133,7 @@ class _SqliteOverviewTabState extends material.State { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final width = material.MediaQuery.of(context).size.width; + final width = material.MediaQuery.sizeOf(context).width; if (_loading && _overview == null) { return material.Center( diff --git a/test/features/main_screen/goldens/title_bar_read_only_badge.png b/test/features/main_screen/goldens/title_bar_read_only_badge.png index b1c5454239410e9aee625683bdf2320ccfae072c..cc90d95f8d1fe0b9046b646541d332ab797dec24 100644 GIT binary patch delta 765 zcmVSmm9f*p z|H8ypE4129EA5Q_B{arpqltw;D11OL#6*bbqCsRIbI-A$GJ$1w7v^4;@7K=B>{sl! zvwQc>5CH%H000000000000000000000PJ7vJ3v|s4^MXG@_#)8+N+JUb!y!WD}ow>yWg$HZqbCCCqQ?Ossdh1N}s_Cl^tIEgW&JS1B%~8I4unF;U%5 zJtJC4M6PUlP;H!;E~bh_Rwm;-W_M?n%sx=Wx3)YN%YRRG)jlHnnCg6i3AS+TBqB2* z4tiY&GN!6g;yBp%?RNHT@J-CsL~d+$Ag4M(MBUj(0{CCBizWc=2!#X45DEv7AruZE zLns_ThT3VBaww-dTj%(m{G$}w3$Abg8ET6w+?G2?00I+i;Ve>TF3#rOC*enwdM_n6 zu!j({jepJ(bKUKHBI20Xr8-xDf-PK!#6POe&S(akmp3-crhJ`7;$XJ%*=?9}J;rgp z-|9gN_BUMV&+4+J-S7(ghe-Zl+aMq z-&vX!Y~hHA2LDdK5T*pB12H$srj_GsGcSE-X?8{M_r4U1BzHPTu5o1N)vSz%2<;yt z;>@`oiKIhKKRsWaevO9+W#r3~)5i|S-2+xr7#pKR`)^Xy||nb^emZf7Wf0^u0jiElT~`?Nb+ zzlBG8KJP9Ok)acR!1l$y1GMWy;b3lXXxD+nnF+_v`pyHrXVtpM%2IW;@@cB@vFS0BSP|at{OkSTv%xMj;XMsROcAki)_0G9L-%+|1`3|t2A1l-`c5-M4mIz zNUItpDksK&oM<{IdYzb#wv~&tPRcpd_NJCpUykhN6B#n6AR&BUoA1NJ)!P`jq zmxyEDvVRK&yTWzheQeCdW&;_KheR}%ek6du1-rrl^dmGJK#I_C04YMl0i*~G2auwE zdP{jIBl51rcsa!^08L?q14vO{tZ?h@AOQ$WunlK_t#vA#&I)Y!Cbhknk~`Q#h}uqT ziMiCez!+<}=lg4WCD?|`MpSIXGL^lIR2q}Uzvodp7sxEUs0+;jEm zs_!UX-3qqhh=}U7$^%1^k3?oO*0guL1NsmTL?W|>b#y#ayYD;AMg)DQ3H`Z5Xh-gX zVL2|wER#}^NW^z8NCC4Y;=B>bi<%d6-|LTXiBL*#P2TpCWt%iuzQlcWO&*b<6BxjD Z_7ew}Ck#{)bVdLG002ovPDHLkV1mPRVz2-J diff --git a/test/features/main_screen/goldens/workspace_empty_hero.png b/test/features/main_screen/goldens/workspace_empty_hero.png index 1d746c4a7d4e94d2a2679ec515cc5a73394282d7..87d831ff278d7276537254cff9afd97fffb5ec30 100644 GIT binary patch literal 7890 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 G(_aATT>Aw8 literal 7985 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 From 2caaa81893d11f55b052fc37592668c58e2ac13f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 20:12:36 +0300 Subject: [PATCH 39/40] fix(test): restore CI baseline golden images --- .../goldens/title_bar_read_only_badge.png | Bin 818 -> 827 bytes .../goldens/workspace_empty_hero.png | Bin 7890 -> 7985 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/test/features/main_screen/goldens/title_bar_read_only_badge.png b/test/features/main_screen/goldens/title_bar_read_only_badge.png index cc90d95f8d1fe0b9046b646541d332ab797dec24..b1c5454239410e9aee625683bdf2320ccfae072c 100644 GIT binary patch delta 743 zcmVxr7#pKR`)^Xy||nb^emZf7Wf0^u0jiElT~`?Nb+ zzlBG8KJP9Ok)acR!1l$y1GMWy;b3lXXxD+nnF+_v`pyHrXVtpM%2IW;@@cB@vFS0BSP|at{OkSTv%xMj;XMsROcAki)_0G9L-%+|1`3|t2A1l-`c5-M4mIz zNUItpDksK&oM<{IdYzb#wv~&tPRcpd_NJCpUykhN6B#n6AR&BUoA1NJ)!P`jq zmxyEDvVRK&yTWzheQeCdW&;_KheR}%ek6du1-rrl^dmGJK#I_C04YMl0i*~G2auwE zdP{jIBl51rcsa!^08L?q14vO{tZ?h@AOQ$WunlK_t#vA#&I)Y!Cbhknk~`Q#h}uqT ziMiCez!+<}=lg4WCD?|`MpSIXGL^lIR2q}Uzvodp7sxEUs0+;jEm zs_!UX-3qqhh=}U7$^%1^k3?oO*0guL1NsmTL?W|>b#y#ayYD;AMg)DQ3H`Z5Xh-gX zVL2|wER#}^NW^z8NCC4Y;=B>bi<%d6-|LTXiBL*#P2TpCWt%iuzQlcWO&*b<6BxjD Z_7ew}Ck#{)bVdLG002ovPDHLkV1mPRVz2-J delta 765 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 literal 7890 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 G(_aATT>Aw8 From 86ea3f148fe7750775d9c1cb2efaec0fe4f44856 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 25 Aug 2026 20:45:43 +0300 Subject: [PATCH 40/40] chore(release): prepare release 0.4.13 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ packaging/linux/aur/PKGBUILD | 2 +- pubspec.yaml | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f75e9b..b0720909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.13] - 2026-08-25 + +Production-ready UI polish, complete interactive Data Grid editing suite, advanced query filtering, fluid collapsible navigation, and platform hardening. + +### Added + +- **In-Place Cell Editing & Staging Engine (#560, #561, #564, #565)** β€” Double-click cell to edit in-place with type-aware inline editors, dirty state indicators, staging buffer for staged mutations (inserts/updates/deletions), and keyboard navigation (Tab/Shift+Tab, Enter, Escape). +- **Atomic DML Preview & Multi-Dialect Generation (#562, #566)** β€” Visual DML confirmation modal displaying compiled atomic `UPDATE`, `INSERT`, and `DELETE` statements with primary key resolution before committing to SQLite, PostgreSQL, and MySQL. +- **In-Cell Validation & Safety Guardrails (#567)** β€” Real-time cell validation for integer, float, boolean, UUID, JSON, date, and timestamp data types with visual error cues. +- **Advanced Query Filter Engine (#568, #569, #570)** β€” Compound predicate filter bar with `AND`, `OR`, `NOT`, parentheses, `LIKE`, `ILIKE`, `IN`, `IS NULL`, `BETWEEN`, escaped quotes, and intelligent popup autocomplete suggestions. +- **Syntax Highlighting & Value Inspector (#571, #572)** β€” Dedicated inspector panel with syntax highlighting for JSON, XML, YAML, and automated XML/HTML formatting and validation. +- **Selection Statistics & Quick Calc (#573)** β€” Extended selection calculations in the status bar (Count, Distinct, Sum, Avg, Min, Max, Median, Standard Deviation) with one-click clipboard summary export. +- **Multi-Column Grouping & Pivot View (#574, #575, #576)** β€” Hierarchical multi-level grouping, custom aggregations (SUM, AVG, MIN, MAX, COUNT), sorting, and CSV export for grouped summaries. +- **Column Drag-Resizing & 3-Phase Sorting (#548, #549)** β€” Interactive column width drag-resizing with divider handles and 3-phase client-side sorting (`natural` -> `asc` -> `desc`). +- **Multi-Cell Range Selection & Clipboard (#550)** β€” Rectangular multi-cell selection (Shift+Click) with TSV/CSV clipboard copy for Excel/Google Sheets. +- **Fluid Collapsible Sidebar (#557, #559)** β€” Physics-driven animated sidebar toggle with `QueryaSpring`, global `Cmd+B` / `Ctrl+B` hotkey, titlebar toggle button, and width persistence. +- **Rich Object Context Menus (#551)** β€” Right-click native context menus for database tables, views, procedures, and connections (*Β«Select TOP 100Β»*, *Β«Copy SELECT statementΒ»*, *Β«Copy nameΒ»*, *Β«Open in SQLΒ»*). +- **Tree Keyboard Navigation (#552)** β€” Full arrow-key navigation (Left/Right to expand/collapse, Up/Down, Enter/Space) across database trees. +- **Interactive Welcome Tour & 1-Click Playground (#558)** β€” Built-in onboarding tour and 1-click SQLite demo database playground. +- **Global Shortcuts & Focus Polish (#579)** β€” Added global shortcuts (`Ctrl+F` for filter bar, `Ctrl+S` for staging commit, `Ctrl+G` for groupings panel) and enhanced focus accessibility. + +### Fixed + +- **Connection Dialogs Inline Validation (#553)** β€” Real-time URI parser and validation with dynamic database driver and host badges. +- **Toolbar Layout Responsive Scroll (#577)** β€” Wrapped data grid action bars in horizontal scroll to eliminate overflow on compact viewports. +- **macOS SPM Path (#555)** β€” Corrected relative path to `refresh_rate` plugin source in `Package.swift` for Swift Package Manager builds. +- **macOS App Sandbox Entitlements (#556)** β€” Added `network.client` and `files.user-selected.read-write` entitlements for outbound TCP connections and local file access. + ## [0.4.11-c] - 2026-07-29 Flutter SDK & dependency compatibility update, image downsampling memory optimization, and UI polish. diff --git a/packaging/linux/aur/PKGBUILD b/packaging/linux/aur/PKGBUILD index 138c5d78..31c526db 100644 --- a/packaging/linux/aur/PKGBUILD +++ b/packaging/linux/aur/PKGBUILD @@ -2,7 +2,7 @@ # AUR package β€” installs the official Release portable Linux zip under /opt. # pkgver is synced by version-bump.yml; CI publishes real sha256sums on Release. pkgname=querya-desktop -pkgver=0.4.11-c +pkgver=0.4.13 pkgrel=1 pkgdesc="Multi-database desktop client (PostgreSQL, MySQL, Redis, MongoDB, SQLite)" arch=('x86_64') diff --git a/pubspec.yaml b/pubspec.yaml index 4be70cdc..34929080 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.11-c +version: 0.4.13