From 1520127eb389947571c72350a1d9db8964f2138c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 10:00:19 +0300 Subject: [PATCH 01/32] feat(ui): unify dropdowns on MenuAnchor via QueryaDropdown (#87) Replace Overlay-based Material dropdowns with a shared MenuAnchor widget styled via shadcn tokens, integrate on New Connection and migrate settings/toolbar selects. --- .../connections/new_connection_dialog.dart | 228 +++++++++++------- lib/features/mongodb/mongo_stats_view.dart | 33 +-- .../settings/preferences_controls.dart | 59 ++--- .../sql_statement_timeout_dropdown.dart | 22 +- lib/shared/widgets/querya_dropdown.dart | 210 ++++++++++++++++ lib/shared/widgets/widgets.dart | 1 + .../sql_statement_timeout_dropdown_test.dart | 27 +-- test/shared/querya_dropdown_test.dart | 77 ++++++ 8 files changed, 477 insertions(+), 180 deletions(-) create mode 100644 lib/shared/widgets/querya_dropdown.dart create mode 100644 test/shared/querya_dropdown_test.dart diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 62b9bb61..4ffb7671 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -92,8 +92,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial final mq = MediaQuery.sizeOf(context); final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(mq.width); final dialogH = WindowLayout.newConnectionDialogHeight(mq.height); - final sidebarW = WindowLayout.newConnectionSidebarWidth(dialogMaxW); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; + final stackFilters = dialogMaxW < 520; return material.Container( width: dialogMaxW, @@ -145,74 +145,61 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial child: TextField( controller: _searchController, placeholder: const Text('Search...'), - onChanged: (v) => setState(() => _searchQuery = v), + onChanged: (v) => setState(() { + _searchQuery = v; + if (_selectedType != null && + !_filteredTypes.contains(_selectedType)) { + _selectedType = null; + } + }), ), ), ], ), ), + const material.SizedBox(height: 12), + _FilterDropdowns( + stackVertically: stackFilters, + category: _category, + selectedType: _selectedType, + filteredTypes: _filteredTypes, + onCategoryChanged: (category) { + setState(() { + _category = category; + if (_selectedType != null && + !_categoryTypes.contains(_selectedType)) { + _selectedType = null; + } + }); + }, + onTypeChanged: (type) => setState(() => _selectedType = type), + ), ], ), ), material.Expanded( - child: material.Row( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - width: sidebarW, - decoration: material.BoxDecoration( - border: material.Border( - right: material.BorderSide( - color: theme.border.withValues(alpha: 0.4), - ), - ), - ), - child: material.ListView( - padding: const material.EdgeInsets.symmetric(vertical: 8), - children: [ - _CategoryTile( - label: 'All', - icon: material.Icons.dns_rounded, - selected: _category == _Category.all, - onTap: () => setState(() => _category = _Category.all), - theme: theme, - ), - _CategoryTile( - label: 'SQL', - icon: material.Icons.table_chart_rounded, - selected: _category == _Category.sql, - onTap: () => setState(() => _category = _Category.sql), - theme: theme, - ), - _CategoryTile( - label: 'NoSQL', - icon: material.Icons.memory_rounded, - selected: _category == _Category.nosql, - onTap: () => setState(() => _category = _Category.nosql), - theme: theme, - ), - ], - ), - ), - material.Expanded( - child: material.LayoutBuilder( - builder: (context, constraints) { - const spacing = 12.0; - final gridPad = dialogMaxW < 420 ? 12.0 : 16.0; - final innerW = math.max(0.0, constraints.maxWidth - gridPad * 2); - final crossAxisCount = - WindowLayout.dbTypeGridCrossAxisCount(innerW); - final cardHeight = - WindowLayout.dbTypeCardHeight(crossAxisCount); - final cardWidth = crossAxisCount > 0 - ? (innerW - spacing * (crossAxisCount - 1)) / - crossAxisCount - : innerW; - final aspect = - cardHeight > 0 ? cardWidth / cardHeight : 1.0; - return material.Padding( - padding: material.EdgeInsets.all(gridPad), - child: material.GridView.count( + child: material.LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.0; + final gridPad = dialogMaxW < 420 ? 12.0 : 16.0; + final innerW = math.max(0.0, constraints.maxWidth - gridPad * 2); + final crossAxisCount = + WindowLayout.dbTypeGridCrossAxisCount(innerW); + final cardHeight = + WindowLayout.dbTypeCardHeight(crossAxisCount); + final cardWidth = crossAxisCount > 0 + ? (innerW - spacing * (crossAxisCount - 1)) / crossAxisCount + : innerW; + final aspect = cardHeight > 0 ? cardWidth / cardHeight : 1.0; + return material.Padding( + padding: material.EdgeInsets.all(gridPad), + child: _filteredTypes.isEmpty + ? material.Center( + child: Text('No databases match your search.') + .muted() + .small(), + ) + : material.GridView.count( crossAxisCount: crossAxisCount, mainAxisSpacing: spacing, crossAxisSpacing: spacing, @@ -229,11 +216,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial ), ], ), - ); - }, - ), - ), - ], + ); + }, ), ), material.Container( @@ -271,38 +255,100 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial } } -class _CategoryTile extends StatelessWidget { - const _CategoryTile({ - required this.label, - required this.icon, - required this.selected, - required this.onTap, - required this.theme, +class _FilterDropdowns extends StatelessWidget { + const _FilterDropdowns({ + required this.stackVertically, + required this.category, + required this.selectedType, + required this.filteredTypes, + required this.onCategoryChanged, + required this.onTypeChanged, }); - final String label; - final material.IconData icon; - final bool selected; - final VoidCallback onTap; - final ColorScheme theme; + final bool stackVertically; + final _Category category; + final ConnectionType? selectedType; + final List filteredTypes; + final void Function(_Category category) onCategoryChanged; + final void Function(ConnectionType? type) onTypeChanged; + + static const _categoryItems = [ + QueryaDropdownItem( + value: _Category.all, + label: 'All databases', + leading: material.Icon(material.Icons.dns_rounded, size: 18), + ), + QueryaDropdownItem( + value: _Category.sql, + label: 'SQL', + leading: material.Icon(material.Icons.table_chart_rounded, size: 18), + ), + QueryaDropdownItem( + value: _Category.nosql, + label: 'NoSQL', + leading: material.Icon(material.Icons.memory_rounded, size: 18), + ), + ]; @override material.Widget build(material.BuildContext context) { - return material.Material( - color: selected ? theme.muted.withValues(alpha: 0.35) : material.Colors.transparent, - child: material.InkWell( - onTap: onTap, - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: material.Row( - children: [ - material.Icon(icon, size: 20, color: theme.mutedForeground), - const material.SizedBox(width: 14), - selected ? Text(label).semiBold().small() : Text(label).small(), - ], - ), + final categoryField = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Category').small().muted(), + const material.SizedBox(height: 4), + QueryaDropdown<_Category>( + value: category, + expandToParent: true, + items: _categoryItems, + onSelected: (value) { + if (value != null) onCategoryChanged(value); + }, ), - ), + ], + ); + + final typeField = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Database type').small().muted(), + const material.SizedBox(height: 4), + QueryaDropdown( + value: selectedType, + hint: filteredTypes.isEmpty ? 'No matches' : 'Select database…', + enabled: filteredTypes.isNotEmpty, + expandToParent: true, + items: [ + for (final type in filteredTypes) + QueryaDropdownItem( + value: type, + label: type.label, + leading: material.Icon(type.icon, size: 18), + ), + ], + onSelected: onTypeChanged, + ), + ], + ); + + if (stackVertically) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + categoryField, + const material.SizedBox(height: 10), + typeField, + ], + ); + } + + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded(child: categoryField), + const material.SizedBox(width: 12), + material.Expanded(child: typeField), + ], ); } } diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 0a029b5e..4b7be2e6 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -333,34 +333,17 @@ class _MongoStatsViewState extends material.State { ), material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 4), - child: material.DropdownButton( + child: QueryaDropdown( + width: 132, value: _autoRefreshInterval, - underline: const material.SizedBox.shrink(), - isDense: true, - borderRadius: material.BorderRadius.circular(8), items: const [ - material.DropdownMenuItem( - value: null, - child: material.Text('Auto: off'), - ), - material.DropdownMenuItem( - value: Duration(seconds: 3), - child: material.Text('Auto: 3 s'), - ), - material.DropdownMenuItem( - value: Duration(seconds: 10), - child: material.Text('Auto: 10 s'), - ), - material.DropdownMenuItem( - value: Duration(seconds: 30), - child: material.Text('Auto: 30 s'), - ), - material.DropdownMenuItem( - value: Duration(seconds: 60), - child: material.Text('Auto: 60 s'), - ), + QueryaDropdownItem(value: null, label: 'Auto: off'), + QueryaDropdownItem(value: Duration(seconds: 3), label: 'Auto: 3 s'), + QueryaDropdownItem(value: Duration(seconds: 10), label: 'Auto: 10 s'), + QueryaDropdownItem(value: Duration(seconds: 30), label: 'Auto: 30 s'), + QueryaDropdownItem(value: Duration(seconds: 60), label: 'Auto: 60 s'), ], - onChanged: (value) { + onSelected: (value) { setState(() => _autoRefreshInterval = value); _startTimer(); }, diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 0029c287..f5b40850 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Helper / hint copy in Preferences — readable on imported themes. @@ -21,7 +22,7 @@ class PreferencesHint extends StatelessWidget { } } -/// Material 3 dropdown anchored to the field (stable inside scroll views). +/// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ super.key, @@ -35,7 +36,7 @@ class PreferencesDropdownMenu extends StatelessWidget { final T value; final List> entries; - final ValueChanged onSelected; + final material.ValueChanged onSelected; /// Fixed width for field + menu. Do not pass [double.infinity] — menu glitches. final double? width; @@ -46,46 +47,22 @@ class PreferencesDropdownMenu extends StatelessWidget { @override material.Widget build(material.BuildContext context) { - final cs = Theme.of(context).colorScheme; - final mTheme = material.Theme.of(context); - final textStyle = material.TextStyle( - color: cs.popoverForeground, - fontSize: 14, - ); - - return material.Theme( - data: mTheme.copyWith( - canvasColor: cs.popover, - colorScheme: mTheme.colorScheme.copyWith( - surface: cs.popover, - onSurface: cs.popoverForeground, - ), - ), - child: material.DropdownMenu( - enabled: enabled, - width: width, - expandedInsets: - expandToParent ? material.EdgeInsets.zero : null, - initialSelection: value, - onSelected: enabled ? onSelected : null, - dropdownMenuEntries: entries, - textStyle: textStyle, - inputDecorationTheme: material.InputDecorationTheme( - isDense: true, - contentPadding: const material.EdgeInsets.symmetric(vertical: 6), - enabledBorder: material.UnderlineInputBorder( - borderSide: material.BorderSide(color: cs.border), - ), - focusedBorder: material.UnderlineInputBorder( - borderSide: material.BorderSide(color: cs.ring, width: 2), - ), - ), - menuStyle: material.MenuStyle( - backgroundColor: material.WidgetStatePropertyAll(cs.popover), - surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), - elevation: const material.WidgetStatePropertyAll(8), + final items = [ + for (final entry in entries) + QueryaDropdownItem( + value: entry.value, + label: entry.label, + enabled: entry.enabled, ), - ), + ]; + + return QueryaDropdown( + value: value, + items: items, + enabled: enabled, + width: width, + expandToParent: expandToParent, + onSelected: onSelected, ); } } diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index fa0848f0..4a664a20 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -1,12 +1,20 @@ import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; /// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL). +const List> kSqlStatementTimeoutMenuItems = [ + QueryaDropdownItem(value: null, label: 'No limit'), + QueryaDropdownItem(value: 10, label: '10 s'), + QueryaDropdownItem(value: 30, label: '30 s'), + QueryaDropdownItem(value: 60, label: '60 s'), + QueryaDropdownItem(value: 120, label: '2 min'), + QueryaDropdownItem(value: 300, label: '5 min'), + QueryaDropdownItem(value: 600, label: '10 min'), +]; + +/// Legacy alias for tests and call sites that still reference menu entries. const List> kSqlStatementTimeoutMenuEntries = [ - material.DropdownMenuEntry( - value: null, - label: 'No limit', - ), + material.DropdownMenuEntry(value: null, label: 'No limit'), material.DropdownMenuEntry(value: 10, label: '10 s'), material.DropdownMenuEntry(value: 30, label: '30 s'), material.DropdownMenuEntry(value: 60, label: '60 s'), @@ -30,11 +38,11 @@ class SqlStatementTimeoutDropdown extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - return PreferencesDropdownMenu( + return QueryaDropdown( value: value, enabled: enabled, onSelected: onChanged, - entries: kSqlStatementTimeoutMenuEntries, + items: kSqlStatementTimeoutMenuItems, ); } } diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart new file mode 100644 index 00000000..d6723e79 --- /dev/null +++ b/lib/shared/widgets/querya_dropdown.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// One selectable row in [QueryaDropdown]. +class QueryaDropdownItem { + const QueryaDropdownItem({ + required this.value, + required this.label, + this.enabled = true, + this.leading, + }); + + final T value; + final String label; + final bool enabled; + final material.Widget? leading; +} + +/// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). +/// +/// Styling uses shadcn [ColorScheme] tokens from [Theme.of]. +class QueryaDropdown extends material.StatefulWidget { + const QueryaDropdown({ + super.key, + required this.value, + required this.items, + required this.onSelected, + this.controller, + this.enabled = true, + this.width, + this.expandToParent = false, + this.alignmentOffset = const material.Offset(0, 6), + this.menuMaxHeight = 320, + this.hint, + }); + + final T value; + final List> items; + final material.ValueChanged onSelected; + final material.MenuController? controller; + final bool enabled; + final double? width; + final bool expandToParent; + final material.Offset alignmentOffset; + final double menuMaxHeight; + final String? hint; + + @override + material.State> createState() => _QueryaDropdownState(); +} + +class _QueryaDropdownState extends material.State> { + late material.MenuController _controller; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? material.MenuController(); + } + + @override + void didUpdateWidget(covariant QueryaDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _controller = widget.controller ?? material.MenuController(); + } + } + + material.Widget _triggerLabel({ + required String label, + required ColorScheme cs, + required bool expand, + }) { + final text = material.Text( + label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 14, + color: widget.enabled ? cs.popoverForeground : cs.mutedForeground, + ), + ); + if (expand) { + return material.Expanded(child: text); + } + return text; + } + + String _labelFor(T value) { + for (final item in widget.items) { + if (item.value == value) return item.label; + } + return widget.hint ?? ''; + } + + material.Widget _menuItem(QueryaDropdownItem item, ColorScheme cs) { + final selected = item.value == widget.value; + return material.MenuItemButton( + style: material.MenuItemButton.styleFrom( + minimumSize: const material.Size(180, 36), + padding: const material.EdgeInsets.symmetric(horizontal: 12), + backgroundColor: selected ? cs.muted.withValues(alpha: 0.45) : null, + foregroundColor: cs.popoverForeground, + disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(6), + ), + ), + onPressed: !widget.enabled || !item.enabled + ? null + : () { + widget.onSelected(item.value); + _controller.close(); + }, + leadingIcon: item.leading, + child: material.Text( + item.label, + style: material.TextStyle( + fontSize: 14, + fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, + ), + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusMd; + final label = _labelFor(widget.value); + final fieldWidth = widget.expandToParent ? null : widget.width; + + final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); + + final anchor = material.MenuAnchor( + controller: _controller, + alignmentOffset: widget.alignmentOffset, + consumeOutsideTap: true, + style: material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(cs.popover), + surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), + elevation: const material.WidgetStatePropertyAll(8), + maximumSize: material.WidgetStatePropertyAll( + material.Size(double.infinity, widget.menuMaxHeight), + ), + padding: const material.WidgetStatePropertyAll( + material.EdgeInsets.symmetric(vertical: 4, horizontal: 4), + ), + shape: material.WidgetStatePropertyAll( + material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + side: material.BorderSide(color: cs.border), + ), + ), + ), + menuChildren: menuChildren, + builder: (context, controller, child) { + final field = material.InkWell( + onTap: widget.enabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + borderRadius: material.BorderRadius.circular(6), + child: material.Container( + width: fieldWidth, + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 6), + decoration: material.BoxDecoration( + border: material.Border( + bottom: material.BorderSide( + color: widget.enabled ? cs.border : cs.border.withValues(alpha: 0.4), + ), + ), + ), + child: material.Row( + mainAxisSize: + widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, + children: [ + _triggerLabel( + label: label, + cs: cs, + expand: widget.expandToParent, + ), + material.Icon( + material.Icons.arrow_drop_down_rounded, + size: 22, + color: widget.enabled ? cs.mutedForeground : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + + if (widget.expandToParent) { + return material.SizedBox(width: double.infinity, child: field); + } + return field; + }, + ); + + if (fieldWidth != null && !widget.expandToParent) { + return material.SizedBox(width: fieldWidth, child: anchor); + } + return anchor; + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 1cd9ec8c..12750aeb 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -9,4 +9,5 @@ library; export 'app_dialog.dart'; +export 'querya_dropdown.dart'; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/features/settings/sql_statement_timeout_dropdown_test.dart b/test/features/settings/sql_statement_timeout_dropdown_test.dart index cde436bb..cdd6a208 100644 --- a/test/features/settings/sql_statement_timeout_dropdown_test.dart +++ b/test/features/settings/sql_statement_timeout_dropdown_test.dart @@ -4,17 +4,16 @@ import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown. import '../../support/querya_theme_test_shell.dart'; void main() { - group('kSqlStatementTimeoutMenuEntries', () { + group('kSqlStatementTimeoutMenuItems', () { test('has seven entries with expected values', () { - expect(kSqlStatementTimeoutMenuEntries.length, 7); - final values = - kSqlStatementTimeoutMenuEntries.map((e) => e.value).toList(); + expect(kSqlStatementTimeoutMenuItems.length, 7); + final values = kSqlStatementTimeoutMenuItems.map((e) => e.value).toList(); expect(values, [null, 10, 30, 60, 120, 300, 600]); }); }); group('SqlStatementTimeoutDropdown', () { - testWidgets('builds DropdownMenu with current value', (tester) async { + testWidgets('builds MenuAnchor with current value', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( @@ -27,15 +26,11 @@ void main() { ); await tester.pump(); - expect(find.byType(material.DropdownMenu), findsOneWidget); - final menu = tester.widget>( - find.byType(material.DropdownMenu), - ); - expect(menu.initialSelection, 60); - expect(menu.onSelected, isNotNull); + expect(find.byType(material.MenuAnchor), findsOneWidget); + expect(find.text('60 s'), findsOneWidget); }); - testWidgets('disables changes when enabled is false', (tester) async { + testWidgets('disables menu when enabled is false', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( @@ -49,10 +44,10 @@ void main() { ); await tester.pump(); - final menu = tester.widget>( - find.byType(material.DropdownMenu), - ); - expect(menu.enabled, isFalse); + await tester.tap(find.text('30 s')); + await tester.pumpAndSettle(); + + expect(find.text('10 s'), findsNothing); }); }); } diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart new file mode 100644 index 00000000..0f1a9c58 --- /dev/null +++ b/test/shared/querya_dropdown_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; + +import '../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaDropdown', () { + testWidgets('builds MenuAnchor with current label', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 'b', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(material.MenuAnchor), findsOneWidget); + expect(find.text('Beta'), findsOneWidget); + }); + + testWidgets('opens menu and reports selection', (tester) async { + int? picked; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 1, + items: const [ + QueryaDropdownItem(value: 1, label: 'One'), + QueryaDropdownItem(value: 2, label: 'Two'), + ], + onSelected: (v) => picked = v, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('One')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Two')); + await tester.pumpAndSettle(); + + expect(picked, 2); + }); + + testWidgets('shows hint when value is null', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: null, + hint: 'Pick one', + items: const [ + QueryaDropdownItem(value: 'x', label: 'X'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Pick one'), findsOneWidget); + }); + }); +} From cefd5c7b265ce23b79f51c15b877839b79020bcf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 10:05:03 +0300 Subject: [PATCH 02/32] fix(ui): satisfy prefer_const_constructors in new connection dialog --- lib/features/connections/new_connection_dialog.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 4ffb7671..6bd23e8c 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -195,7 +195,7 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial padding: material.EdgeInsets.all(gridPad), child: _filteredTypes.isEmpty ? material.Center( - child: Text('No databases match your search.') + child: const Text('No databases match your search.') .muted() .small(), ) From 3d583cba12f5c3131bd10b548e2c81541d544aa4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 13:08:11 +0300 Subject: [PATCH 03/32] feat(ui): apply dropdown design system tokens to QueryaDropdown (#89) Centralize trigger/menu metrics in QueryaDropdownTokens and restyle trigger, menu panel, hover states, and selected checkmarks across all dropdown call sites. --- lib/shared/widgets/querya_dropdown.dart | 297 +++++++++++++----- .../widgets/querya_dropdown_tokens.dart | 46 +++ lib/shared/widgets/widgets.dart | 2 +- test/shared/querya_dropdown_test.dart | 39 ++- 4 files changed, 307 insertions(+), 77 deletions(-) create mode 100644 lib/shared/widgets/querya_dropdown_tokens.dart diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index d6723e79..843bf8b0 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +export 'querya_dropdown_tokens.dart'; + /// One selectable row in [QueryaDropdown]. class QueryaDropdownItem { const QueryaDropdownItem({ @@ -18,7 +21,7 @@ class QueryaDropdownItem { /// Stable dropdown built on [material.MenuAnchor] (no Overlay portal). /// -/// Styling uses shadcn [ColorScheme] tokens from [Theme.of]. +/// Visual metrics: [QueryaDropdownTokens]. Colors from shadcn [ColorScheme]. class QueryaDropdown extends material.StatefulWidget { const QueryaDropdown({ super.key, @@ -29,8 +32,8 @@ class QueryaDropdown extends material.StatefulWidget { this.enabled = true, this.width, this.expandToParent = false, - this.alignmentOffset = const material.Offset(0, 6), - this.menuMaxHeight = 320, + this.alignmentOffset = QueryaDropdownTokens.menuAlignmentOffset, + this.menuMaxHeight = QueryaDropdownTokens.menuMaxHeight, this.hint, }); @@ -51,6 +54,7 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; + bool _triggerHovered = false; @override void initState() { @@ -66,7 +70,7 @@ class _QueryaDropdownState extends material.State> { } } - material.Widget _triggerLabel({ + material.Widget _triggerLabelText({ required String label, required ColorScheme cs, required bool expand, @@ -76,8 +80,9 @@ class _QueryaDropdownState extends material.State> { maxLines: 1, overflow: material.TextOverflow.ellipsis, style: material.TextStyle( - fontSize: 14, - color: widget.enabled ? cs.popoverForeground : cs.mutedForeground, + fontSize: QueryaDropdownTokens.fontSize, + fontWeight: material.FontWeight.w500, + color: widget.enabled ? cs.foreground : cs.mutedForeground, ), ); if (expand) { @@ -94,31 +99,88 @@ class _QueryaDropdownState extends material.State> { } material.Widget _menuItem(QueryaDropdownItem item, ColorScheme cs) { - final selected = item.value == widget.value; - return material.MenuItemButton( - style: material.MenuItemButton.styleFrom( - minimumSize: const material.Size(180, 36), - padding: const material.EdgeInsets.symmetric(horizontal: 12), - backgroundColor: selected ? cs.muted.withValues(alpha: 0.45) : null, - foregroundColor: cs.popoverForeground, - disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), - shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(6), + return _QueryaDropdownMenuItem( + item: item, + selected: item.value == widget.value, + enabled: widget.enabled && item.enabled, + colorScheme: cs, + onPick: () { + widget.onSelected(item.value); + _controller.close(); + }, + ); + } + + material.Widget _buildTrigger({ + required material.BuildContext context, + required material.MenuController controller, + required ColorScheme cs, + required String label, + required double? fieldWidth, + }) { + final borderColor = widget.enabled + ? (_triggerHovered ? cs.ring : cs.border) + : cs.border.withValues(alpha: 0.4); + + final triggerBody = material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _triggerHovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _triggerHovered = false) : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + height: QueryaDropdownTokens.triggerHeight, + padding: QueryaDropdownTokens.triggerPadding, + decoration: material.BoxDecoration( + color: _triggerHovered + ? cs.muted.withValues(alpha: 0.22) + : cs.muted.withValues(alpha: 0.08), + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + mainAxisSize: + widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, + children: [ + _triggerLabelText(label: label, cs: cs, expand: widget.expandToParent), + const material.SizedBox(width: QueryaDropdownTokens.triggerChevronGap), + material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: QueryaDropdownTokens.triggerChevronSize, + color: widget.enabled + ? cs.mutedForeground + : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], ), ), - onPressed: !widget.enabled || !item.enabled - ? null - : () { - widget.onSelected(item.value); - _controller.close(); - }, - leadingIcon: item.leading, - child: material.Text( - item.label, - style: material.TextStyle( - fontSize: 14, - fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, + ); + + return material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: widget.enabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, ), + child: fieldWidth != null + ? material.SizedBox(width: fieldWidth, child: triggerBody) + : triggerBody, ), ); } @@ -126,11 +188,12 @@ class _QueryaDropdownState extends material.State> { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final radius = Theme.of(context).radiusMd; final label = _labelFor(widget.value); final fieldWidth = widget.expandToParent ? null : widget.width; - final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); + final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold + ? widget.menuMaxHeight + : double.infinity; final anchor = material.MenuAnchor( controller: _controller, @@ -139,66 +202,40 @@ class _QueryaDropdownState extends material.State> { style: material.MenuStyle( backgroundColor: material.WidgetStatePropertyAll(cs.popover), surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), - elevation: const material.WidgetStatePropertyAll(8), + elevation: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuElevation, + ), + shadowColor: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuShadowColor, + ), maximumSize: material.WidgetStatePropertyAll( - material.Size(double.infinity, widget.menuMaxHeight), + material.Size(double.infinity, effectiveMaxHeight), ), padding: const material.WidgetStatePropertyAll( - material.EdgeInsets.symmetric(vertical: 4, horizontal: 4), + QueryaDropdownTokens.menuPadding, ), shape: material.WidgetStatePropertyAll( material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular(radius), + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), side: material.BorderSide(color: cs.border), ), ), ), menuChildren: menuChildren, builder: (context, controller, child) { - final field = material.InkWell( - onTap: widget.enabled - ? () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - } - : null, - borderRadius: material.BorderRadius.circular(6), - child: material.Container( - width: fieldWidth, - padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 6), - decoration: material.BoxDecoration( - border: material.Border( - bottom: material.BorderSide( - color: widget.enabled ? cs.border : cs.border.withValues(alpha: 0.4), - ), - ), - ), - child: material.Row( - mainAxisSize: - widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, - children: [ - _triggerLabel( - label: label, - cs: cs, - expand: widget.expandToParent, - ), - material.Icon( - material.Icons.arrow_drop_down_rounded, - size: 22, - color: widget.enabled ? cs.mutedForeground : cs.mutedForeground.withValues(alpha: 0.5), - ), - ], - ), - ), + final trigger = _buildTrigger( + context: context, + controller: controller, + cs: cs, + label: label, + fieldWidth: fieldWidth, ); - if (widget.expandToParent) { - return material.SizedBox(width: double.infinity, child: field); + return material.SizedBox(width: double.infinity, child: trigger); } - return field; + return trigger; }, ); @@ -208,3 +245,113 @@ class _QueryaDropdownState extends material.State> { return anchor; } } + +class _QueryaDropdownMenuItem extends material.StatefulWidget { + const _QueryaDropdownMenuItem({ + required this.item, + required this.selected, + required this.enabled, + required this.colorScheme, + required this.onPick, + }); + + final QueryaDropdownItem item; + final bool selected; + final bool enabled; + final ColorScheme colorScheme; + final material.VoidCallback onPick; + + @override + material.State<_QueryaDropdownMenuItem> createState() => + _QueryaDropdownMenuItemState(); +} + +class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenuItem> { + bool _hovered = false; + + material.Widget _leading(ColorScheme cs) { + if (widget.selected) { + return material.Icon( + material.Icons.check_rounded, + size: QueryaDropdownTokens.selectedCheckSize, + color: cs.primary, + ); + } + if (widget.item.leading != null) { + return widget.item.leading!; + } + return const material.SizedBox(width: QueryaDropdownTokens.selectedCheckSlotWidth); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final bg = _hovered + ? cs.accent.withValues(alpha: 0.12) + : widget.selected + ? cs.muted.withValues(alpha: 0.28) + : material.Colors.transparent; + + return material.MouseRegion( + cursor: widget.enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: widget.enabled ? (_) => setState(() => _hovered = true) : null, + onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, + child: material.MenuItemButton( + style: material.MenuItemButton.styleFrom( + minimumSize: const material.Size( + QueryaDropdownTokens.menuItemMinWidth, + QueryaDropdownTokens.menuItemHeight, + ), + padding: material.EdgeInsets.zero, + foregroundColor: cs.popoverForeground, + disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), + overlayColor: material.Colors.transparent, + shape: material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), + ), + ), + onPressed: widget.enabled ? widget.onPick : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + padding: QueryaDropdownTokens.menuItemPadding, + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular( + QueryaDropdownTokens.menuBorderRadius, + ), + ), + child: material.Row( + children: [ + material.SizedBox( + width: QueryaDropdownTokens.selectedCheckSlotWidth, + child: _leading(cs), + ), + const material.SizedBox(width: 6), + material.Expanded( + child: material.Text( + widget.item.label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: QueryaDropdownTokens.fontSize, + fontWeight: widget.selected + ? material.FontWeight.w600 + : material.FontWeight.w400, + color: cs.popoverForeground, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart new file mode 100644 index 00000000..9fe851a1 --- /dev/null +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart' as material; + +/// Fixed metrics for [QueryaDropdown] — single source of truth for dropdown UI. +abstract final class QueryaDropdownTokens { + /// Compact desktop trigger height. + static const double triggerHeight = 32.0; + + static const material.EdgeInsets triggerPadding = + material.EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + + static const double triggerChevronGap = 8.0; + + static const double triggerChevronSize = 18.0; + + static const material.Offset menuAlignmentOffset = material.Offset(0, 4.0); + + static const double menuMaxHeight = 300.0; + + static const int menuScrollItemThreshold = 8; + + static const double menuBorderRadius = 6.0; + + static const double menuElevation = 8.0; + + static const double menuShadowBlurRadius = 8.0; + + static const material.Color menuShadowColor = material.Color(0x42000000); + + static const material.EdgeInsets menuPadding = + material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); + + static const double menuItemHeight = 28.0; + + static const material.EdgeInsets menuItemPadding = + material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0); + + static const double menuItemMinWidth = 180.0; + + static const double fontSize = 13.0; + + static const double selectedCheckSize = 16.0; + + static const double selectedCheckSlotWidth = 18.0; + + static const int hoverAnimationMs = 120; +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 12750aeb..7dfe6fff 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -9,5 +9,5 @@ library; export 'app_dialog.dart'; -export 'querya_dropdown.dart'; +export 'querya_dropdown.dart' show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 0f1a9c58..fb1e0211 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -5,8 +5,18 @@ import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import '../support/querya_theme_test_shell.dart'; void main() { + group('QueryaDropdownTokens', () { + test('uses design-system defaults from issue #89', () { + expect(QueryaDropdownTokens.triggerHeight, 32.0); + expect(QueryaDropdownTokens.menuAlignmentOffset, const material.Offset(0, 4)); + expect(QueryaDropdownTokens.menuMaxHeight, 300.0); + expect(QueryaDropdownTokens.menuBorderRadius, 6.0); + expect(QueryaDropdownTokens.fontSize, 13.0); + }); + }); + group('QueryaDropdown', () { - testWidgets('builds MenuAnchor with current label', (tester) async { + testWidgets('builds MenuAnchor with current label and chevron', (tester) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( @@ -25,6 +35,10 @@ void main() { expect(find.byType(material.MenuAnchor), findsOneWidget); expect(find.text('Beta'), findsOneWidget); + expect(find.byIcon(material.Icons.keyboard_arrow_down_rounded), findsOneWidget); + + final box = tester.getSize(find.byType(material.AnimatedContainer).first); + expect(box.height, QueryaDropdownTokens.triggerHeight); }); testWidgets('opens menu and reports selection', (tester) async { @@ -54,6 +68,29 @@ void main() { expect(picked, 2); }); + testWidgets('shows check on selected menu item', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: QueryaDropdown( + value: 'b', + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Beta')); + await tester.pumpAndSettle(); + + expect(find.byIcon(material.Icons.check_rounded), findsOneWidget); + }); + testWidgets('shows hint when value is null', (tester) async { await tester.pumpWidget( queryaThemeTestShell( From bb74f11cd893de8d484fd600ec5c4f3329b79246 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 13:17:10 +0300 Subject: [PATCH 04/32] fix(ui): readable uniform preferences dropdowns and interface scale (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix trigger text clipping, align all Preferences dropdowns via PreferencesFieldRow, and add global UI scale (85–150%) with scaled QueryaDropdown metrics. --- .flutter-plugins-dependencies | 2 +- lib/app/app.dart | 26 ++- lib/core/layout/ui_scale.dart | 29 +++ lib/core/layout/ui_scale_controller.dart | 22 ++ lib/core/storage/app_settings.dart | 34 +++ .../preferences_appearance_section.dart | 160 ++++++++------ .../settings/preferences_controls.dart | 55 ++++- lib/features/settings/preferences_dialog.dart | 203 +++++++----------- .../sql_statement_timeout_dropdown.dart | 3 + lib/main.dart | 2 + lib/shared/widgets/querya_dropdown.dart | 124 ++++++----- .../widgets/querya_dropdown_tokens.dart | 65 +++++- lib/shared/widgets/widgets.dart | 3 +- test/core/storage/app_settings_test.dart | 14 ++ test/shared/querya_dropdown_test.dart | 4 +- test/support/querya_theme_test_shell.dart | 10 +- 16 files changed, 487 insertions(+), 269 deletions(-) create mode 100644 lib/core/layout/ui_scale.dart create mode 100644 lib/core/layout/ui_scale_controller.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index e58e7712..d16280a5 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-05-28 10:58:45.738032","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-06-09 13:00:56.820075","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/app/app.dart b/lib/app/app.dart index 06b6fdfa..b14e41c4 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; @@ -13,11 +15,14 @@ class QueryaApp extends StatelessWidget { Widget build(BuildContext context) { final themeController = ThemeController.instance; + final uiScaleController = UiScaleController.instance; + return ListenableBuilder( - listenable: themeController, + listenable: Listenable.merge([themeController, uiScaleController]), builder: (context, _) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; + final scale = uiScaleController.scale; return ShadcnApp( title: 'Querya', theme: themeController.lightShadcnTheme, @@ -28,10 +33,21 @@ class QueryaApp extends StatelessWidget { enableThemeAnimation: themeController.themeAnimationEnabled, enableScrollInterception: false, // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. - builder: (context, child) => QueryaThemeScope( - data: queryaTheme, - child: child ?? const SizedBox.shrink(), - ), + builder: (context, child) { + final mq = MediaQuery.maybeOf(context); + return QueryaUiScaleScope( + scale: scale, + child: MediaQuery( + data: (mq ?? const MediaQueryData()).copyWith( + textScaler: TextScaler.linear(scale), + ), + child: QueryaThemeScope( + data: queryaTheme, + child: child ?? const SizedBox.shrink(), + ), + ), + ); + }, home: const AppLifecycleCleanup( child: MainScreen(), ), diff --git a/lib/core/layout/ui_scale.dart b/lib/core/layout/ui_scale.dart new file mode 100644 index 00000000..b290b8b2 --- /dev/null +++ b/lib/core/layout/ui_scale.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; + +/// App-wide UI scale factor (Preferences → Appearance). +class QueryaUiScaleScope extends InheritedWidget { + const QueryaUiScaleScope({ + super.key, + required this.scale, + required super.child, + }); + + final double scale; + + static double of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.scale ?? + 1.0; + } + + @override + bool updateShouldNotify(QueryaUiScaleScope oldWidget) => + oldWidget.scale != scale; +} + +extension QueryaUiScaleContext on BuildContext { + double get uiScale => QueryaUiScaleScope.of(this); + + double scaled(double logicalPixels) => logicalPixels * uiScale; +} diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart new file mode 100644 index 00000000..f0cc2234 --- /dev/null +++ b/lib/core/layout/ui_scale_controller.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; + +/// Loads and broadcasts [AppSettings] UI scale for the widget tree. +class UiScaleController extends ChangeNotifier { + UiScaleController._(); + static final UiScaleController instance = UiScaleController._(); + + double _scale = kDefaultUiScale; + double get scale => _scale; + + Future load() async { + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } + + Future setScale(double value) async { + await AppSettings.instance.setUiScale(value); + _scale = await AppSettings.instance.getUiScale(); + notifyListeners(); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 77c537bf..e5965dd3 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -22,6 +22,19 @@ const List kSqlResultMaxRowsPresets = [ /// Default monospace size in the SQL editor (logical pixels). const double kDefaultSqlEditorFontSize = 13; +/// Default interface scale (1.0 = 100%). +const double kDefaultUiScale = 1.0; + +/// Allowed UI scale presets (nearest is used when persisting). +const List kUiScalePresets = [0.85, 0.9, 1.0, 1.1, 1.25, 1.5]; + +double _normalizeUiScale(double value) { + final clamped = value.clamp(0.85, 1.5); + return kUiScalePresets.reduce( + (a, b) => (clamped - a).abs() <= (clamped - b).abs() ? a : b, + ); +} + /// Default cap on stored SQL history entries per connection + database. const int kDefaultSqlHistoryMaxEntries = 100; @@ -57,6 +70,7 @@ abstract final class AppSettingsKeys { static const themeImportName = 'theme_import_name'; static const themeImportedColorsJson = 'theme_imported_colors_json'; static const themeAnimationEnabled = 'theme_animation_enabled'; + static const uiScale = 'ui_scale'; } /// Bumps [listenable] when any preference is persisted so open screens can reload. @@ -159,6 +173,26 @@ class AppSettings { AppSettingsRevision.bump(); } + /// Global interface scale for typography and compact controls. + Future getUiScale() async { + final v = await LocalDb.instance.getAppSetting(AppSettingsKeys.uiScale); + if (v == null || v.isEmpty) return kDefaultUiScale; + final n = double.tryParse(v); + if (n == null) return kDefaultUiScale; + return _normalizeUiScale(n); + } + + Future setUiScale(double scale) async { + final preset = kUiScalePresets.contains(scale) + ? scale + : _normalizeUiScale(scale); + await LocalDb.instance.setAppSetting( + AppSettingsKeys.uiScale, + preset.toString(), + ); + AppSettingsRevision.bump(); + } + /// Max SQL history rows kept per connection + database (oldest trimmed). Future getSqlHistoryMaxEntries() async { final v = await LocalDb.instance.getAppSetting( diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 84fe0961..6b4b3e85 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,12 +2,18 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; + +String _uiScaleLabel(double scale) { + final pct = (scale * 100).round(); + return '$pct%'; +} /// Appearance / theme controls for [PreferencesDialog]. class PreferencesAppearanceSection extends material.StatefulWidget { @@ -21,6 +27,7 @@ class PreferencesAppearanceSection extends material.StatefulWidget { class _PreferencesAppearanceSectionState extends material.State { final _controller = ThemeController.instance; + final _uiScale = UiScaleController.instance; String? _importError; bool _importing = false; @@ -28,11 +35,13 @@ class _PreferencesAppearanceSectionState void initState() { super.initState(); _controller.addListener(_onThemeChanged); + _uiScale.addListener(_onThemeChanged); } @override void dispose() { _controller.removeListener(_onThemeChanged); + _uiScale.removeListener(_onThemeChanged); super.dispose(); } @@ -48,6 +57,10 @@ class _PreferencesAppearanceSectionState await _controller.setPreset(preset); } + Future _setUiScale(double scale) async { + await _uiScale.setScale(scale); + } + Future _pickAndImportTheme() async { setState(() { _importing = true; @@ -101,77 +114,90 @@ class _PreferencesAppearanceSectionState children: [ const Text('Appearance').semiBold().small().foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Theme mode').small().foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: c.themeMode, - onSelected: (v) { - if (v != null) unawaited(_setThemeMode(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: ThemeMode.dark, - label: 'Dark', - ), - material.DropdownMenuEntry( - value: ThemeMode.light, - label: 'Light', - ), - material.DropdownMenuEntry( - value: ThemeMode.system, - label: 'System', - ), - ], - ), - ], + PreferencesFieldRow( + label: 'Theme mode', + control: PreferencesDropdownMenu( + value: c.themeMode, + onSelected: (v) { + if (v != null) unawaited(_setThemeMode(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: ThemeMode.dark, + label: 'Dark', + ), + material.DropdownMenuEntry( + value: ThemeMode.light, + label: 'Light', + ), + material.DropdownMenuEntry( + value: ThemeMode.system, + label: 'System', + ), + ], + ), ), const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: const material.EdgeInsets.only(top: 10), - child: const Text('Color preset').small().foreground(), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: PreferencesDropdownMenu( - value: c.preset, - expandToParent: true, - onSelected: (v) { - if (v != null) unawaited(_setPreset(v)); - }, - entries: [ - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaDark, - label: 'Querya Dark', - ), - const material.DropdownMenuEntry( - value: QueryaThemePreset.queryaLight, - label: 'Querya Light', - ), - material.DropdownMenuEntry( - value: QueryaThemePreset.imported, - enabled: c.hasImportedTheme, - label: importedLabel, - ), - ], + PreferencesFieldRow( + label: 'Color preset', + control: PreferencesDropdownMenu( + value: c.preset, + onSelected: (v) { + if (v != null) unawaited(_setPreset(v)); + }, + entries: [ + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaDark, + label: 'Querya Dark', ), - ), - ], + const material.DropdownMenuEntry( + value: QueryaThemePreset.queryaLight, + label: 'Querya Light', + ), + material.DropdownMenuEntry( + value: QueryaThemePreset.imported, + enabled: c.hasImportedTheme, + label: importedLabel, + ), + ], + ), ), const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Animate theme changes').small().foreground(), - const material.SizedBox(width: 12), - material.Switch( - value: c.themeAnimationEnabled, - onChanged: (v) => unawaited(_setThemeAnimation(v)), - ), - ], + PreferencesFieldRow( + label: 'Interface scale', + hint: 'Scales labels, menus, and compact controls across the app.', + control: PreferencesDropdownMenu( + value: _uiScale.scale, + onSelected: (v) { + if (v != null) unawaited(_setUiScale(v)); + }, + entries: [ + for (final scale in kUiScalePresets) + material.DropdownMenuEntry( + value: scale, + label: _uiScaleLabel(scale), + ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Animate theme changes', + control: material.Builder( + builder: (context) { + final h = QueryaDropdownTokens.scaledTriggerHeight(context); + return material.SizedBox( + height: h, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: material.Switch( + value: c.themeAnimationEnabled, + onChanged: (v) => unawaited(_setThemeAnimation(v)), + ), + ), + ); + }, + ), ), const material.SizedBox(height: 4), const PreferencesHint( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index f5b40850..0b462ed7 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -22,6 +22,55 @@ class PreferencesHint extends StatelessWidget { } } +/// Label + full-width control row for Preferences (uniform dropdown width). +class PreferencesFieldRow extends StatelessWidget { + const PreferencesFieldRow({ + super.key, + required this.label, + required this.control, + this.hint, + this.labelWidth = kPreferencesLabelWidth, + }); + + final String label; + final material.Widget control; + final String? hint; + final double labelWidth; + + @override + material.Widget build(material.BuildContext context) { + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: labelWidth, + height: triggerHeight, + child: material.Align( + alignment: material.Alignment.centerLeft, + child: Text(label).small().foreground(), + ), + ), + const material.SizedBox(width: 12), + material.Expanded(child: control), + ], + ), + if (hint != null) ...[ + const material.SizedBox(height: 4), + material.Padding( + padding: material.EdgeInsets.only(left: labelWidth + 12), + child: PreferencesHint(hint!), + ), + ], + ], + ); + } +} + /// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ @@ -30,7 +79,7 @@ class PreferencesDropdownMenu extends StatelessWidget { required this.entries, required this.onSelected, this.width, - this.expandToParent = false, + this.expandToParent = true, this.enabled = true, }); @@ -38,10 +87,10 @@ class PreferencesDropdownMenu extends StatelessWidget { final List> entries; final material.ValueChanged onSelected; - /// Fixed width for field + menu. Do not pass [double.infinity] — menu glitches. + /// Fixed width when [expandToParent] is false. final double? width; - /// Fill [Expanded] parent width without stretching the popup to screen width. + /// Fill parent — use inside [PreferencesFieldRow]. final bool expandToParent; final bool enabled; diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 1a84d5f1..664b48bb 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -144,17 +144,13 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout') - .small() - .foreground(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _pgTimeout, - onChanged: (v) => unawaited(_setPg(v)), - ), - ], + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _pgTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setPg(v)), + ), ), const material.SizedBox(height: 24), const Text('SQL — MySQL / MariaDB') @@ -162,17 +158,13 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Statement timeout') - .small() - .foreground(), - const material.SizedBox(width: 12), - SqlStatementTimeoutDropdown( - value: _mysqlTimeout, - onChanged: (v) => unawaited(_setMysql(v)), - ), - ], + PreferencesFieldRow( + label: 'Statement timeout', + control: SqlStatementTimeoutDropdown( + value: _mysqlTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setMysql(v)), + ), ), const material.SizedBox(height: 24), const Text('SQL editor') @@ -180,109 +172,78 @@ class _PreferencesDialogContentState .small() .foreground(), const material.SizedBox(height: 8), - material.Row( - children: [ - const Text('Max rows in results') - .small() - .foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: _maxRows, - onSelected: (v) { - if (v != null) unawaited(_setMaxRows(v)); - }, - entries: [ - for (final n in kSqlResultMaxRowsPresets) - material.DropdownMenuEntry( - value: n, - label: '$n', - ), - ], - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.Padding( - padding: - const material.EdgeInsets.only(top: 8), - child: const Text('Query history limit') - .small() - .foreground(), - ), - const material.SizedBox(width: 12), - material.Expanded( - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - PreferencesDropdownMenu( - value: _historyMax, - expandToParent: true, - onSelected: (v) { - if (v != null) { - unawaited(_setHistoryMax(v)); - } - }, - entries: [ - for (final n - in kSqlHistoryMaxEntriesPresets) - material.DropdownMenuEntry( - value: n, - label: '$n entries', - ), - ], - ), - const material.SizedBox(height: 4), - const PreferencesHint( - 'Per connection and database; oldest queries are dropped.', - ), - ], - ), - ), - ], - ), - const material.SizedBox(height: 12), - material.Row( - children: [ - const Text('Font size').small().foreground(), - const material.SizedBox(width: 12), - PreferencesDropdownMenu( - value: _fontSize, - onSelected: (v) { - if (v != null) unawaited(_setFont(v)); - }, - entries: const [ - material.DropdownMenuEntry( - value: 11.0, - label: '11 pt', - ), - material.DropdownMenuEntry( - value: 12.0, - label: '12 pt', - ), + PreferencesFieldRow( + label: 'Max rows in results', + control: PreferencesDropdownMenu( + value: _maxRows, + onSelected: (v) { + if (v != null) unawaited(_setMaxRows(v)); + }, + entries: [ + for (final n in kSqlResultMaxRowsPresets) material.DropdownMenuEntry( - value: 13.0, - label: '13 pt', - ), - material.DropdownMenuEntry( - value: 14.0, - label: '14 pt', - ), - material.DropdownMenuEntry( - value: 16.0, - label: '16 pt', + value: n, + label: '$n', ), + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Query history limit', + hint: + 'Per connection and database; oldest queries are dropped.', + control: PreferencesDropdownMenu( + value: _historyMax, + onSelected: (v) { + if (v != null) { + unawaited(_setHistoryMax(v)); + } + }, + entries: [ + for (final n in kSqlHistoryMaxEntriesPresets) material.DropdownMenuEntry( - value: 18.0, - label: '18 pt', + value: n, + label: '$n entries', ), - ], - ), - ], + ], + ), + ), + const material.SizedBox(height: 12), + PreferencesFieldRow( + label: 'Font size', + control: PreferencesDropdownMenu( + value: _fontSize, + onSelected: (v) { + if (v != null) unawaited(_setFont(v)); + }, + entries: const [ + material.DropdownMenuEntry( + value: 11.0, + label: '11 pt', + ), + material.DropdownMenuEntry( + value: 12.0, + label: '12 pt', + ), + material.DropdownMenuEntry( + value: 13.0, + label: '13 pt', + ), + material.DropdownMenuEntry( + value: 14.0, + label: '14 pt', + ), + material.DropdownMenuEntry( + value: 16.0, + label: '16 pt', + ), + material.DropdownMenuEntry( + value: 18.0, + label: '18 pt', + ), + ], + ), ), const material.SizedBox(height: 16), const PreferencesHint( diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index 4a664a20..129bb956 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -30,17 +30,20 @@ class SqlStatementTimeoutDropdown extends material.StatelessWidget { required this.value, required this.onChanged, this.enabled = true, + this.expandToParent = false, }); final int? value; final void Function(int?) onChanged; final bool enabled; + final bool expandToParent; @override material.Widget build(material.BuildContext context) { return QueryaDropdown( value: value, enabled: enabled, + expandToParent: expandToParent, onSelected: onChanged, items: kSqlStatementTimeoutMenuItems, ); diff --git a/lib/main.dart b/lib/main.dart index 14dbd026..887d9a07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'app/app.dart'; import 'core/editor/syntax_highlight_service.dart'; +import 'core/layout/ui_scale_controller.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; @@ -11,6 +12,7 @@ void main() async { await LocalDb.initFfi(); await SyntaxHighlightService.ensureInitialized(); await ThemeController.instance.load(); + await UiScaleController.instance.load(); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 843bf8b0..039f0fa3 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -71,19 +72,18 @@ class _QueryaDropdownState extends material.State> { } material.Widget _triggerLabelText({ + required material.BuildContext context, required String label, required ColorScheme cs, required bool expand, }) { + final textColor = + widget.enabled ? cs.popoverForeground : cs.mutedForeground; final text = material.Text( label, maxLines: 1, overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: QueryaDropdownTokens.fontSize, - fontWeight: material.FontWeight.w500, - color: widget.enabled ? cs.foreground : cs.mutedForeground, - ), + style: QueryaDropdownTokens.triggerTextStyle(context, textColor), ); if (expand) { return material.Expanded(child: text); @@ -121,6 +121,10 @@ class _QueryaDropdownState extends material.State> { final borderColor = widget.enabled ? (_triggerHovered ? cs.ring : cs.border) : cs.border.withValues(alpha: 0.4); + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + final chevronGap = context.scaled(QueryaDropdownTokens.triggerChevronGap); + final chevronSize = context.scaled(QueryaDropdownTokens.triggerChevronSize); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final triggerBody = material.MouseRegion( cursor: widget.enabled @@ -133,27 +137,31 @@ class _QueryaDropdownState extends material.State> { milliseconds: QueryaDropdownTokens.hoverAnimationMs, ), curve: material.Curves.easeOut, - height: QueryaDropdownTokens.triggerHeight, - padding: QueryaDropdownTokens.triggerPadding, + height: triggerHeight, + padding: QueryaDropdownTokens.scaledTriggerPadding(context), decoration: material.BoxDecoration( color: _triggerHovered - ? cs.muted.withValues(alpha: 0.22) - : cs.muted.withValues(alpha: 0.08), - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + ? cs.muted.withValues(alpha: 0.28) + : cs.muted.withValues(alpha: 0.14), + borderRadius: material.BorderRadius.circular(radius), border: material.Border.all(color: borderColor), ), child: material.Row( mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - mainAxisSize: - widget.expandToParent ? material.MainAxisSize.max : material.MainAxisSize.min, + mainAxisSize: widget.expandToParent + ? material.MainAxisSize.max + : material.MainAxisSize.min, children: [ - _triggerLabelText(label: label, cs: cs, expand: widget.expandToParent), - const material.SizedBox(width: QueryaDropdownTokens.triggerChevronGap), + _triggerLabelText( + context: context, + label: label, + cs: cs, + expand: widget.expandToParent, + ), + material.SizedBox(width: chevronGap), material.Icon( material.Icons.keyboard_arrow_down_rounded, - size: QueryaDropdownTokens.triggerChevronSize, + size: chevronSize, color: widget.enabled ? cs.mutedForeground : cs.mutedForeground.withValues(alpha: 0.5), @@ -175,9 +183,7 @@ class _QueryaDropdownState extends material.State> { } } : null, - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), child: fieldWidth != null ? material.SizedBox(width: fieldWidth, child: triggerBody) : triggerBody, @@ -189,15 +195,23 @@ class _QueryaDropdownState extends material.State> { material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; final label = _labelFor(widget.value); - final fieldWidth = widget.expandToParent ? null : widget.width; + final fieldWidth = widget.expandToParent + ? null + : (widget.width != null ? context.scaled(widget.width!) : null); final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); - final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold - ? widget.menuMaxHeight - : double.infinity; + final scaledMaxHeight = context.scaled(widget.menuMaxHeight); + final effectiveMaxHeight = + widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold + ? scaledMaxHeight + : double.infinity; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final anchor = material.MenuAnchor( controller: _controller, - alignmentOffset: widget.alignmentOffset, + alignmentOffset: material.Offset( + widget.alignmentOffset.dx, + context.scaled(widget.alignmentOffset.dy), + ), consumeOutsideTap: true, style: material.MenuStyle( backgroundColor: material.WidgetStatePropertyAll(cs.popover), @@ -216,9 +230,7 @@ class _QueryaDropdownState extends material.State> { ), shape: material.WidgetStatePropertyAll( material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), side: material.BorderSide(color: cs.border), ), ), @@ -269,28 +281,34 @@ class _QueryaDropdownMenuItem extends material.StatefulWidget { class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenuItem> { bool _hovered = false; - material.Widget _leading(ColorScheme cs) { + material.Widget _leading(material.BuildContext context, ColorScheme cs) { + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + final checkSize = context.scaled(QueryaDropdownTokens.selectedCheckSize); if (widget.selected) { return material.Icon( material.Icons.check_rounded, - size: QueryaDropdownTokens.selectedCheckSize, + size: checkSize, color: cs.primary, ); } if (widget.item.leading != null) { return widget.item.leading!; } - return const material.SizedBox(width: QueryaDropdownTokens.selectedCheckSlotWidth); + return material.SizedBox(width: slot); } @override material.Widget build(material.BuildContext context) { final cs = widget.colorScheme; final bg = _hovered - ? cs.accent.withValues(alpha: 0.12) + ? cs.accent.withValues(alpha: 0.14) : widget.selected - ? cs.muted.withValues(alpha: 0.28) + ? cs.muted.withValues(alpha: 0.32) : material.Colors.transparent; + final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); + final minWidth = context.scaled(QueryaDropdownTokens.menuItemMinWidth); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); return material.MouseRegion( cursor: widget.enabled @@ -300,51 +318,43 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, child: material.MenuItemButton( style: material.MenuItemButton.styleFrom( - minimumSize: const material.Size( - QueryaDropdownTokens.menuItemMinWidth, - QueryaDropdownTokens.menuItemHeight, - ), + minimumSize: material.Size(minWidth, itemHeight), padding: material.EdgeInsets.zero, foregroundColor: cs.popoverForeground, disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), overlayColor: material.Colors.transparent, shape: material.RoundedRectangleBorder( - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), ), ), onPressed: widget.enabled ? widget.onPick : null, child: material.AnimatedContainer( duration: const Duration( - milliseconds: QueryaDropdownTokens.hoverAnimationMs, - ), + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), curve: material.Curves.easeOut, - padding: QueryaDropdownTokens.menuItemPadding, + constraints: material.BoxConstraints(minHeight: itemHeight), + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), + vertical: context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), + ), decoration: material.BoxDecoration( color: bg, - borderRadius: material.BorderRadius.circular( - QueryaDropdownTokens.menuBorderRadius, - ), + borderRadius: material.BorderRadius.circular(radius), ), child: material.Row( children: [ - material.SizedBox( - width: QueryaDropdownTokens.selectedCheckSlotWidth, - child: _leading(cs), - ), - const material.SizedBox(width: 6), + material.SizedBox(width: slot, child: _leading(context, cs)), + material.SizedBox(width: context.scaled(6)), material.Expanded( child: material.Text( widget.item.label, maxLines: 1, overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: QueryaDropdownTokens.fontSize, - fontWeight: widget.selected - ? material.FontWeight.w600 - : material.FontWeight.w400, - color: cs.popoverForeground, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: widget.selected, ), ), ), diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart index 9fe851a1..7ee67934 100644 --- a/lib/shared/widgets/querya_dropdown_tokens.dart +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; /// Fixed metrics for [QueryaDropdown] — single source of truth for dropdown UI. abstract final class QueryaDropdownTokens { - /// Compact desktop trigger height. - static const double triggerHeight = 32.0; + /// Compact desktop trigger height (content is vertically centered). + static const double triggerHeight = 36.0; - static const material.EdgeInsets triggerPadding = - material.EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + static const double triggerPaddingHorizontal = 12.0; static const double triggerChevronGap = 8.0; @@ -22,25 +22,72 @@ abstract final class QueryaDropdownTokens { static const double menuElevation = 8.0; - static const double menuShadowBlurRadius = 8.0; - static const material.Color menuShadowColor = material.Color(0x42000000); static const material.EdgeInsets menuPadding = material.EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0); - static const double menuItemHeight = 28.0; + static const double menuItemHeight = 32.0; static const material.EdgeInsets menuItemPadding = - material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0); + material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0); static const double menuItemMinWidth = 180.0; - static const double fontSize = 13.0; + static const double fontSize = 14.0; + + static const double lineHeight = 1.25; static const double selectedCheckSize = 16.0; static const double selectedCheckSlotWidth = 18.0; static const int hoverAnimationMs = 120; + + static double scaledTriggerHeight(material.BuildContext context) => + context.scaled(triggerHeight); + + static double scaledFontSize(material.BuildContext context) => + context.scaled(fontSize); + + static double scaledMenuItemHeight(material.BuildContext context) => + context.scaled(menuItemHeight); + + static double scaledMenuMaxHeight(material.BuildContext context) => + context.scaled(menuMaxHeight); + + static material.EdgeInsets scaledTriggerPadding(material.BuildContext context) => + material.EdgeInsets.symmetric( + horizontal: context.scaled(triggerPaddingHorizontal), + ); + + static material.TextStyle triggerTextStyle( + material.BuildContext context, + material.Color color, + ) { + final size = scaledFontSize(context); + return material.TextStyle( + fontSize: size, + height: lineHeight, + fontWeight: material.FontWeight.w500, + color: color, + ); + } + + static material.TextStyle menuItemTextStyle( + material.BuildContext context, + material.Color color, { + required bool selected, + }) { + final size = scaledFontSize(context); + return material.TextStyle( + fontSize: size, + height: lineHeight, + fontWeight: selected ? material.FontWeight.w600 : material.FontWeight.w400, + color: color, + ); + } } + +/// Uniform label column width in [PreferencesFieldRow]. +const double kPreferencesLabelWidth = 152.0; diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 7dfe6fff..4c4619cf 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -9,5 +9,6 @@ library; export 'app_dialog.dart'; -export 'querya_dropdown.dart' show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens; +export 'querya_dropdown.dart' + show QueryaDropdown, QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 5b1a0d99..16fa0b8a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -229,6 +229,20 @@ void main() { }); }); + group('ui scale', () { + test('defaults to 1.0 and snaps to presets', () async { + expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); + + await AppSettings.instance.setUiScale(1.12); + expect(await AppSettings.instance.getUiScale(), 1.1); + + await AppSettings.instance.setUiScale(1.5); + expect(await AppSettings.instance.getUiScale(), 1.5); + + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); + }); + }); + group('AppSettingsRevision', () { test('bump increments listenable value', () { final start = AppSettingsRevision.listenable.value; diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index fb1e0211..408ae03a 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -7,11 +7,11 @@ import '../support/querya_theme_test_shell.dart'; void main() { group('QueryaDropdownTokens', () { test('uses design-system defaults from issue #89', () { - expect(QueryaDropdownTokens.triggerHeight, 32.0); + expect(QueryaDropdownTokens.triggerHeight, 36.0); expect(QueryaDropdownTokens.menuAlignmentOffset, const material.Offset(0, 4)); expect(QueryaDropdownTokens.menuMaxHeight, 300.0); expect(QueryaDropdownTokens.menuBorderRadius, 6.0); - expect(QueryaDropdownTokens.fontSize, 13.0); + expect(QueryaDropdownTokens.fontSize, 14.0); }); }); diff --git a/test/support/querya_theme_test_shell.dart b/test/support/querya_theme_test_shell.dart index 32f13e6b..11090d9b 100644 --- a/test/support/querya_theme_test_shell.dart +++ b/test/support/querya_theme_test_shell.dart @@ -1,3 +1,4 @@ +import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -13,9 +14,12 @@ Widget queryaThemeTestShell({ theme: td, darkTheme: td, themeMode: ThemeMode.dark, - builder: (context, appChild) => QueryaThemeScope( - data: data, - child: appChild ?? const SizedBox.shrink(), + builder: (context, appChild) => QueryaUiScaleScope( + scale: 1.0, + child: QueryaThemeScope( + data: data, + child: appChild ?? const SizedBox.shrink(), + ), ), home: child, ); From 4495bc4a198535ad32e24a2e7cd5e98b0e0c4e63 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 18:54:59 +0300 Subject: [PATCH 05/32] feat(ui): match dropdown menu width to trigger and add scale slider (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu panel follows anchor width via crossAxisUnconstrained; replace interface scale presets with a Telegram-style 75–200% slider with live preview. --- lib/core/layout/ui_scale_controller.dart | 19 ++++++- lib/core/storage/app_settings.dart | 23 ++++---- .../preferences_appearance_section.dart | 27 ++-------- .../settings/preferences_controls.dart | 53 +++++++++++++++++++ lib/shared/widgets/querya_dropdown.dart | 4 +- .../widgets/querya_dropdown_tokens.dart | 2 - test/core/storage/app_settings_test.dart | 11 ++-- .../settings/interface_scale_slider_test.dart | 42 +++++++++++++++ test/shared/querya_dropdown_test.dart | 27 ++++++++++ 9 files changed, 165 insertions(+), 43 deletions(-) create mode 100644 test/features/settings/interface_scale_slider_test.dart diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index f0cc2234..1ae2c6d8 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -14,9 +14,26 @@ class UiScaleController extends ChangeNotifier { notifyListeners(); } - Future setScale(double value) async { + /// Live preview while dragging the scale slider (not persisted). + void setScalePreview(double value) { + final next = _normalizeUiScale(value); + if (next == _scale) return; + _scale = next; + notifyListeners(); + } + + /// Persist scale to SQLite (called on slider release). + Future commitScale(double value) async { await AppSettings.instance.setUiScale(value); _scale = await AppSettings.instance.getUiScale(); notifyListeners(); } + + Future setScale(double value) => commitScale(value); + + double _normalizeUiScale(double value) { + final clamped = value.clamp(kMinUiScale, kMaxUiScale); + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + } } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index e5965dd3..04fa7390 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -25,14 +25,19 @@ const double kDefaultSqlEditorFontSize = 13; /// Default interface scale (1.0 = 100%). const double kDefaultUiScale = 1.0; -/// Allowed UI scale presets (nearest is used when persisting). -const List kUiScalePresets = [0.85, 0.9, 1.0, 1.1, 1.25, 1.5]; +/// Minimum interface scale (Telegram Desktop supports 75%). +const double kMinUiScale = 0.75; + +/// Maximum interface scale (Telegram goes to 300%; 200% is enough for Querya). +const double kMaxUiScale = 2.0; + +/// Slider step — 1% increments, like Telegram's interface scale control. +const double kUiScaleStep = 0.01; double _normalizeUiScale(double value) { - final clamped = value.clamp(0.85, 1.5); - return kUiScalePresets.reduce( - (a, b) => (clamped - a).abs() <= (clamped - b).abs() ? a : b, - ); + final clamped = value.clamp(kMinUiScale, kMaxUiScale); + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); } /// Default cap on stored SQL history entries per connection + database. @@ -183,12 +188,10 @@ class AppSettings { } Future setUiScale(double scale) async { - final preset = kUiScalePresets.contains(scale) - ? scale - : _normalizeUiScale(scale); + final normalized = _normalizeUiScale(scale); await LocalDb.instance.setAppSetting( AppSettingsKeys.uiScale, - preset.toString(), + normalized.toStringAsFixed(2), ); AppSettingsRevision.bump(); } diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 6b4b3e85..3c1c3645 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -3,18 +3,12 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; -import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -String _uiScaleLabel(double scale) { - final pct = (scale * 100).round(); - return '$pct%'; -} - /// Appearance / theme controls for [PreferencesDialog]. class PreferencesAppearanceSection extends material.StatefulWidget { const PreferencesAppearanceSection({super.key}); @@ -57,10 +51,6 @@ class _PreferencesAppearanceSectionState await _controller.setPreset(preset); } - Future _setUiScale(double scale) async { - await _uiScale.setScale(scale); - } - Future _pickAndImportTheme() async { setState(() { _importing = true; @@ -165,20 +155,9 @@ class _PreferencesAppearanceSectionState const material.SizedBox(height: 12), PreferencesFieldRow( label: 'Interface scale', - hint: 'Scales labels, menus, and compact controls across the app.', - control: PreferencesDropdownMenu( - value: _uiScale.scale, - onSelected: (v) { - if (v != null) unawaited(_setUiScale(v)); - }, - entries: [ - for (final scale in kUiScalePresets) - material.DropdownMenuEntry( - value: scale, - label: _uiScaleLabel(scale), - ), - ], - ), + hint: + 'Drag to resize the UI (75–200%). Changes apply live; release to save.', + control: InterfaceScaleSlider(scale: _uiScale.scale), ), const material.SizedBox(height: 12), PreferencesFieldRow( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index 0b462ed7..e976d36d 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -1,4 +1,8 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -71,6 +75,55 @@ class PreferencesFieldRow extends StatelessWidget { } } +/// Telegram-style interface scale slider (75–200%, 1% steps, live preview). +class InterfaceScaleSlider extends StatelessWidget { + const InterfaceScaleSlider({super.key, required this.scale}); + + final double scale; + + static int get _divisions => + ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pct = (scale * 100).round(); + + return material.Row( + children: [ + material.Expanded( + child: Slider( + value: SliderValue.single(scale), + min: kMinUiScale, + max: kMaxUiScale, + divisions: _divisions, + hintValue: const SliderValue.single(kDefaultUiScale), + onChanged: (value) { + UiScaleController.instance.setScalePreview(value.value); + }, + onChangeEnd: (value) { + unawaited(UiScaleController.instance.commitScale(value.value)); + }, + ), + ), + const material.SizedBox(width: 8), + material.SizedBox( + width: 44, + child: material.Text( + '$pct%', + textAlign: material.TextAlign.right, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.popoverForeground, + ), + ), + ), + ], + ); + } +} + /// Preferences dropdown backed by [QueryaDropdown] ([MenuAnchor]). class PreferencesDropdownMenu extends StatelessWidget { const PreferencesDropdownMenu({ diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 039f0fa3..3480ec79 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -208,6 +208,7 @@ class _QueryaDropdownState extends material.State> { final anchor = material.MenuAnchor( controller: _controller, + crossAxisUnconstrained: false, alignmentOffset: material.Offset( widget.alignmentOffset.dx, context.scaled(widget.alignmentOffset.dy), @@ -306,7 +307,6 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu ? cs.muted.withValues(alpha: 0.32) : material.Colors.transparent; final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); - final minWidth = context.scaled(QueryaDropdownTokens.menuItemMinWidth); final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); @@ -318,7 +318,7 @@ class _QueryaDropdownMenuItemState extends material.State<_QueryaDropdownMenu onExit: widget.enabled ? (_) => setState(() => _hovered = false) : null, child: material.MenuItemButton( style: material.MenuItemButton.styleFrom( - minimumSize: material.Size(minWidth, itemHeight), + minimumSize: material.Size(double.infinity, itemHeight), padding: material.EdgeInsets.zero, foregroundColor: cs.popoverForeground, disabledForegroundColor: cs.mutedForeground.withValues(alpha: 0.5), diff --git a/lib/shared/widgets/querya_dropdown_tokens.dart b/lib/shared/widgets/querya_dropdown_tokens.dart index 7ee67934..367bbaaf 100644 --- a/lib/shared/widgets/querya_dropdown_tokens.dart +++ b/lib/shared/widgets/querya_dropdown_tokens.dart @@ -32,8 +32,6 @@ abstract final class QueryaDropdownTokens { static const material.EdgeInsets menuItemPadding = material.EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0); - static const double menuItemMinWidth = 180.0; - static const double fontSize = 14.0; static const double lineHeight = 1.25; diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 16fa0b8a..dfeba43d 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -230,14 +230,17 @@ void main() { }); group('ui scale', () { - test('defaults to 1.0 and snaps to presets', () async { + test('defaults to 1.0 and stores continuous 1% steps', () async { expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); await AppSettings.instance.setUiScale(1.12); - expect(await AppSettings.instance.getUiScale(), 1.1); + expect(await AppSettings.instance.getUiScale(), closeTo(1.12, 0.001)); - await AppSettings.instance.setUiScale(1.5); - expect(await AppSettings.instance.getUiScale(), 1.5); + await AppSettings.instance.setUiScale(0.75); + expect(await AppSettings.instance.getUiScale(), kMinUiScale); + + await AppSettings.instance.setUiScale(2.5); + expect(await AppSettings.instance.getUiScale(), kMaxUiScale); await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); }); diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart new file mode 100644 index 00000000..82ceb734 --- /dev/null +++ b/test/features/settings/interface_scale_slider_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('InterfaceScaleSlider', () { + testWidgets('shows percentage and slider', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: InterfaceScaleSlider(scale: 1.0), + ), + ), + ); + await tester.pump(); + + expect(find.text('100%'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + }); + + test('preview updates controller without persisting', () { + final controller = UiScaleController.instance; + final before = controller.scale; + controller.setScalePreview(1.15); + expect(controller.scale, closeTo(1.15, 0.001)); + controller.setScalePreview(before); + }); + }); + + group('ui scale range', () { + test('matches Telegram-style 75–200 percent bounds', () { + expect(kMinUiScale, 0.75); + expect(kMaxUiScale, 2.0); + expect(kUiScaleStep, 0.01); + }); + }); +} diff --git a/test/shared/querya_dropdown_test.dart b/test/shared/querya_dropdown_test.dart index 408ae03a..ec2fa07f 100644 --- a/test/shared/querya_dropdown_test.dart +++ b/test/shared/querya_dropdown_test.dart @@ -68,6 +68,33 @@ void main() { expect(picked, 2); }); + testWidgets('menu anchor constrains width to trigger', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 240, + child: QueryaDropdown( + value: 'a', + expandToParent: true, + items: const [ + QueryaDropdownItem(value: 'a', label: 'Alpha'), + QueryaDropdownItem(value: 'b', label: 'Beta'), + ], + onSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final anchor = tester.widget( + find.byType(material.MenuAnchor), + ); + expect(anchor.crossAxisUnconstrained, isFalse); + }); + testWidgets('shows check on selected menu item', (tester) async { await tester.pumpWidget( queryaThemeTestShell( From 680804cedc8ed75b3644bffb669814b0e43e47b6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 19:00:40 +0300 Subject: [PATCH 06/32] feat(ui): snap interface scale slider to presets, Shift for fine steps (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default slider ticks at 75–200% presets; hold Shift for 1% continuous control. --- lib/core/layout/ui_scale_controller.dart | 20 +-- lib/core/storage/app_settings.dart | 42 +++++- .../preferences_appearance_section.dart | 2 +- .../settings/preferences_controls.dart | 123 +++++++++++++----- test/core/storage/app_settings_test.dart | 5 +- .../settings/interface_scale_slider_test.dart | 23 +++- 6 files changed, 164 insertions(+), 51 deletions(-) diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index 1ae2c6d8..ce318e9b 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -15,25 +15,29 @@ class UiScaleController extends ChangeNotifier { } /// Live preview while dragging the scale slider (not persisted). - void setScalePreview(double value) { - final next = _normalizeUiScale(value); + void setScalePreview(double value, {bool fine = false}) { + final next = _normalize(value, fine: fine); if (next == _scale) return; _scale = next; notifyListeners(); } /// Persist scale to SQLite (called on slider release). - Future commitScale(double value) async { - await AppSettings.instance.setUiScale(value); + Future commitScale(double value, {bool fine = false}) async { + await AppSettings.instance.setUiScale(value, fine: fine); _scale = await AppSettings.instance.getUiScale(); notifyListeners(); } - Future setScale(double value) => commitScale(value); + Future setScale(double value, {bool fine = false}) => + commitScale(value, fine: fine); - double _normalizeUiScale(double value) { + double _normalize(double value, {required bool fine}) { final clamped = value.clamp(kMinUiScale, kMaxUiScale); - final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); - return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + if (fine) { + final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); + return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + } + return snapUiScaleToPreset(clamped); } } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 04fa7390..ad8036c2 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -31,15 +31,47 @@ const double kMinUiScale = 0.75; /// Maximum interface scale (Telegram goes to 300%; 200% is enough for Querya). const double kMaxUiScale = 2.0; -/// Slider step — 1% increments, like Telegram's interface scale control. +/// Slider step — 1% increments when Shift is held (fine control). const double kUiScaleStep = 0.01; -double _normalizeUiScale(double value) { +/// Fixed tick marks on the interface scale slider (75% … 200%). +const List kUiScalePresets = [ + 0.75, + 0.85, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, +]; + +int nearestUiScalePresetIndex(double scale) { + var best = 0; + var bestDist = double.infinity; + for (var i = 0; i < kUiScalePresets.length; i++) { + final dist = (kUiScalePresets[i] - scale).abs(); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + } + return best; +} + +double snapUiScaleToPreset(double scale) => + kUiScalePresets[nearestUiScalePresetIndex(scale)]; + +double _normalizeUiScaleContinuous(double value) { final clamped = value.clamp(kMinUiScale, kMaxUiScale); final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); } +double _normalizeUiScale(double value, {bool fine = false}) => + fine ? _normalizeUiScaleContinuous(value) : snapUiScaleToPreset(value); + /// Default cap on stored SQL history entries per connection + database. const int kDefaultSqlHistoryMaxEntries = 100; @@ -184,11 +216,11 @@ class AppSettings { if (v == null || v.isEmpty) return kDefaultUiScale; final n = double.tryParse(v); if (n == null) return kDefaultUiScale; - return _normalizeUiScale(n); + return _normalizeUiScaleContinuous(n); } - Future setUiScale(double scale) async { - final normalized = _normalizeUiScale(scale); + Future setUiScale(double scale, {bool fine = false}) async { + final normalized = _normalizeUiScale(scale, fine: fine); await LocalDb.instance.setAppSetting( AppSettingsKeys.uiScale, normalized.toStringAsFixed(2), diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 3c1c3645..7c464a92 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -156,7 +156,7 @@ class _PreferencesAppearanceSectionState PreferencesFieldRow( label: 'Interface scale', hint: - 'Drag to resize the UI (75–200%). Changes apply live; release to save.', + 'Snap to presets (75%, 85%, 90%, 100% …). Hold Shift for 1% fine control.', control: InterfaceScaleSlider(scale: _uiScale.scale), ), const material.SizedBox(height: 12), diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index e976d36d..a72022ce 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.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/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; @@ -75,52 +76,106 @@ class PreferencesFieldRow extends StatelessWidget { } } -/// Telegram-style interface scale slider (75–200%, 1% steps, live preview). -class InterfaceScaleSlider extends StatelessWidget { +/// Interface scale slider: fixed presets by default; hold Shift for 1% fine steps. +class InterfaceScaleSlider extends material.StatefulWidget { const InterfaceScaleSlider({super.key, required this.scale}); final double scale; - static int get _divisions => + @override + material.State createState() => + _InterfaceScaleSliderState(); +} + +class _InterfaceScaleSliderState extends material.State { + bool _fineControl = false; + + static int get _fineDivisions => ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); + bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; + + void _syncModifierKeys() { + final fine = _shiftHeld; + if (fine != _fineControl) { + setState(() => _fineControl = fine); + } + } + + double _sliderPosition(double scale, {required bool fine}) { + if (fine) return scale; + return nearestUiScalePresetIndex(scale).toDouble(); + } + + double _scaleFromSlider(double position, {required bool fine}) { + if (fine) return position; + final index = position.round().clamp(0, kUiScalePresets.length - 1); + return kUiScalePresets[index]; + } + @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final pct = (scale * 100).round(); + final pct = (widget.scale * 100).round(); + final fine = _fineControl; - return material.Row( - children: [ - material.Expanded( - child: Slider( - value: SliderValue.single(scale), - min: kMinUiScale, - max: kMaxUiScale, - divisions: _divisions, - hintValue: const SliderValue.single(kDefaultUiScale), - onChanged: (value) { - UiScaleController.instance.setScalePreview(value.value); - }, - onChangeEnd: (value) { - unawaited(UiScaleController.instance.commitScale(value.value)); - }, - ), - ), - const material.SizedBox(width: 8), - material.SizedBox( - width: 44, - child: material.Text( - '$pct%', - textAlign: material.TextAlign.right, - style: material.TextStyle( - fontSize: 13, - fontWeight: material.FontWeight.w600, - color: cs.popoverForeground, + return material.Listener( + onPointerDown: (_) => _syncModifierKeys(), + onPointerMove: (_) => _syncModifierKeys(), + child: material.Row( + children: [ + material.Expanded( + child: Slider( + value: SliderValue.single( + _sliderPosition(widget.scale, fine: fine), + ), + min: fine ? kMinUiScale : 0, + max: fine + ? kMaxUiScale + : (kUiScalePresets.length - 1).toDouble(), + divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, + hintValue: const SliderValue.single(kDefaultUiScale), + onChanged: (value) { + _syncModifierKeys(); + final next = _scaleFromSlider( + value.value, + fine: _shiftHeld, + ); + UiScaleController.instance.setScalePreview( + next, + fine: _shiftHeld, + ); + }, + onChangeEnd: (value) { + final next = _scaleFromSlider( + value.value, + fine: _shiftHeld, + ); + unawaited( + UiScaleController.instance.commitScale( + next, + fine: _shiftHeld, + ), + ); + }, + ), ), - ), + const material.SizedBox(width: 8), + material.SizedBox( + width: 44, + child: material.Text( + '$pct%', + textAlign: material.TextAlign.right, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.popoverForeground, + ), + ), + ), + ], ), - ], - ); + ); } } diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index dfeba43d..cb0d5f6a 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -233,7 +233,7 @@ void main() { test('defaults to 1.0 and stores continuous 1% steps', () async { expect(await AppSettings.instance.getUiScale(), kDefaultUiScale); - await AppSettings.instance.setUiScale(1.12); + await AppSettings.instance.setUiScale(1.12, fine: true); expect(await AppSettings.instance.getUiScale(), closeTo(1.12, 0.001)); await AppSettings.instance.setUiScale(0.75); @@ -242,6 +242,9 @@ void main() { await AppSettings.instance.setUiScale(2.5); expect(await AppSettings.instance.getUiScale(), kMaxUiScale); + await AppSettings.instance.setUiScale(1.12, fine: false); + expect(await AppSettings.instance.getUiScale(), 1.1); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.uiScale); }); }); diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart index 82ceb734..7e346486 100644 --- a/test/features/settings/interface_scale_slider_test.dart +++ b/test/features/settings/interface_scale_slider_test.dart @@ -23,12 +23,14 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); - test('preview updates controller without persisting', () { + test('preview snaps to presets unless fine mode', () { final controller = UiScaleController.instance; final before = controller.scale; controller.setScalePreview(1.15); + expect(controller.scale, 1.1); + controller.setScalePreview(1.15, fine: true); expect(controller.scale, closeTo(1.15, 0.001)); - controller.setScalePreview(before); + controller.setScalePreview(before, fine: true); }); }); @@ -38,5 +40,22 @@ void main() { expect(kMaxUiScale, 2.0); expect(kUiScaleStep, 0.01); }); + + test('presets snap to fixed ticks', () { + expect(kUiScalePresets, [ + 0.75, + 0.85, + 0.9, + 1.0, + 1.1, + 1.25, + 1.5, + 1.75, + 2.0, + ]); + expect(snapUiScaleToPreset(1.12), 1.1); + expect(snapUiScaleToPreset(0.88), 0.9); + expect(nearestUiScalePresetIndex(1.0), 3); + }); }); } From 3dc5d42b55201e8785b24bc7363660c6f3e19294 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 9 Jun 2026 19:22:04 +0300 Subject: [PATCH 07/32] feat(ui): scale modal dialog dimensions with interface scale (#89) Add WindowLayout.dialogConstraints and apply UI scale to all app modals including connection forms, preferences, and SQL editor dialogs. --- lib/core/layout/window_layout.dart | 71 +++++++++++++++---- .../connections/driver_manager_dialog.dart | 6 +- .../connections/new_connection_dialog.dart | 9 ++- .../connections/new_folder_dialog.dart | 6 +- .../main_screen/sql_query_history_dialog.dart | 3 +- .../mongodb/mongo_database_dialog.dart | 2 +- .../mongodb/mongodb_connection_form.dart | 6 +- lib/features/mysql/mysql_connection_form.dart | 6 +- .../mysql/mysql_sql_editor_dialog.dart | 3 +- .../postgres_sql_editor_dialog.dart | 3 +- .../postgres_table_privileges_dialog.dart | 23 ++++-- .../postgresql_connection_form.dart | 6 +- lib/features/redis/redis_connection_form.dart | 6 +- lib/features/settings/preferences_dialog.dart | 3 +- .../core/layout/window_layout_scale_test.dart | 54 ++++++++++++++ 15 files changed, 173 insertions(+), 34 deletions(-) create mode 100644 test/core/layout/window_layout_scale_test.dart diff --git a/lib/core/layout/window_layout.dart b/lib/core/layout/window_layout.dart index a926d607..7491d6d8 100644 --- a/lib/core/layout/window_layout.dart +++ b/lib/core/layout/window_layout.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; /// Breakpoints and sizes derived from window / overlay size (desktop adaptive UI). abstract class WindowLayout { @@ -18,6 +19,36 @@ abstract class WindowLayout { return (screenHeight * 0.04).clamp(12.0, 40.0); } + /// Scaled [BoxConstraints] for modal dialogs (respects [QueryaUiScaleScope]). + static BoxConstraints dialogConstraints( + BuildContext context, { + double? maxWidth, + double? minWidth, + double? maxHeight, + double? minHeight, + }) { + return BoxConstraints( + maxWidth: maxWidth != null ? context.scaled(maxWidth) : double.infinity, + minWidth: minWidth != null ? context.scaled(minWidth) : 0, + maxHeight: maxHeight != null ? context.scaled(maxHeight) : double.infinity, + minHeight: minHeight != null ? context.scaled(minHeight) : 0, + ); + } + + /// Fits a base dialog dimension into the viewport, then applies UI scale. + static double scaledDialogExtent( + BuildContext context, { + required double screenExtent, + required double insetTotal, + required double baseMax, + required double baseMin, + double viewportFactor = 1.0, + }) { + final available = math.max(0.0, screenExtent - insetTotal); + final base = math.min(baseMax, math.max(baseMin, available * viewportFactor)); + return math.min(context.scaled(base), available); + } + /// Use for [Dialog.insetPadding] / modal margins on small windows. static EdgeInsets dialogSymmetricInsets(BuildContext context) { final mq = MediaQuery.sizeOf(context); @@ -28,15 +59,30 @@ abstract class WindowLayout { } /// "Select database" and similar pickers. - static double newConnectionDialogMaxWidth(double screenWidth) { - final inset = dialogHorizontalInset(screenWidth) * 2; - return math.min(740, math.max(280.0, screenWidth - inset)); + static double newConnectionDialogMaxWidth(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + final inset = dialogHorizontalInset(mq.width) * 2; + return scaledDialogExtent( + context, + screenExtent: mq.width, + insetTotal: inset, + baseMax: 740, + baseMin: 280, + viewportFactor: 1.0, + ); } - static double newConnectionDialogHeight(double screenHeight) { - final inset = dialogVerticalInset(screenHeight) * 2; - final h = screenHeight - inset; - return math.min(580, math.max(320.0, h * 0.78)); + static double newConnectionDialogHeight(BuildContext context) { + final mq = MediaQuery.sizeOf(context); + final inset = dialogVerticalInset(mq.height) * 2; + return scaledDialogExtent( + context, + screenExtent: mq.height, + insetTotal: inset, + baseMax: 580, + baseMin: 320, + viewportFactor: 0.78, + ); } static double newConnectionSidebarWidth(double dialogWidth) { @@ -53,12 +99,13 @@ abstract class WindowLayout { return 1; } - static double dbTypeCardHeight(int crossAxisCount) { - return switch (crossAxisCount) { - 4 => 144, - 2 => 138, - _ => 132, + static double dbTypeCardHeight(BuildContext context, int crossAxisCount) { + final base = switch (crossAxisCount) { + 4 => 144.0, + 2 => 138.0, + _ => 132.0, }; + return context.scaled(base); } /// Empty workspace hero content max width (stays within viewport minus padding). diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 8b77c30f..b3e18e19 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -51,7 +51,11 @@ class _DriverManagerDialogContent extends material.StatelessWidget { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 520, minWidth: 400), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 400, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 6bd23e8c..9cf93f7e 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -89,9 +89,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; - final mq = MediaQuery.sizeOf(context); - final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(mq.width); - final dialogH = WindowLayout.newConnectionDialogHeight(mq.height); + final dialogMaxW = WindowLayout.newConnectionDialogMaxWidth(context); + final dialogH = WindowLayout.newConnectionDialogHeight(context); final headerPadH = dialogMaxW < 420 ? 16.0 : 24.0; final stackFilters = dialogMaxW < 520; @@ -185,8 +184,8 @@ class _NewConnectionDialogContentState extends material.State<_NewConnectionDial final innerW = math.max(0.0, constraints.maxWidth - gridPad * 2); final crossAxisCount = WindowLayout.dbTypeGridCrossAxisCount(innerW); - final cardHeight = - WindowLayout.dbTypeCardHeight(crossAxisCount); + final cardHeight = + WindowLayout.dbTypeCardHeight(context, crossAxisCount); final cardWidth = crossAxisCount > 0 ? (innerW - spacing * (crossAxisCount - 1)) / crossAxisCount : innerW; diff --git a/lib/features/connections/new_folder_dialog.dart b/lib/features/connections/new_folder_dialog.dart index 15c95a5d..db316e78 100644 --- a/lib/features/connections/new_folder_dialog.dart +++ b/lib/features/connections/new_folder_dialog.dart @@ -37,7 +37,11 @@ class _NewFolderDialogContentState extends material.State<_NewFolderDialogConten final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 440, minWidth: 360), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 440, + minWidth: 360, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index c9e01a18..bf5a1d98 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -125,7 +125,8 @@ class _SqlQueryHistoryDialogContentState final scheme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 520, minWidth: 320, maxHeight: 440, diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart index dcf15582..620de32f 100644 --- a/lib/features/mongodb/mongo_database_dialog.dart +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -52,7 +52,7 @@ class _CreateMongoDBDialogContentState extends material.State<_CreateMongoDBDial final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 500), + constraints: WindowLayout.dialogConstraints(context, maxWidth: 500), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index f0526ece..0382846f 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -207,7 +207,11 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 700), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 700, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 8227115f..525215b9 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -186,7 +186,11 @@ class _MysqlConnectionFormContentState return material.Container( constraints: - const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 640, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart index 6fbdcda3..3f653e3d 100644 --- a/lib/features/mysql/mysql_sql_editor_dialog.dart +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -78,7 +78,8 @@ class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 720, minWidth: 480, maxHeight: 520, diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index 8c05490f..f0e5a84a 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -105,7 +105,8 @@ class _PostgresSqlEditorDialogState extends material.State<_PostgresSqlEditorDia backgroundColor: material.Colors.transparent, insetPadding: WindowLayout.dialogSymmetricInsets(context), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 720, minWidth: 480, maxHeight: 520, diff --git a/lib/features/postgresql/postgres_table_privileges_dialog.dart b/lib/features/postgresql/postgres_table_privileges_dialog.dart index b66dbc2a..c5234604 100644 --- a/lib/features/postgresql/postgres_table_privileges_dialog.dart +++ b/lib/features/postgresql/postgres_table_privileges_dialog.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -81,10 +79,23 @@ class _PrivilegesDialogBodyState extends material.State<_PrivilegesDialogBody> { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; final mq = material.MediaQuery.sizeOf(context); - final dialogHeight = - math.min(480.0, math.max(260.0, mq.height * 0.72)).toDouble(); - final dialogWidth = - math.min(560.0, math.max(300.0, mq.width - 48)).toDouble(); + final hInset = WindowLayout.dialogVerticalInset(mq.height) * 2; + final wInset = WindowLayout.dialogHorizontalInset(mq.width) * 2; + final dialogHeight = WindowLayout.scaledDialogExtent( + context, + screenExtent: mq.height, + insetTotal: hInset, + baseMax: 480, + baseMin: 260, + viewportFactor: 0.72, + ); + final dialogWidth = WindowLayout.scaledDialogExtent( + context, + screenExtent: mq.width, + insetTotal: wInset, + baseMax: 560, + baseMin: 300, + ); return material.Dialog( backgroundColor: material.Colors.transparent, diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 30ab6670..06119d65 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -186,7 +186,11 @@ class _PostgresConnectionFormContentState return material.Container( constraints: - const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 640, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 798b3a2c..e2dbb48e 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -137,7 +137,11 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo final radius = Theme.of(context).radiusXxl; return material.Container( - constraints: const material.BoxConstraints(maxWidth: 600, maxHeight: 560), + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 600, + maxHeight: 560, + ), decoration: material.BoxDecoration( color: theme.popover, borderRadius: material.BorderRadius.circular(radius), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 664b48bb..78fc0762 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -94,7 +94,8 @@ class _PreferencesDialogContentState child: material.IconTheme( data: material.IconThemeData(color: onPopover), child: material.Container( - constraints: const material.BoxConstraints( + constraints: WindowLayout.dialogConstraints( + context, maxWidth: 480, minWidth: 360, maxHeight: 640, diff --git a/test/core/layout/window_layout_scale_test.dart b/test/core/layout/window_layout_scale_test.dart new file mode 100644 index 00000000..f064d83e --- /dev/null +++ b/test/core/layout/window_layout_scale_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; + +void main() { + testWidgets('dialogConstraints scales with QueryaUiScaleScope', (tester) async { + await tester.pumpWidget( + QueryaUiScaleScope( + scale: 1.25, + child: MaterialApp( + home: Builder( + builder: (context) { + final c = WindowLayout.dialogConstraints( + context, + maxWidth: 480, + minWidth: 360, + maxHeight: 640, + ); + expect(c.maxWidth, 600); + expect(c.minWidth, 450); + expect(c.maxHeight, 800); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + }); + + testWidgets('scaledDialogExtent applies scale before viewport clamp', (tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => QueryaUiScaleScope( + scale: 1.25, + child: child ?? const SizedBox.shrink(), + ), + home: Builder( + builder: (context) { + final extent = WindowLayout.scaledDialogExtent( + context, + screenExtent: 2000, + insetTotal: 100, + baseMax: 480, + baseMin: 200, + ); + expect(extent, 600); + return const SizedBox.shrink(); + }, + ), + ), + ); + }); +} From 15951ade29eb547993f42c76021d37d021693924 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 12:55:56 +0300 Subject: [PATCH 08/32] docs: repo cleanup and professional README/docs (#91) Restructure documentation with a docs index, architecture guide, and expanded contributing guide; archive historical spikes; stop tracking generated .flutter-plugins-dependencies. --- .flutter-plugins-dependencies | 1 - CHANGELOG.md | 2 +- CONTRIBUTING.md | 88 +++++++++-- README.md | 141 +++++++++--------- docs/README.md | 38 +++++ docs/architecture.md | 71 +++++++++ docs/archive/AUDIT.md | 47 ++++++ docs/{ => archive}/code-forge-evaluation.md | 0 docs/{ => archive}/editor-spike-report.md | 0 .../mysql-implementation-plan.md | 0 docs/{ => archive}/research_theme.md | 0 docs/getting-started.md | 75 ++++++++++ docs/roadmap.md | 2 +- docs/theme.md | 10 +- 14 files changed, 381 insertions(+), 94 deletions(-) delete mode 100644 .flutter-plugins-dependencies create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/archive/AUDIT.md rename docs/{ => archive}/code-forge-evaluation.md (100%) rename docs/{ => archive}/editor-spike-report.md (100%) rename docs/{ => archive}/mysql-implementation-plan.md (100%) rename docs/{ => archive}/research_theme.md (100%) create mode 100644 docs/getting-started.md diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies deleted file mode 100644 index e58e7712..00000000 --- a/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_ios","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_ios-0.5.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"android":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_android-0.5.2+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage-9.2.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_macos-3.1.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_windows-3.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"irondash_engine_context","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/irondash_engine_context-0.5.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","native_build":true,"dependencies":["irondash_engine_context","device_info_plus"],"dev_dependency":false}],"web":[{"name":"device_info_plus","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/","dependencies":[],"dev_dependency":false},{"name":"file_selector_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/file_selector_web-0.9.4+2/","dependencies":[],"dev_dependency":false},{"name":"flutter_secure_storage_web","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/flutter_secure_storage_web-1.2.1/","dependencies":[],"dev_dependency":false},{"name":"super_native_extensions","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/super_native_extensions-0.9.1/","dependencies":["device_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector","dependencies":["file_selector_android","file_selector_ios","file_selector_linux","file_selector_macos","file_selector_web","file_selector_windows"]},{"name":"file_selector_android","dependencies":[]},{"name":"file_selector_ios","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_web","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_secure_storage","dependencies":["flutter_secure_storage_linux","flutter_secure_storage_macos","flutter_secure_storage_web","flutter_secure_storage_windows"]},{"name":"flutter_secure_storage_linux","dependencies":[]},{"name":"flutter_secure_storage_macos","dependencies":[]},{"name":"flutter_secure_storage_web","dependencies":[]},{"name":"flutter_secure_storage_windows","dependencies":["path_provider"]},{"name":"irondash_engine_context","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"super_native_extensions","dependencies":["irondash_engine_context","device_info_plus"]}],"date_created":"2026-05-28 10:58:45.738032","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 50972bd1..4ca273cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Theme system milestone (epic #37). Git tag **`0.4.0`** — use this release for - **Syntax highlighting** — SQL and JSON in `QueryaCodeEditor` via `syntax_highlight`; `tokenColors` mapped to TextMate scopes; isolate highlight for large buffers. - **Editor** — `QueryaCodeEditor` abstraction, `SqlEditorChrome` from theme tokens, `QueryaThemeScope` for workbench/editor tokens. - **Samples** — `themes/samples/cyberpunk-neon.json` (+ JSONC) for manual import testing. -- **Docs** — [docs/theme.md](docs/theme.md), [docs/theme-import.md](docs/theme-import.md), [docs/editor-spike-report.md](docs/editor-spike-report.md), [docs/code-forge-evaluation.md](docs/code-forge-evaluation.md). +- **Docs** — [docs/theme.md](docs/theme.md), [docs/theme-import.md](docs/theme-import.md), [docs/archive/editor-spike-report.md](docs/archive/editor-spike-report.md), [docs/archive/code-forge-evaluation.md](docs/archive/code-forge-evaluation.md). ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adc18b20..a9b216d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,32 +1,94 @@ # Contributing -## Flutter version +Thanks for your interest in improving Querya Desktop. This guide covers the +workflow, branch/commit conventions, and the checks we expect before a PR. -CI pins a **stable** Flutter version in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) and [`.github/workflows/release.yml`](.github/workflows/release.yml). Prefer matching that version locally to avoid “works on my machine” drift. When bumping the pin, run `flutter test` and a release smoke build before merging. +New to the codebase? Start with [docs/getting-started.md](docs/getting-started.md) +and [docs/architecture.md](docs/architecture.md). -## Linux: `flutter analyze` and “Too many open files” +## Workflow -On some Linux setups the Dart analysis server hits the process **open file limit** (`errno = 24`). Try: +We follow a simple GitFlow: feature work branches off `dev` and merges back into +`dev` via PR; `main` holds production-ready code and release tags. ```bash -ulimit -n 8192 -flutter analyze +# 1. Sync +git fetch --all --prune +git checkout dev +git pull --ff-only origin dev + +# 2. Branch (one issue → one branch → one PR) +git checkout -b issue/- # or feat/ + +# 3. ...make changes, then run the checks below... + +# 4. Open a PR into `dev` with "Closes #" in the body ``` -## Git tags and release commits +Keep each PR scoped to a single issue — avoid drive-by refactoring. -A **tag points at one commit**. Release artifacts are built from the tree at that commit. If you fix something **after** pushing a release tag, either: +## Commits -- move the tag to the new commit (only if the team agrees and the release is not yet consumed), or -- ship a **new** semver (update `pubspec.yaml` / `CHANGELOG.md`) and push a **new** tag. +We use [Conventional Commits](https://www.conventionalcommits.org/): +`feat`, `fix`, `perf`, `docs`, `test`, `ci`, `chore`, `refactor`, with a scope +where helpful (`postgresql`, `mysql`, `mongodb`, `redis`, `connections`, `theme`, +`editor`, `settings`, `ui`, `ci`, `deps`). -See [docs/tags-and-releases.md](docs/tags-and-releases.md). +``` +feat(theme): add QueryaWorkbenchTheme and editor tokens +fix(postgresql): pass right-clicked table into Open in SQL +docs: restructure README and docs index +``` -## Tests +**Never commit** secrets — `.env`, keys, `credentials.json`, or exported +connection secrets. + +## Checks before a PR + +Run the same checks CI runs: ```bash flutter pub get +flutter analyze flutter test ``` -Widget tests that use SQLite or `path_provider` follow patterns in `test/features/connections/connections_panel_layout_test.dart` and `test/flutter_test_config.dart` (in-memory secrets backend). +For release or large UI PRs, optionally smoke-test a build: + +```bash +flutter build linux --release # or windows / macos +``` + +### Linux: `flutter analyze` and "Too many open files" + +On some Linux setups the Dart analysis server hits the process **open file +limit** (`errno = 24`). Raise it for the session: + +```bash +ulimit -n 8192 +flutter analyze +``` + +## Flutter version + +CI pins a **stable** Flutter version in +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) and +[`.github/workflows/release.yml`](.github/workflows/release.yml). Match that +version locally to avoid "works on my machine" drift. When bumping the pin, run +`flutter test` and a release smoke build before merging. + +## Tests + +Widget tests that use SQLite or `path_provider` follow patterns in +`test/features/connections/connections_panel_layout_test.dart` and +`test/flutter_test_config.dart` (in-memory secrets backend). Mirror `lib/` layout +under `test/` where possible. + +## Releases and tags + +A **tag points at one commit**, and release artifacts are built from that tree. +If you fix something **after** pushing a release tag, either move the tag (only if +the team agrees and the release is not yet consumed) or ship a **new** semver +(update `pubspec.yaml` / `CHANGELOG.md`) and push a new tag. See +[docs/tags-and-releases.md](docs/tags-and-releases.md) and +[docs/release-checklist.md](docs/release-checklist.md). diff --git a/README.md b/README.md index e8d34c75..e7f4e94b 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,98 @@ -# Querya Desktop +
-A lightweight desktop client for SQL and NoSQL databases. Connect to PostgreSQL, MySQL, Redis, and MongoDB from a single app with a clean, dark UI inspired by tools like pgAdmin. +# Querya Desktop -## What it is +**A lightweight, cross-platform desktop client for SQL and NoSQL databases.** -- **Cross-platform:** Windows, Linux, macOS (Flutter desktop). -- **Multi-database:** PostgreSQL, MySQL, Redis, MongoDB (more can be added). -- **UI:** Custom window (no system title bar), resizable left panel (connection tree) and bottom split (query editor / results), dark theme, [shadcn_flutter](https://pub.dev/packages/shadcn_flutter) components. -- **Flow:** Right-click “Servers” → “New connection” (or **Connection → New Database Connection**) → pick database type → configure and save. Metadata is stored in local SQLite; **passwords and connection strings** use the OS secure store (see [docs/security.md](docs/security.md)). +Connect to PostgreSQL, MySQL/MariaDB, Redis, and MongoDB from a single app with +a clean, dark UI inspired by tools like pgAdmin. -## Database drivers +[![CI](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/ci.yml/badge.svg)](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/ci.yml) +[![Release](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/release.yml/badge.svg)](https://github.com/QueryaHub/Querya-Desktop/actions/workflows/release.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Flutter](https://img.shields.io/badge/Flutter-Desktop-02569B?logo=flutter)](https://flutter.dev) +[![Platforms](https://img.shields.io/badge/Platforms-Linux%20%7C%20Windows%20%7C%20macOS-555)](#) -- **PostgreSQL** — `postgres` (Dart); browser, SQL workspace, table/view browsing, server stats. -- **MySQL / MariaDB** — `mysql_client` (Dart); browser (databases, tables, views), SQL workspace with configurable statement timeout, paginated table/view data (read-oriented browse SQL). -- **Redis** / **MongoDB** — see connection panels and workspace. +
-## Tech stack +--- -- **Flutter** (Dart) with desktop support -- **shadcn_flutter** for UI (buttons, inputs, theme) -- **bitsdojo_window** for custom frame and window sizing +## Highlights -## Prerequisites +- **Multi-database** — PostgreSQL, MySQL/MariaDB, Redis, and MongoDB, with + built-in Dart drivers (no JDBC JARs to download). +- **Cross-platform** — Linux, Windows, and macOS from one Flutter codebase. +- **SQL workspace** — query editor with syntax highlighting, configurable + statement timeouts, query history, and CSV/JSON export. +- **Object browsing** — connection tree with databases, tables, views, and + server stats. +- **Themeable** — runtime dark/light/system modes and **VS Code theme import**. +- **Scalable UI** — global interface scaling for high-DPI and accessibility. +- **Secure by default** — passwords and connection strings live in the OS secure + store, never in plaintext. -- [Flutter SDK](https://docs.flutter.dev/get-started/install) (stable) with desktop enabled: - ```bash - flutter config --enable-linux-desktop # or windows, macos - ``` -- **Linux builds** also need desktop headers used by Flutter and plugins (GTK, etc.). For `flutter_secure_storage` on Linux you typically need **`libsecret-1-dev`** (Debian/Ubuntu: `sudo apt install libsecret-1-dev`; Fedora: `libsecret-devel`). Match this to your distro’s Flutter desktop docs. +## Screenshots -## Setup +> _Add screenshots/GIFs to `docs/assets/` and reference them here._ -From the project root: +## Quick start ```bash -flutter pub get -``` - -If platform folders are missing: +# 1. Enable Flutter desktop for your platform +flutter config --enable-linux-desktop # or --enable-windows-desktop / --enable-macos-desktop -```bash -flutter create . --project-name querya_desktop --platforms=linux,windows,macos +# 2. Fetch dependencies flutter pub get + +# 3. Run +flutter run -d linux # or windows / macos ``` -## Run +Linux builds also need keyring headers (`libsecret-1-dev` on Debian/Ubuntu). +See **[Getting started](docs/getting-started.md)** for the full setup, including +prerequisites, release builds, and your first connection. -```bash -# Linux (on Wayland, use X11 to avoid Gdk warnings when the pointer leaves the window) -GDK_BACKEND=x11 flutter run -d linux +## Tech stack -# Or -flutter run -d linux +- **[Flutter](https://flutter.dev)** (Dart) with desktop support. +- **[shadcn_flutter](https://pub.dev/packages/shadcn_flutter)** for UI components and theming. +- **[bitsdojo_window](https://pub.dev/packages/bitsdojo_window)** for the custom window frame. +- Built-in Dart drivers: `postgres`, `mysql_client`, `redis`, `mongo_dart`. -# Windows -flutter run -d windows +## Project structure -# macOS -flutter run -d macos -``` +| Path | Description | +|------|-------------| +| `lib/main.dart` | App entry, window setup. | +| `lib/app/` | App shell and theme wiring. | +| `lib/core/` | Database clients, storage, theme, layout, editor helpers. | +| `lib/features/` | Feature modules (main screen, connections, per-engine UI, settings). | +| `lib/shared/` | Reusable widgets shared across features. | +| `assets/` | Database type icons and other bundled assets. | +| `linux/`, `windows/`, `macos/` | Native runners. | +| `third_party/` | Vendored components (e.g. `shadcn_flutter`), under their own licenses. | -Or use the helper script on Linux: +For a deeper map of modules and their responsibilities, see +**[Architecture](docs/architecture.md)**. -```bash -./run_linux.sh -``` +## Documentation -## Build release +Full documentation lives in **[`docs/`](docs/README.md)**: -```bash -flutter build linux -flutter build windows -flutter build macos -``` +- **[Getting started](docs/getting-started.md)** — install and first run. +- **[User guide](docs/user-guide.md)** — connections, preferences, drivers. +- **[Architecture](docs/architecture.md)** — codebase layout. +- **[Security](docs/security.md)** — local data and secrets. +- **[Theme system](docs/theme.md)** · **[Theme import](docs/theme-import.md)**. +- **[Roadmap](docs/roadmap.md)** · **[Releases](docs/tags-and-releases.md)**. -## Project structure +## Contributing -| Path | Description | -|------|-------------| -| `lib/main.dart` | App entry, window setup (bitsdojo_window) | -| `lib/app/` | App shell and theme | -| `lib/features/main_screen/` | Main layout, workspace panel, query/results tabs | -| `lib/features/connections/` | Connection tree, new connection / folder flows, driver manager | -| `lib/features/postgresql/`, `mysql/`, `redis/`, `mongodb/` | Per-engine workspace and browser UI | -| `lib/shared/widgets/` | Shared UI (shadcn re-exports, app dialog) | -| `lib/core/` | Database clients, local storage, theme, editor helpers | -| `assets/images/` | Database type icons (PostgreSQL, MySQL, Redis, MongoDB) | -| `linux/`, `windows/`, `macos/` | Native runners (custom frame on Linux/Windows) | +Contributions are welcome. Please read **[CONTRIBUTING.md](CONTRIBUTING.md)** for +the workflow, CI expectations, and the pre-PR checklist, and see the +[Changelog](CHANGELOG.md) for release history. ## License -[MIT](LICENSE). Third-party components (e.g. vendored UI under `third_party/`) retain their own licenses. - -## More documentation - -- [Security / local data](docs/security.md) -- [Theme system](docs/theme.md) -- [User guide](docs/user-guide.md) -- [Releases](docs/tags-and-releases.md) -- [Release checklist](docs/release-checklist.md) -- [Contributing](CONTRIBUTING.md) (Flutter pin, tags, local analyze) -- [Roadmap](docs/roadmap.md) +[MIT](LICENSE). Third-party components (e.g. vendored UI under `third_party/`) +retain their own licenses. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..2d38f3ea --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# Documentation + +Index of Querya Desktop documentation, grouped by audience. + +## For users + +- [Getting started](getting-started.md) — prerequisites, install, first run. +- [User guide](user-guide.md) — connections, preferences, driver manager. +- [Security / local data](security.md) — where metadata and secrets are stored. + +## For contributors + +- [Contributing](../CONTRIBUTING.md) — workflow, CI, pre-PR checklist. +- [Architecture](architecture.md) — `lib/` layout and module responsibilities. +- [Theme system](theme.md) — runtime theming and VS Code theme tokens. +- [Theme import](theme-import.md) — supported `colors` keys and merge behavior. +- [Performance baseline](perf-baseline.md) — per-milestone DevTools checklist. + +## For release managers + +- [Tags and releases](tags-and-releases.md) — tag/release policy. +- [Release checklist](release-checklist.md) — step-by-step release flow. +- [macOS signing](macos-signing.md) — signing and notarization track. + +## Planning + +- [Roadmap](roadmap.md) — current direction and follow-ups. + +## Archive + +Historical design notes and one-off spikes, kept for context but no longer +maintained — see [`archive/`](archive/): + +- [Repository audit (#91)](archive/AUDIT.md) +- [Editor package spike (#48)](archive/editor-spike-report.md) +- [`code_forge` + LSP evaluation (#52)](archive/code-forge-evaluation.md) +- [Theme research (RU)](archive/research_theme.md) +- [MySQL implementation plan](archive/mysql-implementation-plan.md) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..5817736a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,71 @@ +# Architecture + +A high-level map of the codebase for contributors. The app is a Flutter desktop +client with a custom window frame ([bitsdojo_window](https://pub.dev/packages/bitsdojo_window)) +and [shadcn_flutter](https://pub.dev/packages/shadcn_flutter) UI components. + +## Layered layout + +``` +lib/ +├── main.dart App entry: window setup, controller bootstrap +├── app/ App shell, theme wiring, global scopes +├── core/ Cross-cutting infrastructure (no feature UI) +├── features/ Feature modules, one folder per area / engine +└── shared/ Reusable widgets shared across features +``` + +The dependency direction is **features → core/shared**, never the reverse. +`core/` and `shared/` must not import from `features/`. + +## `core/` + +| Path | Responsibility | +|------|----------------| +| `core/database/` | Engine clients and query execution (PostgreSQL, MySQL, Redis, MongoDB). | +| `core/storage/` | Local SQLite metadata (`local_db.dart`), app settings, secure secrets store. | +| `core/theme/` | Theme tokens, VS Code theme import, color resolution. | +| `core/editor/` | Code editor helpers and syntax highlighting glue. | +| `core/layout/` | Window/dialog sizing and the global UI-scale system. | +| `core/csv/`, `core/json/` | Result export helpers (CSV / JSON). | + +## `features/` + +| Path | Responsibility | +|------|----------------| +| `features/main_screen/` | Main layout, workspace panel, query/results tabs, history. | +| `features/connections/` | Connection tree, new connection / folder flows, driver manager. | +| `features/postgresql/` | PostgreSQL workspace, object browser, SQL editor dialog. | +| `features/mysql/` | MySQL / MariaDB workspace, browser, SQL editor dialog. | +| `features/redis/` | Redis connection form and workspace. | +| `features/mongodb/` | MongoDB connection form, database dialog, stats view. | +| `features/settings/` | Preferences dialog, controls, interface scale slider. | + +## `shared/` + +| Path | Responsibility | +|------|----------------| +| `shared/widgets/` | shadcn re-exports, `QueryaDropdown`, app dialog, shared tokens. | + +## Cross-cutting systems + +- **Theme system** — runtime dark/light/system modes plus VS Code theme import. + See [theme.md](theme.md) and [theme-import.md](theme-import.md). +- **UI scaling** — a global scale factor propagated via an inherited scope and + applied to text (`TextScaler`) and dialog dimensions. Lives in `core/layout/`. +- **Secrets** — passwords and connection strings go to the OS secure store, not + SQLite. See [security.md](security.md). + +## Data and secrets + +- **Connection metadata + preferences**: local SQLite (`querya.db` in the app + support directory). +- **Passwords / connection strings**: OS secure store via `flutter_secure_storage` + (Keychain / Credential Manager / libsecret). + +## Tests + +Widget tests that touch SQLite or `path_provider` follow the patterns in +`test/features/connections/connections_panel_layout_test.dart` and +`test/flutter_test_config.dart` (in-memory secrets backend, no desktop keyring +required). Run them with `flutter test`. diff --git a/docs/archive/AUDIT.md b/docs/archive/AUDIT.md new file mode 100644 index 00000000..a65176e0 --- /dev/null +++ b/docs/archive/AUDIT.md @@ -0,0 +1,47 @@ +# Repository audit (#91) + +Inventory of docs and tracked artifacts with a disposition decision. Goal: keep +the repository professional and easy to navigate without losing technically +valuable history. + +Legend: **keep** = active, maintained · **archive** = historical, moved to +`docs/archive/` · **untrack** = remove from git, keep locally · **delete** = removed. + +## Documentation + +| Path | Disposition | Rationale | +|------|-------------|-----------| +| `README.md` | keep (rewritten) | Project front page — restructured with badges, features, quick start, docs index. | +| `CONTRIBUTING.md` | keep (expanded) | GitFlow, CI expectations, pre-PR checklist. | +| `CHANGELOG.md` | keep | Release history (Keep a Changelog). Links updated for moved files. | +| `docs/README.md` | new | Documentation index (User / Developer / Release / Theme / Archive). | +| `docs/getting-started.md` | new | Install + first run, extracted from README. | +| `docs/architecture.md` | new | `lib/` layout and module responsibilities for contributors. | +| `docs/user-guide.md` | keep | End-user guide. Kept current with the shipped UI. | +| `docs/security.md` | keep | Local-data and secrets model. | +| `docs/theme.md` | keep | Theme system reference (epic #37). | +| `docs/theme-import.md` | keep | VS Code theme import details. | +| `docs/roadmap.md` | keep (synced) | Living roadmap; synced with closed issues. | +| `docs/perf-baseline.md` | keep | Reusable per-milestone DevTools checklist. | +| `docs/tags-and-releases.md` | keep | Release/tag policy. | +| `docs/release-checklist.md` | keep | Release steps. | +| `docs/macos-signing.md` | keep | Signing/notarize track. | +| `docs/editor-spike-report.md` | archive | One-off editor package spike (#48); decision shipped in 0.4.0. | +| `docs/code-forge-evaluation.md` | archive | `code_forge`/LSP go/no-go spike (#52); NO-GO recorded. | +| `docs/research_theme.md` | archive | Background research (RU) behind the theme epic. | +| `docs/mysql-implementation-plan.md` | archive | Historical plan; MySQL is implemented (`lib/features/mysql/`). | + +## Tracked artifacts + +| Path | Disposition | Rationale | +|------|-------------|-----------| +| `.flutter-plugins-dependencies` | untrack | Generated per-machine; already in `.gitignore`. Caused recurring "local changes". | +| `.metadata` | keep | Standard Flutter project metadata. | +| `third_party/` | keep (untouched) | Vendored `shadcn_flutter`; retains its own license/docs. | +| `test/`, `.github/workflows/` | keep (untouched) | Behavior and CI unchanged by this cleanup. | + +## Not in scope + +- Rewriting `third_party/shadcn_flutter` documentation. +- Full RU+EN localization of docs (separate issue if needed). +- Marketing site / landing outside the repository. diff --git a/docs/code-forge-evaluation.md b/docs/archive/code-forge-evaluation.md similarity index 100% rename from docs/code-forge-evaluation.md rename to docs/archive/code-forge-evaluation.md diff --git a/docs/editor-spike-report.md b/docs/archive/editor-spike-report.md similarity index 100% rename from docs/editor-spike-report.md rename to docs/archive/editor-spike-report.md diff --git a/docs/mysql-implementation-plan.md b/docs/archive/mysql-implementation-plan.md similarity index 100% rename from docs/mysql-implementation-plan.md rename to docs/archive/mysql-implementation-plan.md diff --git a/docs/research_theme.md b/docs/archive/research_theme.md similarity index 100% rename from docs/research_theme.md rename to docs/archive/research_theme.md diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..832767f4 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,75 @@ +# Getting started + +How to build and run Querya Desktop from source. + +## Prerequisites + +- [Flutter SDK](https://docs.flutter.dev/get-started/install) (stable channel) + with desktop enabled: + + ```bash + flutter config --enable-linux-desktop # or --enable-windows-desktop / --enable-macos-desktop + ``` + +- **Linux** also needs the desktop headers used by Flutter and its plugins + (GTK, etc.). For `flutter_secure_storage` you typically need + **`libsecret-1-dev`**: + + ```bash + sudo apt install libsecret-1-dev # Debian / Ubuntu + sudo dnf install libsecret-devel # Fedora + ``` + + CI pins a specific stable Flutter version; matching it locally avoids + "works on my machine" drift (see [Contributing](../CONTRIBUTING.md)). + +## Install + +From the project root: + +```bash +flutter pub get +``` + +If the platform folders are missing: + +```bash +flutter create . --project-name querya_desktop --platforms=linux,windows,macos +flutter pub get +``` + +## Run + +```bash +# Linux +flutter run -d linux +# On Wayland, force X11 to avoid Gdk pointer warnings: +GDK_BACKEND=x11 flutter run -d linux +# Or use the helper script: +./run_linux.sh + +# Windows +flutter run -d windows + +# macOS +flutter run -d macos +``` + +## Build release + +```bash +flutter build linux +flutter build windows +flutter build macos +``` + +## First connection + +1. Launch the app. +2. **Connection → New Database Connection** (or right-click **Servers** in the tree). +3. Pick **PostgreSQL**, **MySQL**, **Redis**, or **MongoDB** and fill in host, + port, and credentials. +4. Saved connections appear in the left tree. + +See the [User guide](user-guide.md) for preferences, the driver manager, and +day-to-day usage, and [Security](security.md) for how credentials are stored. diff --git a/docs/roadmap.md b/docs/roadmap.md index 30a36187..491c5d92 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,7 +8,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per - [code-forge-evaluation.md](code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). + [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). ## Query history and favorites diff --git a/docs/theme.md b/docs/theme.md index f310fbf5..4fbb4550 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -13,7 +13,7 @@ import (#43–45), `tokenColors` → syntax highlight (#46–47, #49–50), P0 w animation (#57), editor package spikes (#48, #52). **Follow-up (not blocking):** P2 surfaces (Mongo/Redis explorer semantic hues), `re_editor` -if large-buffer benchmarks fail — see [code-forge-evaluation.md](code-forge-evaluation.md). +if large-buffer benchmarks fail — see [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md). ## Architecture @@ -180,11 +180,11 @@ Manual QA (with animation enabled): | SQL/JSON syntax highlighting | Done | | `tokenColors` → highlighter | Done | | Theme transition animation | Preferences → **Animate theme changes** (default off) | -| `code_forge` / LSP editor | **NO-GO** for 0.3 — [code-forge-evaluation.md](code-forge-evaluation.md) | +| `code_forge` / LSP editor | **NO-GO** for 0.3 — [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) | ## Related docs - [theme-import.md](theme-import.md) — supported `colors` keys and merge behavior -- [research_theme.md](research_theme.md) — background research (RU) -- [editor-spike-report.md](editor-spike-report.md) — code editor package evaluation (#48) -- [code-forge-evaluation.md](code-forge-evaluation.md) — `code_forge` + LSP go/no-go (#52) +- [archive/research_theme.md](archive/research_theme.md) — background research (RU) +- [archive/editor-spike-report.md](archive/editor-spike-report.md) — code editor package evaluation (#48) +- [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) — `code_forge` + LSP go/no-go (#52) From 3722907623297a6ae92f048f7b4bd348eaad363f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 13:03:09 +0300 Subject: [PATCH 09/32] chore: ignore local .cursor/ directory in git Stop tracking Cursor rules; keep them local-only per developer setup. --- .cursor/rules/gitflow.md | 167 --------------------------------------- .gitignore | 6 +- 2 files changed, 2 insertions(+), 171 deletions(-) delete mode 100644 .cursor/rules/gitflow.md diff --git a/.cursor/rules/gitflow.md b/.cursor/rules/gitflow.md deleted file mode 100644 index 28f9a773..00000000 --- a/.cursor/rules/gitflow.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -description: Querya Desktop GitFlow, branching, commits, and PR workflow -alwaysApply: true ---- - -# Querya Desktop — agent workflow - -Desktop SQL/NoSQL client (PostgreSQL, MySQL, Redis, MongoDB). -Стек: **Flutter** (Dart 3.5+), **shadcn_flutter** (vendored в `third_party/`), локальный SQLite + OS secure storage для секретов. - -Remote: `git@github.com:QueryaHub/Querya-Desktop.git` - -## Sync with remote (always) - -Before checkout, branch, push, or PR — and **after** merge to `dev`: - -```bash -git fetch --all --prune -git checkout dev -git pull --ff-only origin dev -``` - -Before push on a feature branch: `git fetch origin`, then rebase or merge remote. - -**Full checks** before PR (локально, как в CI): - -```bash -flutter pub get -flutter analyze -flutter test -``` - -Опционально перед релизом / крупным UI-PR: - -```bash -flutter build linux --release # или windows / macos -``` - -На Linux при `errno = 24` на analyze: `ulimit -n 8192` (см. [CONTRIBUTING.md](CONTRIBUTING.md)). - -## Git branches - -- **`main`**: production-ready; релизные теги (`X.Y.Z`) ставятся на коммиты, готовые к бинарникам. -- **`dev`**: интеграция; **все feature PR → `dev`**. -- **Feature**: `issue/-` или `feat/` от актуального `dev`. - -```bash -git fetch --all --prune && git checkout dev && git pull --ff-only origin dev -git checkout -b issue/38-querya-workbench-theme-models -# или: git checkout -b feat/postgres-sql-workspace-toolbar -``` - -Долгоживущие ветки по подсистемам (если согласовано с командой): `postgres-ench`, `mongo-ench`, `my_sql`, `design` — не создавать без необходимости; предпочитать короткие `issue/*` / `feat/*`. - -**Hotfix** только по явному запросу: `hotfix/` от `main` → PR в `main`, затем back-merge в `dev`. - -**Release**: версия в `pubspec.yaml`, тег на нужном коммите, workflow Release — см. [docs/tags-and-releases.md](docs/tags-and-releases.md), [docs/release-checklist.md](docs/release-checklist.md). - -## Issue priority & roadmap - -Живой roadmap: [docs/roadmap.md](docs/roadmap.md). Крупные темы (пример — epic **#37** theming): дочерние issues #38–#60. - -| Область | Фокус | -|---------|--------| -| Connections | Tree, new connection, drivers, secure storage | -| PostgreSQL / MySQL | Browser, SQL workspace, grids, timeouts | -| Redis / MongoDB | Explorer, keys/collections, document editor | -| Theme / UI | `lib/core/theme/`, shadcn tokens, SQL editor (#37 epic) | -| CI / release | `.github/workflows/`, Linux deps, signing (macOS) | - -Один issue → одна ветка → один PR. Scope = issue only — без drive-by рефакторинга. - -## GitHub labels & milestones - -### Milestone: **Theme system** - -Epic [#37](https://github.com/QueryaHub/Querya-Desktop/issues/37) и дочерние issues **#38–#60** (кроме закрытого #56) — milestone [Theme system](https://github.com/QueryaHub/Querya-Desktop/milestone/1). - -Новые theme-issues: label `theme` → workflow [issue-theme-milestone.yml](.github/workflows/issue-theme-milestone.yml) проставит milestone автоматически. Шаблон: [.github/ISSUE_TEMPLATE/theme_task.yml](.github/ISSUE_TEMPLATE/theme_task.yml). - -### PR: labels + milestone (автоматика) - -1. Ветка **`issue/-`** (рекомендуется), например `issue/38-workbench-theme-models`. -2. В PR body: **`Closes #38`** (или Fixes/Resolves). -3. В title опционально: `feat(theme): … (#38)`. - -Workflow [pr-linked-issue-metadata.yml](.github/workflows/pr-linked-issue-metadata.yml) копирует **все labels** и **milestone** с linked issue на PR при open/edit/sync. - -Шаблон PR: [.github/pull_request_template.md](.github/pull_request_template.md). - -### Ручное создание PR (если автоматика не сработала) - -```bash -gh pr create --base dev \ - --milestone "Theme system" \ - --label "theme,enhancement" \ - --title "feat(theme): QueryaWorkbenchTheme models (#38)" \ - --body "$(cat <<'EOF' -## Summary -… - -Closes #38 -EOF -)" -``` - -Для editor-задач добавь label `editor`: `--label "theme,editor,enhancement"`. - -### Issues - -```bash -gh issue edit 38 --milestone "Theme system" --add-label "theme,enhancement" -``` - -Labels: `bug`, `enhancement`, `documentation`, `theme`, `editor`, `epic`. - -После merge: issue `CLOSED`; `git fetch` + `git pull --ff-only` на `dev`. - -## Commits and PRs - -- [Conventional Commits](https://www.conventionalcommits.org/): `feat`, `fix`, `perf`, `docs`, `test`, `ci`, `chore`, `refactor` + scope. -- Scopes (примеры): `postgresql`, `mysql`, `mongodb`, `redis`, `connections`, `theme`, `editor`, `settings`, `ci`, `deps`, `ui`. -- Атомарные коммиты; не коммитить без явной просьбы пользователя. -- PR body: `Closes #N` когда применимо. -- **Не коммитить:** `.env`, ключи, `credentials.json`, экспортированные connection secrets. - -Примеры: - -``` -feat(theme): add QueryaWorkbenchTheme and editor tokens -fix(postgresql): pass right-clicked table into Open in SQL -feat(mysql): SQL workspace statement timeout from settings -test(connections): panel layout with in-memory secrets -chore(release): bump version to 0.2.2+4 -``` - -## Layout & checks - -| Area | Path | -|------|------| -| Entry | `lib/main.dart`, `lib/app/app.dart` | -| Theme | `lib/core/theme/` | -| DB / pools | `lib/core/database/` | -| Storage / settings | `lib/core/storage/` | -| Features | `lib/features//` | -| Shared UI | `lib/shared/widgets/` | -| Vendored UI | `third_party/shadcn_flutter/` (override в `pubspec.yaml`) | -| Tests | `test/` (mirror `lib/` where possible) | -| Docs | `docs/` | -| CI / release | `.github/workflows/` | - -Зависимости: `pubspec.yaml`; lockfile **не** в git (см. `.gitignore`). - -## Flutter conventions - -- UI: **shadcn** `Theme.of(context).colorScheme`, не смешивать с Material без нужды (`material.` prefix где уже есть). -- Патчи shadcn — только в `third_party/shadcn_flutter`, с комментарием в `pubspec.yaml` `dependency_overrides`. -- Секреты: `flutter_secure_storage` / `connection_secrets_store` — без паролей в SQLite и логах ([docs/security.md](docs/security.md)). -- SQL editor: пока `TextField` / `QueryEditorTab`; подсветка — по issues #47–#50, не раздувать `TextField` ad hoc. -- Новый код: `flutter analyze` clean, тесты для нетривиальной логики (парсеры, storage, SQL helpers). - -## Out of scope (unless issue says otherwise) - -- Backend-сервисы, облачный sync аккаунтов. -- JDBC-драйверы (только встроенные Dart/native пути). -- Полная VS Code theme compatibility в одном PR (см. epic #37, поэтапно). -- Force-push на `main` / `dev` без явного запроса. diff --git a/.gitignore b/.gitignore index 22dd42e8..1960cc1d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,11 +15,9 @@ linux/flutter/generated_* windows/flutter/generated_* macos/Flutter/ephemeral/ -# IDE — track Cursor rules, ignore the rest +# IDE .idea/ -.cursor/* -!.cursor/rules/ -!.cursor/rules/** +.cursor/ # OS .DS_Store From 928a2b4a014ef13bc920c4470d805b51c9f8f472 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 20:10:38 +0300 Subject: [PATCH 10/32] perf(ui): decouple scale preview from app-wide rebuilds (#93) Keep interface scale drag local to the preferences slider; commit once on release. Split theme and scale ListenableBuilders, cache ThemeData, and stop bumping AppSettingsRevision on ui_scale writes. --- lib/app/app.dart | 62 +++++++++-------- lib/core/layout/ui_scale_controller.dart | 14 ++-- lib/core/storage/app_settings.dart | 1 - lib/core/theme/theme_controller.dart | 66 ++++++++++++++---- .../preferences_appearance_section.dart | 8 +-- .../settings/preferences_controls.dart | 68 ++++++++++++++----- .../settings/interface_scale_slider_test.dart | 12 ++-- 7 files changed, 147 insertions(+), 84 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index b14e41c4..75009613 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,5 @@ import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; -import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -14,43 +13,48 @@ class QueryaApp extends StatelessWidget { @override Widget build(BuildContext context) { final themeController = ThemeController.instance; - final uiScaleController = UiScaleController.instance; return ListenableBuilder( - listenable: Listenable.merge([themeController, uiScaleController]), + listenable: themeController, builder: (context, _) { final queryaTheme = themeController.activeTheme; final colorScheme = queryaTheme.colorScheme; - final scale = uiScaleController.scale; - return ShadcnApp( - title: 'Querya', - theme: themeController.lightShadcnTheme, - darkTheme: themeController.darkShadcnTheme, - themeMode: themeController.themeMode, - materialTheme: materialThemeFromQuerya(colorScheme), - debugShowCheckedModeBanner: false, - enableThemeAnimation: themeController.themeAnimationEnabled, - enableScrollInterception: false, - // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. - builder: (context, child) { - final mq = MediaQuery.maybeOf(context); - return QueryaUiScaleScope( - scale: scale, - child: MediaQuery( - data: (mq ?? const MediaQueryData()).copyWith( - textScaler: TextScaler.linear(scale), - ), - child: QueryaThemeScope( - data: queryaTheme, - child: child ?? const SizedBox.shrink(), - ), + + return ListenableBuilder( + listenable: uiScaleController, + builder: (context, _) { + final scale = uiScaleController.scale; + return ShadcnApp( + title: 'Querya', + theme: themeController.lightShadcnTheme, + darkTheme: themeController.darkShadcnTheme, + themeMode: themeController.themeMode, + materialTheme: themeController.materialThemeFor(colorScheme), + debugShowCheckedModeBanner: false, + enableThemeAnimation: themeController.themeAnimationEnabled, + enableScrollInterception: false, + // Above navigator so dialogs/overlays (SQL editor, Preferences) see tokens. + builder: (context, child) { + final mq = MediaQuery.maybeOf(context); + return QueryaUiScaleScope( + scale: scale, + child: MediaQuery( + data: (mq ?? const MediaQueryData()).copyWith( + textScaler: TextScaler.linear(scale), + ), + child: QueryaThemeScope( + data: queryaTheme, + child: child ?? const SizedBox.shrink(), + ), + ), + ); + }, + home: const AppLifecycleCleanup( + child: MainScreen(), ), ); }, - home: const AppLifecycleCleanup( - child: MainScreen(), - ), ); }, ); diff --git a/lib/core/layout/ui_scale_controller.dart b/lib/core/layout/ui_scale_controller.dart index ce318e9b..39142046 100644 --- a/lib/core/layout/ui_scale_controller.dart +++ b/lib/core/layout/ui_scale_controller.dart @@ -14,14 +14,6 @@ class UiScaleController extends ChangeNotifier { notifyListeners(); } - /// Live preview while dragging the scale slider (not persisted). - void setScalePreview(double value, {bool fine = false}) { - final next = _normalize(value, fine: fine); - if (next == _scale) return; - _scale = next; - notifyListeners(); - } - /// Persist scale to SQLite (called on slider release). Future commitScale(double value, {bool fine = false}) async { await AppSettings.instance.setUiScale(value, fine: fine); @@ -32,11 +24,13 @@ class UiScaleController extends ChangeNotifier { Future setScale(double value, {bool fine = false}) => commitScale(value, fine: fine); - double _normalize(double value, {required bool fine}) { + /// Normalizes a raw scale value (preset snap or 1% fine steps). + static double normalize(double value, {bool fine = false}) { final clamped = value.clamp(kMinUiScale, kMaxUiScale); if (fine) { final steps = ((clamped - kMinUiScale) / kUiScaleStep).round(); - return (kMinUiScale + steps * kUiScaleStep).clamp(kMinUiScale, kMaxUiScale); + return (kMinUiScale + steps * kUiScaleStep) + .clamp(kMinUiScale, kMaxUiScale); } return snapUiScaleToPreset(clamped); } diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index ad8036c2..51c767a0 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -225,7 +225,6 @@ class AppSettings { AppSettingsKeys.uiScale, normalized.toStringAsFixed(2), ); - AppSettingsRevision.bump(); } /// Max SQL history rows kept per connection + database (oldest trimmed). diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index ac562091..8abdb638 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,4 +1,6 @@ +import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/theme/querya_material_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'parser/apply_token_colors_to_editor.dart'; @@ -25,6 +27,14 @@ class ThemeController extends ChangeNotifier { bool _loaded = false; bool _themeAnimationEnabled = false; + QueryaTheme? _cachedLightTheme; + QueryaTheme? _cachedDarkTheme; + QueryaTheme? _cachedActiveTheme; + ThemeData? _cachedLightShadcnTheme; + ThemeData? _cachedDarkShadcnTheme; + material.ThemeData? _cachedMaterialTheme; + ColorScheme? _cachedMaterialThemeScheme; + ThemeMode get themeMode => _themeMode; /// When true, [QueryaApp] enables ShadcnAnimatedTheme transitions. @@ -70,13 +80,41 @@ class ThemeController extends ChangeNotifier { } /// Workbench + editor tokens for the current preset/mode and overrides. - QueryaTheme get activeTheme => _themeForBrightness(_effectiveBrightness()); + QueryaTheme get activeTheme => + _cachedActiveTheme ??= _themeForBrightness(_effectiveBrightness()); + + ThemeData get lightShadcnTheme => _cachedLightShadcnTheme ??= + (_cachedLightTheme ??= _themeForBrightness(Brightness.light)) + .toShadcnThemeData(); + + ThemeData get darkShadcnTheme => _cachedDarkShadcnTheme ??= + (_cachedDarkTheme ??= _themeForBrightness(Brightness.dark)) + .toShadcnThemeData(); + + /// Cached Material theme for dialogs/dropdowns (avoids rebuild churn). + material.ThemeData materialThemeFor(ColorScheme scheme) { + if (_cachedMaterialTheme != null && + _cachedMaterialThemeScheme == scheme) { + return _cachedMaterialTheme!; + } + _cachedMaterialThemeScheme = scheme; + return _cachedMaterialTheme = materialThemeFromQuerya(scheme); + } - ThemeData get lightShadcnTheme => - _themeForBrightness(Brightness.light).toShadcnThemeData(); + void _invalidateThemeCache() { + _cachedLightTheme = null; + _cachedDarkTheme = null; + _cachedActiveTheme = null; + _cachedLightShadcnTheme = null; + _cachedDarkShadcnTheme = null; + _cachedMaterialTheme = null; + _cachedMaterialThemeScheme = null; + } - ThemeData get darkShadcnTheme => - _themeForBrightness(Brightness.dark).toShadcnThemeData(); + void _notifyThemeChanged() { + _invalidateThemeCache(); + notifyListeners(); + } Future load() async { final mode = await AppSettings.instance.getThemeMode(); @@ -106,13 +144,13 @@ class ThemeController extends ChangeNotifier { _themeAnimationEnabled = await AppSettings.instance.getThemeAnimationEnabled(); _loaded = true; - notifyListeners(); + _notifyThemeChanged(); } Future setThemeAnimationEnabled(bool enabled) async { _themeAnimationEnabled = enabled; await AppSettings.instance.setThemeAnimationEnabled(enabled); - notifyListeners(); + _notifyThemeChanged(); } Future setThemeMode(ThemeMode mode) async { @@ -124,7 +162,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); } await AppSettings.instance.setThemeMode(mode); - notifyListeners(); + _notifyThemeChanged(); } Future setPreset(QueryaThemePreset preset) async { @@ -141,7 +179,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(preset); await AppSettings.instance.setThemeMode(_themeMode); } - notifyListeners(); + _notifyThemeChanged(); } /// Parses a VS Code theme file, persists it, and activates the imported preset. @@ -165,7 +203,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemeImportPath(storedPath); await AppSettings.instance.setThemePreset(QueryaThemePreset.imported); await AppSettings.instance.setThemeMode(_themeMode); - notifyListeners(); + _notifyThemeChanged(); return result; case ThemeImportFailure(): return result; @@ -182,14 +220,14 @@ class ThemeController extends ChangeNotifier { } _userOverrides = Map.unmodifiable(next); await AppSettings.instance.setThemeColorOverrides(next); - notifyListeners(); + _notifyThemeChanged(); } /// Removes only the user override layer (keeps preset/imported theme). Future clearColorOverrides() async { _userOverrides = const {}; await AppSettings.instance.clearThemeColorOverrides(); - notifyListeners(); + _notifyThemeChanged(); } /// Clears imported theme file and settings; falls back to Querya Dark. @@ -205,7 +243,7 @@ class ThemeController extends ChangeNotifier { await AppSettings.instance.setThemePreset(_preset); await AppSettings.instance.setThemeMode(_themeMode); } - notifyListeners(); + _notifyThemeChanged(); } Future resetToDefaults() async { @@ -218,7 +256,7 @@ class ThemeController extends ChangeNotifier { _userOverrides = const {}; _importedThemeName = null; _themeAnimationEnabled = false; - notifyListeners(); + _notifyThemeChanged(); } Brightness _effectiveBrightness() { diff --git a/lib/features/settings/preferences_appearance_section.dart b/lib/features/settings/preferences_appearance_section.dart index 7c464a92..c5706448 100644 --- a/lib/features/settings/preferences_appearance_section.dart +++ b/lib/features/settings/preferences_appearance_section.dart @@ -2,7 +2,6 @@ import 'dart:async' show unawaited; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/layout/ui_scale_controller.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; import 'package:querya_desktop/core/theme/theme_controller.dart'; import 'package:querya_desktop/core/theme/theme_import_service.dart'; @@ -21,7 +20,6 @@ class PreferencesAppearanceSection extends material.StatefulWidget { class _PreferencesAppearanceSectionState extends material.State { final _controller = ThemeController.instance; - final _uiScale = UiScaleController.instance; String? _importError; bool _importing = false; @@ -29,13 +27,11 @@ class _PreferencesAppearanceSectionState void initState() { super.initState(); _controller.addListener(_onThemeChanged); - _uiScale.addListener(_onThemeChanged); } @override void dispose() { _controller.removeListener(_onThemeChanged); - _uiScale.removeListener(_onThemeChanged); super.dispose(); } @@ -153,11 +149,11 @@ class _PreferencesAppearanceSectionState ), ), const material.SizedBox(height: 12), - PreferencesFieldRow( + const PreferencesFieldRow( label: 'Interface scale', hint: 'Snap to presets (75%, 85%, 90%, 100% …). Hold Shift for 1% fine control.', - control: InterfaceScaleSlider(scale: _uiScale.scale), + control: InterfaceScaleSlider(), ), const material.SizedBox(height: 12), PreferencesFieldRow( diff --git a/lib/features/settings/preferences_controls.dart b/lib/features/settings/preferences_controls.dart index a72022ce..ad30a932 100644 --- a/lib/features/settings/preferences_controls.dart +++ b/lib/features/settings/preferences_controls.dart @@ -77,10 +77,14 @@ class PreferencesFieldRow extends StatelessWidget { } /// Interface scale slider: fixed presets by default; hold Shift for 1% fine steps. +/// +/// Drag updates the label locally; [UiScaleController.commitScale] runs on release +/// so the rest of the app rebuilds once, not on every slider tick. class InterfaceScaleSlider extends material.StatefulWidget { - const InterfaceScaleSlider({super.key, required this.scale}); + const InterfaceScaleSlider({super.key, this.scale}); - final double scale; + /// When set (e.g. in tests), overrides [UiScaleController.instance.scale]. + final double? scale; @override material.State createState() => @@ -89,17 +93,55 @@ class InterfaceScaleSlider extends material.StatefulWidget { class _InterfaceScaleSliderState extends material.State { bool _fineControl = false; + double? _dragScale; static int get _fineDivisions => ((kMaxUiScale - kMinUiScale) / kUiScaleStep).round(); bool get _shiftHeld => HardwareKeyboard.instance.isShiftPressed; - void _syncModifierKeys() { + double get _committedScale => + widget.scale ?? UiScaleController.instance.scale; + + double get _displayScale => _dragScale ?? _committedScale; + + @override + void initState() { + super.initState(); + HardwareKeyboard.instance.addHandler(_onKeyEvent); + if (widget.scale == null) { + UiScaleController.instance.addListener(_onCommittedScaleChanged); + } + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onKeyEvent); + if (widget.scale == null) { + UiScaleController.instance.removeListener(_onCommittedScaleChanged); + } + super.dispose(); + } + + @override + void didUpdateWidget(InterfaceScaleSlider oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.scale != widget.scale) { + _dragScale = null; + } + } + + void _onCommittedScaleChanged() { + if (_dragScale != null || !mounted) return; + setState(() {}); + } + + bool _onKeyEvent(KeyEvent event) { final fine = _shiftHeld; if (fine != _fineControl) { setState(() => _fineControl = fine); } + return false; } double _sliderPosition(double scale, {required bool fine}) { @@ -116,18 +158,16 @@ class _InterfaceScaleSliderState extends material.State { @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; - final pct = (widget.scale * 100).round(); + final displayScale = _displayScale; + final pct = (displayScale * 100).round(); final fine = _fineControl; - return material.Listener( - onPointerDown: (_) => _syncModifierKeys(), - onPointerMove: (_) => _syncModifierKeys(), - child: material.Row( + return material.Row( children: [ material.Expanded( child: Slider( value: SliderValue.single( - _sliderPosition(widget.scale, fine: fine), + _sliderPosition(displayScale, fine: fine), ), min: fine ? kMinUiScale : 0, max: fine @@ -136,21 +176,18 @@ class _InterfaceScaleSliderState extends material.State { divisions: fine ? _fineDivisions : kUiScalePresets.length - 1, hintValue: const SliderValue.single(kDefaultUiScale), onChanged: (value) { - _syncModifierKeys(); final next = _scaleFromSlider( value.value, fine: _shiftHeld, ); - UiScaleController.instance.setScalePreview( - next, - fine: _shiftHeld, - ); + setState(() => _dragScale = next); }, onChangeEnd: (value) { final next = _scaleFromSlider( value.value, fine: _shiftHeld, ); + setState(() => _dragScale = null); unawaited( UiScaleController.instance.commitScale( next, @@ -174,8 +211,7 @@ class _InterfaceScaleSliderState extends material.State { ), ), ], - ), - ); + ); } } diff --git a/test/features/settings/interface_scale_slider_test.dart b/test/features/settings/interface_scale_slider_test.dart index 7e346486..300c9c6d 100644 --- a/test/features/settings/interface_scale_slider_test.dart +++ b/test/features/settings/interface_scale_slider_test.dart @@ -23,14 +23,10 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); - test('preview snaps to presets unless fine mode', () { - final controller = UiScaleController.instance; - final before = controller.scale; - controller.setScalePreview(1.15); - expect(controller.scale, 1.1); - controller.setScalePreview(1.15, fine: true); - expect(controller.scale, closeTo(1.15, 0.001)); - controller.setScalePreview(before, fine: true); + test('UiScaleController.normalize snaps to presets unless fine mode', () { + expect(UiScaleController.normalize(1.15), 1.1); + expect(UiScaleController.normalize(1.15, fine: true), closeTo(1.15, 0.001)); + expect(UiScaleController.normalize(0.88), 0.9); }); }); From 1114b35c616fd002698ef403ca22265a4a2376be Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 20:10:40 +0300 Subject: [PATCH 11/32] perf(editor): debounce syntax highlight and always use isolate (#93) Remove synchronous highlight on the UI thread; debounce scheduling by 100ms and route all jobs through compute. Update editor tests to flush timers. --- .../editor/querya_highlight_controller.dart | 38 +++++++++++++------ lib/core/editor/syntax_highlight_isolate.dart | 5 +-- test/core/editor/querya_code_editor_test.dart | 4 ++ .../mongodb/mongo_document_editor_test.dart | 4 ++ test/support/pump_syntax_highlight.dart | 8 ++++ 5 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 test/support/pump_syntax_highlight.dart diff --git a/lib/core/editor/querya_highlight_controller.dart b/lib/core/editor/querya_highlight_controller.dart index ae92387b..eb46c8e0 100644 --- a/lib/core/editor/querya_highlight_controller.dart +++ b/lib/core/editor/querya_highlight_controller.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; import 'syntax_highlight_isolate.dart'; +/// Debounce delay before scheduling syntax highlight work. +const Duration kSyntaxHighlightDebounce = Duration(milliseconds: 100); + /// [TextEditingController] that applies [Highlighter] in [buildTextSpan]. class QueryaHighlightController extends TextEditingController { QueryaHighlightController({ @@ -29,6 +34,7 @@ class QueryaHighlightController extends TextEditingController { String? _cachedText; Brightness? _cachedBrightness; int _highlightGeneration = 0; + Timer? _debounceTimer; @override TextSpan buildTextSpan({ @@ -37,20 +43,10 @@ class QueryaHighlightController extends TextEditingController { required bool withComposing, }) { final brightness = Theme.of(context).brightness; - final highlighter = brightness == Brightness.light - ? lightHighlighter - : darkHighlighter; final themeConfig = brightness == Brightness.light ? lightThemeConfig : darkThemeConfig; - if (text.length < kSyntaxHighlightIsolateThreshold) { - return TextSpan( - style: style, - children: [highlighter.highlight(text)], - ); - } - if (_cachedText == text && _cachedBrightness == brightness && _cachedSpan != null) { @@ -64,7 +60,9 @@ class QueryaHighlightController extends TextEditingController { style: style, ); - if (_cachedSpan != null && _cachedText == text) { + if (_cachedSpan != null && + _cachedText == text && + _cachedBrightness == brightness) { return TextSpan(style: style, children: [_cachedSpan!]); } @@ -76,6 +74,23 @@ class QueryaHighlightController extends TextEditingController { required Brightness brightness, required String themeConfig, required TextStyle? style, + }) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(kSyntaxHighlightDebounce, () { + _runIsolateHighlight( + text: text, + brightness: brightness, + themeConfig: themeConfig, + style: style, + ); + }); + } + + void _runIsolateHighlight({ + required String text, + required Brightness brightness, + required String themeConfig, + required TextStyle? style, }) { final generation = ++_highlightGeneration; final lang = switch (language) { @@ -103,6 +118,7 @@ class QueryaHighlightController extends TextEditingController { @override void dispose() { + _debounceTimer?.cancel(); _highlightGeneration++; super.dispose(); } diff --git a/lib/core/editor/syntax_highlight_isolate.dart b/lib/core/editor/syntax_highlight_isolate.dart index 4e1aa7ed..a0fece91 100644 --- a/lib/core/editor/syntax_highlight_isolate.dart +++ b/lib/core/editor/syntax_highlight_isolate.dart @@ -119,12 +119,9 @@ TextStyle? _styleFromSegment(HighlightSegment s, TextStyle? base) { ); } -/// Runs [syntaxHighlightInIsolate] off the UI thread when [code] is large. +/// Runs [syntaxHighlightInIsolate] off the UI thread. Future> highlightOffMainThread( SyntaxHighlightJob job, ) { - if (job.code.length < kSyntaxHighlightIsolateThreshold) { - return Future.value(syntaxHighlightInIsolate(job)); - } return compute(syntaxHighlightInIsolate, job); } diff --git a/test/core/editor/querya_code_editor_test.dart b/test/core/editor/querya_code_editor_test.dart index 33f1dc22..4c376dab 100644 --- a/test/core/editor/querya_code_editor_test.dart +++ b/test/core/editor/querya_code_editor_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import '../../support/pump_syntax_highlight.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { @@ -74,11 +75,14 @@ void main() { ), ); await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); await tester.enterText(find.byType(material.EditableText), 'SELECT 1'); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect(external.text, 'SELECT 1'); external.text = 'UPDATE x'; await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect( tester.widget(find.byType(material.EditableText)).controller.text, 'UPDATE x', diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 64f23985..6915c118 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; +import '../../support/pump_syntax_highlight.dart'; import '../../support/querya_theme_test_shell.dart'; void main() { @@ -37,6 +38,7 @@ void main() { ), ); await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); } testWidgets('Format pretty-prints valid JSON', (tester) async { @@ -48,6 +50,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Format')); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); final editable = tester.widget( find.byType(material.EditableText), @@ -63,6 +66,7 @@ void main() { await tester.pump(); await tester.tap(find.text('Format')); await tester.pump(); + await pumpSyntaxHighlightDebounce(tester); expect(find.textContaining('Invalid JSON'), findsOneWidget); expect(find.byType(material.EditableText), findsOneWidget); diff --git a/test/support/pump_syntax_highlight.dart b/test/support/pump_syntax_highlight.dart new file mode 100644 index 00000000..da1c7970 --- /dev/null +++ b/test/support/pump_syntax_highlight.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/editor/querya_highlight_controller.dart'; + +/// Flushes the syntax-highlight debounce timer and pending isolate work. +Future pumpSyntaxHighlightDebounce(WidgetTester tester) async { + await tester.pump(kSyntaxHighlightDebounce); + await tester.pump(); +} From 301922d3b741633ba4a0d39c528cb62802139879 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 20:10:41 +0300 Subject: [PATCH 12/32] perf(ui): virtualize SQL result grid (#93) Replace material.Table with VirtualResultGrid using ListView.builder, fixed column widths, and lighter cell widgets for large query results. --- .../main_screen/result_grid_view.dart | 318 ++++++++++++++++++ lib/features/main_screen/results_tab.dart | 54 +-- .../main_screen/results_tab_test.dart | 100 ++++++ 3 files changed, 420 insertions(+), 52 deletions(-) create mode 100644 lib/features/main_screen/result_grid_view.dart create mode 100644 test/features/main_screen/results_tab_test.dart diff --git a/lib/features/main_screen/result_grid_view.dart b/lib/features/main_screen/result_grid_view.dart new file mode 100644 index 00000000..eedeba8a --- /dev/null +++ b/lib/features/main_screen/result_grid_view.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Layout metrics for [VirtualResultGrid]. +abstract final class ResultGridMetrics { + static const double rowHeight = 36; + static const double headerHeight = 36; + static const double minColumnWidth = 120; + static const double maxColumnWidth = 280; + static const int columnWidthSampleRows = 40; + static const int tooltipMinLength = 48; +} + +/// Computes fixed column widths from headers and a sample of [rows]. +List computeResultGridColumnWidths({ + required List columns, + required List> rows, + double minWidth = ResultGridMetrics.minColumnWidth, + double maxWidth = ResultGridMetrics.maxColumnWidth, + int sampleRowCount = ResultGridMetrics.columnWidthSampleRows, +}) { + if (columns.isEmpty) return const []; + + final widths = List.filled(columns.length, minWidth); + final sample = rows.length < sampleRowCount ? rows.length : sampleRowCount; + + for (var c = 0; c < columns.length; c++) { + var maxChars = columns[c].length; + for (var r = 0; r < sample; r++) { + if (c < rows[r].length && rows[r][c].length > maxChars) { + maxChars = rows[r][c].length; + } + } + widths[c] = (maxChars * 7.5 + 24).clamp(minWidth, maxWidth); + } + return widths; +} + +/// Virtualized read-only grid for SQL query results. +class VirtualResultGrid extends material.StatefulWidget { + const VirtualResultGrid({ + super.key, + required this.columns, + required this.rows, + }); + + final List columns; + final List> rows; + + @override + material.State createState() => _VirtualResultGridState(); +} + +class _VirtualResultGridState extends material.State { + final _horizontalController = material.ScrollController(); + final _verticalController = material.ScrollController(); + + List _columnWidths = const []; + bool _widthsNeedUpdate = true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_widthsNeedUpdate) { + _columnWidths = _computeColumnWidths(); + _widthsNeedUpdate = false; + } + } + + @override + void didUpdateWidget(VirtualResultGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.columns != widget.columns || + oldWidget.rows != widget.rows) { + _widthsNeedUpdate = true; + } + } + + @override + void dispose() { + _horizontalController.dispose(); + _verticalController.dispose(); + super.dispose(); + } + + List _computeColumnWidths() { + return computeResultGridColumnWidths( + columns: widget.columns, + rows: widget.rows, + minWidth: context.scaled(ResultGridMetrics.minColumnWidth), + maxWidth: context.scaled(ResultGridMetrics.maxColumnWidth), + ); + } + + double get _tableWidth { + if (_columnWidths.isEmpty) return 0; + return _columnWidths.reduce((a, b) => a + b); + } + + double _scaledRowHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.rowHeight); + + double _scaledHeaderHeight(material.BuildContext context) => + context.scaled(ResultGridMetrics.headerHeight); + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final colCount = widget.columns.length; + final rowHeight = _scaledRowHeight(context); + final headerHeight = _scaledHeaderHeight(context); + + return material.RepaintBoundary( + child: material.LayoutBuilder( + builder: (context, constraints) { + final tableWidth = _tableWidth > constraints.maxWidth + ? _tableWidth + : constraints.maxWidth; + + 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: _columnWidths, + height: headerHeight, + colorScheme: cs, + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: widget.rows.length, + itemExtent: rowHeight, + itemBuilder: (context, rowIndex) { + final row = widget.rows[rowIndex]; + final isEven = rowIndex.isEven; + return _DataRow( + key: ValueKey('result-row-$rowIndex'), + row: row, + columnWidths: _columnWidths, + columnCount: colCount, + height: rowHeight, + colorScheme: cs, + striped: !isEven, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _HeaderRow extends material.StatelessWidget { + const _HeaderRow({ + required this.columns, + required this.columnWidths, + required this.height, + required this.colorScheme, + }); + + final List columns; + final List columnWidths; + final double height; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + height: height, + decoration: material.BoxDecoration( + color: colorScheme.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.5), + ), + ), + ), + child: material.Row( + children: [ + for (var i = 0; i < columns.length; i++) + _GridCell( + text: columns[i], + width: columnWidths[i], + isHeader: true, + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _DataRow extends material.StatelessWidget { + const _DataRow({ + super.key, + required this.row, + required this.columnWidths, + required this.columnCount, + required this.height, + required this.colorScheme, + required this.striped, + }); + + final List row; + final List columnWidths; + final int columnCount; + final double height; + final ColorScheme colorScheme; + final bool striped; + + @override + material.Widget build(material.BuildContext context) { + return material.Container( + 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: [ + for (var c = 0; c < columnCount; c++) + _GridCell( + text: c < row.length ? row[c] : '', + width: columnWidths[c], + colorScheme: colorScheme, + ), + ], + ), + ); + } +} + +class _GridCell extends material.StatelessWidget { + const _GridCell({ + 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 style = material.TextStyle( + fontSize: isHeader ? 12 : 12, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + fontFamily: isHeader ? null : 'monospace', + color: isNull + ? colorScheme.mutedForeground.withValues(alpha: 0.5) + : (isHeader ? colorScheme.foreground : colorScheme.foreground), + fontStyle: isNull ? material.FontStyle.italic : material.FontStyle.normal, + ); + + final cell = material.Container( + width: width, + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide( + color: colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Text( + text, + style: style, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ); + + if (isHeader) return cell; + + return material.Tooltip( + message: text.length >= ResultGridMetrics.tooltipMinLength ? text : '', + waitDuration: const Duration(milliseconds: 400), + child: material.GestureDetector( + onSecondaryTap: () => Clipboard.setData(ClipboardData(text: text)), + child: cell, + ), + ); + } +} diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index 36675758..c7f2fd47 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/csv/result_grid_csv.dart'; import 'package:querya_desktop/core/csv/save_result_grid_csv.dart'; import 'package:querya_desktop/core/json/result_grid_json.dart'; import 'package:querya_desktop/core/json/save_result_grid_json.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Query output: grid, loading, error, or placeholder. @@ -148,58 +149,7 @@ class ResultsTab extends StatelessWidget { ), ), material.Expanded( - child: material.Scrollbar( - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: material.SingleChildScrollView( - child: material.Table( - border: material.TableBorder.all( - color: Theme.of(context) - .colorScheme - .border - .withValues(alpha: 0.35), - ), - defaultColumnWidth: const material.IntrinsicColumnWidth(), - children: [ - material.TableRow( - decoration: material.BoxDecoration( - color: Theme.of(context) - .colorScheme - .muted - .withValues(alpha: 0.35), - ), - children: columns - .map( - (c) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: Text(c).semiBold().small(), - ), - ) - .toList(), - ), - ...rows.map( - (r) => material.TableRow( - children: r - .map( - (cell) => material.Padding( - padding: const material.EdgeInsets.all(8), - child: material.SelectableText( - cell, - style: const material.TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - ), - ), - ) - .toList(), - ), - ), - ], - ), - ), - ), - ), + child: VirtualResultGrid(columns: columns, rows: rows), ), ], ); diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart new file mode 100644 index 00000000..1f77c119 --- /dev/null +++ b/test/features/main_screen/results_tab_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/result_grid_view.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('computeResultGridColumnWidths', () { + test('returns empty list for no columns', () { + expect( + computeResultGridColumnWidths(columns: const [], rows: const []), + isEmpty, + ); + }); + + test('respects min and max width bounds', () { + final widths = computeResultGridColumnWidths( + columns: const ['id', 'note'], + rows: [ + ['1', 'x'], + ['2', 'y'], + ], + minWidth: 100, + maxWidth: 150, + ); + expect(widths, hasLength(2)); + for (final w in widths) { + expect(w, inInclusiveRange(100, 150)); + } + }); + + test('widens columns for long sampled values', () { + final short = computeResultGridColumnWidths( + columns: const ['payload'], + rows: [ + ['a'], + ], + ).single; + final long = computeResultGridColumnWidths( + columns: const ['payload'], + rows: [ + ['${'x' * 80}'], + ], + ).single; + expect(long, greaterThan(short)); + }); + }); + + group('ResultsTab', () { + testWidgets('uses virtualized grid instead of Table', (tester) async { + final rows = List.generate( + 120, + (i) => ['$i', 'value-$i'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ResultsTab( + columns: const ['id', 'name'], + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + expect(find.byType(material.Table), findsNothing); + expect(find.byType(VirtualResultGrid), findsOneWidget); + }); + + testWidgets('virtualizes rows — does not build all row widgets at once', + (tester) async { + final rows = List.generate( + 500, + (i) => ['$i', 'value-$i'], + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + height: 400, + width: 600, + child: VirtualResultGrid( + columns: const ['id', 'name'], + rows: rows, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Header + only visible rows (not all 500). + final dataRowWidgets = tester.widgetList(find.byType(material.Row)).length; + expect(dataRowWidgets, lessThan(80)); + }); + }); +} From faf58345e2ecb9758ee6fd8dd60f9a6d608c437d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 20:10:41 +0300 Subject: [PATCH 13/32] perf(connections): lazy lists and sliver tree panel (#93) Virtualize large PG/MySQL object lists via lazyConnectionTreeList, drop per-row TextPainter in tree labels, and build top-level panel with SliverList. --- .../connections/connections_panel.dart | 92 +++++++++++---- .../connections/connections_panel_mysql.dart | 62 ++++++---- .../connections_panel_pg_tree.dart | 108 ++++++++++-------- .../connections_panel_sidebar.dart | 34 ++++-- .../lazy_connection_tree_list_test.dart | 49 ++++++++ 5 files changed, 245 insertions(+), 100 deletions(-) create mode 100644 test/features/connections/lazy_connection_tree_list_test.dart diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 59e4c43c..5cc49806 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback, RepaintBoundary; +import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, EdgeInsetsGeometry, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, TextStyle, CustomScrollView, SliverFillRemaining, SliverPadding, SliverList, SliverChildBuilderDelegate, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, SelectableText, Padding, Widget, Navigator, ValueKey, FontWeight, VoidCallback, RepaintBoundary, ListView, ClampingScrollPhysics; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; @@ -33,6 +33,54 @@ typedef OnPostgresOpenSqlWorkspace = void Function( PostgresObjectKind? kind, }); +/// Typical height of a compact tree row (object leaf / table name). +const double kConnectionTreeRowExtent = 28; + +/// Build all rows inline when the list is short. +const int kConnectionTreeEagerThreshold = 24; + +/// Max rows visible before nested list scrolls (virtualized via [ListView.builder]). +const int kConnectionTreeMaxVisibleRows = 14; + +/// Builds a short [Column] or a height-capped [ListView.builder] for large lists. +material.Widget lazyConnectionTreeList({ + required material.BuildContext context, + required int itemCount, + required material.Widget Function(material.BuildContext context, int index) + itemBuilder, + double? itemExtent, + int eagerThreshold = kConnectionTreeEagerThreshold, + int maxVisibleRows = kConnectionTreeMaxVisibleRows, + material.EdgeInsetsGeometry? padding, +}) { + if (itemCount == 0) { + return const material.SizedBox.shrink(); + } + if (itemCount <= eagerThreshold) { + return material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < itemCount; i++) itemBuilder(context, i), + ], + ); + } + final rowExtent = itemExtent ?? kConnectionTreeRowExtent; + return material.ConstrainedBox( + constraints: material.BoxConstraints( + maxHeight: maxVisibleRows * rowExtent, + ), + child: material.ListView.builder( + padding: padding ?? material.EdgeInsets.zero, + shrinkWrap: true, + physics: const material.ClampingScrollPhysics(), + itemCount: itemCount, + itemExtent: itemExtent, + itemBuilder: itemBuilder, + ), + ); +} + /// Left panel: Browser tree (pgAdmin-style). Uses shadcn layout widgets. class ConnectionsPanel extends StatefulWidget { const ConnectionsPanel({ @@ -268,6 +316,9 @@ class ConnectionsPanelState extends State { // Connections without a folder final rootConnections = _connections.where((c) => c.folderId == null).toList(); + final showEmptyState = _connections.isEmpty && _folders.isEmpty; + final topLevelCount = + _folders.length + rootConnections.length + (showEmptyState ? 1 : 0); return material.Container( decoration: material.BoxDecoration( @@ -302,14 +353,20 @@ class ConnectionsPanelState extends State { slivers: [ material.SliverPadding( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 12), - sliver: material.SliverToBoxAdapter( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Folders - for (final name in _folders) - _FolderTile( + sliver: material.SliverList( + delegate: material.SliverChildBuilderDelegate( + (context, index) { + if (showEmptyState && index == 0) { + return const material.Padding( + padding: material.EdgeInsets.only(top: 8), + child: _EmptyState(message: 'No connections yet'), + ); + } + final folderOffset = showEmptyState ? 1 : 0; + final folderIndex = index - folderOffset; + if (folderIndex < _folders.length) { + final name = _folders[folderIndex]; + return _FolderTile( name: name, initiallyExpanded: _expandedFolders.contains(name), onExpansionCommitted: (folderName, expanded) { @@ -337,17 +394,12 @@ class ConnectionsPanelState extends State { onRedisDatabaseTap: widget.onRedisDatabaseSelected, onMongoDBDatabaseTap: widget.onMongoDBDatabaseSelected, buildConnectionTile: _buildConnectionTile, - ), - // Root connections (no folder) - for (final conn in rootConnections) - _buildConnectionTile(conn), - // Empty state - if (_connections.isEmpty && _folders.isEmpty) - const material.Padding( - padding: material.EdgeInsets.only(top: 8), - child: _EmptyState(message: 'No connections yet'), - ), - ], + ); + } + final connIndex = folderIndex - _folders.length; + return _buildConnectionTile(rootConnections[connIndex]); + }, + childCount: topLevelCount, ), ), ), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 61684b78..f6045237 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -289,14 +289,20 @@ class _MysqlDatabasesNode extends material.StatelessWidget { : (c, {database, schema, name, kind}) => onMysqlOpenSqlWorkspace!(c), ), - for (final db in databases) - _MysqlDatabaseNode( - key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), - connection: connection, - databaseName: db, - onMysqlObjectSelected: onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: databases.length, + itemBuilder: (context, index) { + final db = databases[index]; + return _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -444,10 +450,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { onContextRefresh: _loadTables, onOpenSqlWorkspace: null, ), - for (final t in _tables) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: _tables.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 12), + itemBuilder: (context, index) { + final t = _tables[index]; + return _PgTreeRow( + key: material.ValueKey( + 'mysql-table-${widget.connection.id ?? 0}-${widget.databaseName}-$t', + ), label: t, icon: material.Icons.grid_on_rounded, iconSize: 12, @@ -468,8 +481,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { connection: widget.connection, onContextRefresh: null, onOpenSqlWorkspace: null, - ), - ), + ); + }, + ), ], if (_views.isNotEmpty) ...[ _PgTreeRow( @@ -487,10 +501,17 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { onContextRefresh: _loadTables, onOpenSqlWorkspace: null, ), - for (final v in _views) - material.Padding( - padding: const material.EdgeInsets.only(left: 12), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: _views.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 12), + itemBuilder: (context, index) { + final v = _views[index]; + return _PgTreeRow( + key: material.ValueKey( + 'mysql-view-${widget.connection.id ?? 0}-${widget.databaseName}-$v', + ), label: v, icon: material.Icons.view_week_rounded, iconSize: 12, @@ -511,8 +532,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { connection: widget.connection, onContextRefresh: null, onOpenSqlWorkspace: null, - ), - ), + ); + }, + ), ], ], ), diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 2ff0fc92..d79fe824 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -25,7 +25,7 @@ class _PgDatabasesNode extends StatefulWidget { State<_PgDatabasesNode> createState() => _PgDatabasesNodeState(); } -/// Ellipsis label; tooltip only when text overflows (intrinsic width > slot). +/// Ellipsis label; tooltip when the name is long enough to likely truncate. class _PgTreeRowLabel extends material.StatelessWidget { const _PgTreeRowLabel({ required this.label, @@ -35,30 +35,21 @@ class _PgTreeRowLabel extends material.StatelessWidget { final String label; final material.TextStyle textStyle; + static const int _tooltipMinLength = 28; + @override material.Widget build(material.BuildContext context) { - return material.LayoutBuilder( - builder: (context, constraints) { - final tp = material.TextPainter( - text: material.TextSpan(text: label, style: textStyle), - maxLines: 1, - textDirection: material.TextDirection.ltr, - ); - tp.layout(maxWidth: double.infinity); - final overflow = tp.width > constraints.maxWidth + 0.5; - final text = material.Text( - label, - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: textStyle, - ); - if (!overflow) return text; - return material.Tooltip( - message: label, - waitDuration: const Duration(milliseconds: 450), - child: text, - ); - }, + final text = material.Text( + label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: textStyle, + ); + if (label.length < _tooltipMinLength) return text; + return material.Tooltip( + message: label, + waitDuration: const Duration(milliseconds: 450), + child: text, ); } } @@ -66,6 +57,7 @@ class _PgTreeRowLabel extends material.StatelessWidget { /// Shared tree row: consistent ink hover, optional context menu, tooltips when truncated. class _PgTreeRow extends material.StatelessWidget { const _PgTreeRow({ + super.key, required this.label, this.leading, this.icon, @@ -242,14 +234,20 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final db in widget.databases) - _PgDatabaseNode( - key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), - connection: widget.connection, - databaseName: db, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: widget.databases.length, + itemBuilder: (context, index) { + final db = widget.databases[index]; + return _PgDatabaseNode( + key: material.ValueKey('pg-db-${widget.connection.id ?? 0}-$db'), + connection: widget.connection, + databaseName: db, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -531,17 +529,23 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final schema in widget.schemas) - _PgSchemaNode( - key: material.ValueKey( - 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', - ), - connection: widget.connection, - databaseName: widget.databaseName, - schemaName: schema, - onPostgresObjectSelected: widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, - ), + lazyConnectionTreeList( + context: context, + itemCount: widget.schemas.length, + itemBuilder: (context, index) { + final schema = widget.schemas[index]; + return _PgSchemaNode( + key: material.ValueKey( + 'pg-schema-${widget.connection.id ?? 0}-${widget.databaseName}-$schema', + ), + connection: widget.connection, + databaseName: widget.databaseName, + schemaName: schema, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + ); + }, + ), ], ), ); @@ -955,10 +959,17 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { onOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ), if (_expanded) - for (final item in widget.items) - material.Padding( - padding: const material.EdgeInsets.only(left: 22), - child: _PgTreeRow( + lazyConnectionTreeList( + context: context, + itemCount: widget.items.length, + itemExtent: kConnectionTreeRowExtent, + padding: const material.EdgeInsets.only(left: 22), + itemBuilder: (context, index) { + final item = widget.items[index]; + return _PgTreeRow( + key: material.ValueKey( + 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', + ), label: item, icon: widget.icon, iconSize: 12, @@ -979,8 +990,9 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { openSqlSchema: widget.schemaName, openSqlName: item, openSqlKind: widget.objectKind, - ), - ), + ); + }, + ), ], ), ); diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 0aae05be..f1994158 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -67,6 +67,7 @@ class _EmptyState extends StatelessWidget { /// Tile for a single connection in the sidebar. class _ConnectionTile extends StatelessWidget { const _ConnectionTile({ + super.key, required this.connection, this.isSelected = false, required this.icon, @@ -271,19 +272,28 @@ class _FolderTileState extends State<_FolderTile> { ), ), if (_expanded) - for (final conn in widget.connections) - material.Padding( - padding: const material.EdgeInsets.only(left: 24), - child: widget.buildConnectionTile != null - ? widget.buildConnectionTile!(conn) - : _ConnectionTile( - connection: conn, - icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => widget.onRemoveConnection(conn.id!), - onTap: () => widget.onConnectionTap?.call(conn), - ), + material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: lazyConnectionTreeList( + context: context, + itemCount: widget.connections.length, + itemBuilder: (context, index) { + final conn = widget.connections[index]; + return widget.buildConnectionTile != null + ? widget.buildConnectionTile!(conn) + : _ConnectionTile( + key: material.ValueKey('folder-conn-${conn.id}'), + connection: conn, + icon: widget.iconForType(conn.type), + iconAsset: ConnectionsPanelState._iconAssetForType( + conn.type, + ), + onRemove: () => widget.onRemoveConnection(conn.id!), + onTap: () => widget.onConnectionTap?.call(conn), + ); + }, ), + ), ], ), ), diff --git a/test/features/connections/lazy_connection_tree_list_test.dart b/test/features/connections/lazy_connection_tree_list_test.dart new file mode 100644 index 00000000..8feed90e --- /dev/null +++ b/test/features/connections/lazy_connection_tree_list_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; + +void main() { + testWidgets('lazyConnectionTreeList uses ListView for large lists', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 50, + itemExtent: kConnectionTreeRowExtent, + itemBuilder: (context, index) => material.SizedBox( + height: kConnectionTreeRowExtent, + child: material.Text('item $index'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + }); + + testWidgets('lazyConnectionTreeList uses Column for small lists', (tester) async { + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => lazyConnectionTreeList( + context: context, + itemCount: 5, + itemBuilder: (context, index) => material.Text('item $index'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsNothing); + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item 4'), findsOneWidget); + }); +} From 7668cdba1f49c7bc95b74b9f1b12d85b94fe9813 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 20:16:07 +0300 Subject: [PATCH 14/32] fix(connections): open connection form after menu type picker Menu overlay context unmounted before the second dialog, so New Database Connection stopped after Next. --- .../connections/connection_creation_flow.dart | 22 ++++++++++++++----- lib/features/main_screen/main_screen.dart | 15 +++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index 935ff472..9a43338e 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -7,22 +7,32 @@ import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; +/// Context that stays mounted after menu overlays close (multi-step dialog flow). +material.BuildContext _dialogAnchorContext(material.BuildContext context) { + final navigator = material.Navigator.maybeOf(context, rootNavigator: true); + if (navigator != null && navigator.context.mounted) { + return navigator.context; + } + return context; +} + /// Picks a database type, opens the matching form, returns a saved row or null. Future promptCreateConnection( material.BuildContext context, { int? folderId, }) async { - final type = await showNewConnectionDialog(context); + final dialogContext = _dialogAnchorContext(context); + final type = await showNewConnectionDialog(dialogContext); if (type == null) return null; - if (!context.mounted) return null; + if (!dialogContext.mounted) return null; switch (type) { case ConnectionType.postgresql: - return await showPostgresConnectionForm(context, folderId: folderId); + return await showPostgresConnectionForm(dialogContext, folderId: folderId); case ConnectionType.mysql: - return await showMysqlConnectionForm(context, folderId: folderId); + return await showMysqlConnectionForm(dialogContext, folderId: folderId); case ConnectionType.mongodb: - return await showMongoConnectionForm(context, folderId: folderId); + return await showMongoConnectionForm(dialogContext, folderId: folderId); case ConnectionType.redis: - return await showRedisConnectionForm(context, folderId: folderId); + return await showRedisConnectionForm(dialogContext, folderId: folderId); } } diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index a7461e5e..bd451572 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -117,9 +117,11 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } - Future _onNewDatabaseConnectionFromMenu( - material.BuildContext menuContext) async { - final row = await promptCreateConnection(menuContext, folderId: null); + 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(); @@ -329,8 +331,7 @@ class _CustomTitleBar extends StatefulWidget { }); final ColorScheme theme; - final Future Function(material.BuildContext context) - onNewDatabaseConnection; + final Future Function() onNewDatabaseConnection; @override State<_CustomTitleBar> createState() => _CustomTitleBarState(); @@ -414,8 +415,8 @@ class _CustomTitleBarState extends State<_CustomTitleBar> { material.Icons.add_link_rounded, size: 18), trailing: const Text('Shift+Ctrl+N').xSmall().muted(), - onPressed: (ctx) => - widget.onNewDatabaseConnection(ctx), + onPressed: (_) => + widget.onNewDatabaseConnection(), child: const Text('New Database Connection'), ), MenuButton( From 44bc20b635fea1105f79a2598e1b585ec37778f8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 20:29:29 +0300 Subject: [PATCH 15/32] perf(ui): narrow rebuilds for splitters, panel, and forms (#93) Use ValueNotifier split panes, SqlWorkspaceSettingsRevision, connection-id panel slot, and FormValidityNotifier for connection dialogs. --- lib/core/layout/vertical_split_pane.dart | 88 ++++++++ lib/core/storage/app_settings.dart | 19 +- lib/features/main_screen/main_screen.dart | 164 ++++++++++----- lib/features/main_screen/workspace_panel.dart | 149 +++++-------- .../mongodb/mongodb_connection_form.dart | 119 ++++++----- lib/features/mysql/mysql_connection_form.dart | 136 ++++++------ lib/features/mysql/mysql_sql_workspace.dart | 171 ++++++--------- .../postgresql/postgres_sql_workspace.dart | 195 +++++++----------- .../postgresql_connection_form.dart | 146 +++++++------ lib/features/redis/redis_connection_form.dart | 108 +++++----- .../widgets/form_validity_notifier.dart | 33 +++ test/core/storage/app_settings_test.dart | 24 ++- 12 files changed, 747 insertions(+), 605 deletions(-) create mode 100644 lib/core/layout/vertical_split_pane.dart create mode 100644 lib/shared/widgets/form_validity_notifier.dart diff --git a/lib/core/layout/vertical_split_pane.dart b/lib/core/layout/vertical_split_pane.dart new file mode 100644 index 00000000..36dc3cb2 --- /dev/null +++ b/lib/core/layout/vertical_split_pane.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Holds top/bottom panes for [VerticalSplitPane] [ValueListenableBuilder.child]. +class SplitPanePair extends StatelessWidget { + const SplitPanePair({super.key, required this.top, required this.bottom}); + + final Widget top; + final Widget bottom; + + @override + Widget build(BuildContext context) => top; +} + +/// Vertical split whose drag updates [fraction] without rebuilding [top]/[bottom]. +class VerticalSplitPane extends StatelessWidget { + const VerticalSplitPane({ + super.key, + required this.fraction, + required this.top, + required this.bottom, + this.minFraction = 0.2, + this.maxFraction = 0.8, + this.handleKey, + }); + + final ValueNotifier fraction; + final Widget top; + final Widget bottom; + final double minFraction; + final double maxFraction; + final Key? handleKey; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalHeight = constraints.maxHeight; + return ValueListenableBuilder( + valueListenable: fraction, + builder: (context, value, panes) { + final pair = panes! as SplitPanePair; + final topFlex = (value * 100).round().clamp(20, 80).toInt(); + final bottomFlex = 100 - topFlex; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: topFlex, child: pair.top), + _VerticalSplitHandle( + key: handleKey, + onDrag: (dy) { + if (totalHeight <= 0) return; + fraction.value = (fraction.value + dy / totalHeight) + .clamp(minFraction, maxFraction); + }, + ), + Expanded(flex: bottomFlex, child: pair.bottom), + ], + ); + }, + child: SplitPanePair(top: top, bottom: bottom), + ); + }, + ); + } +} + +class _VerticalSplitHandle extends StatelessWidget { + const _VerticalSplitHandle({super.key, required this.onDrag}); + + final void Function(double dy) onDrag; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).colorScheme; + return material.MouseRegion( + cursor: material.SystemMouseCursors.resizeRow, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onVerticalDragUpdate: (e) => onDrag(e.delta.dy), + child: material.Container( + height: 6, + color: theme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 51c767a0..9530b38d 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -110,13 +110,20 @@ abstract final class AppSettingsKeys { static const uiScale = 'ui_scale'; } -/// Bumps [listenable] when any preference is persisted so open screens can reload. +/// Bumps [listenable] when any preference is persisted (theme, legacy listeners). abstract final class AppSettingsRevision { static final ValueNotifier listenable = ValueNotifier(0); static void bump() => listenable.value++; } +/// Bumps when SQL workspace preferences change (timeouts, grid, editor font, history). +abstract final class SqlWorkspaceSettingsRevision { + static final ValueNotifier listenable = ValueNotifier(0); + + static void bump() => listenable.value++; +} + /// User preferences backed by [LocalDb] (SQLite). class AppSettings { AppSettings._(); @@ -142,7 +149,7 @@ class AppSettings { seconds.toString(), ); } - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// `null` = use driver default for statement duration. @@ -165,7 +172,7 @@ class AppSettings { seconds.toString(), ); } - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Max rows loaded into the result grid for PostgreSQL / MySQL workspaces. @@ -187,7 +194,7 @@ class AppSettings { AppSettingsKeys.sqlResultMaxRows, preset.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Editor font size in logical pixels. @@ -207,7 +214,7 @@ class AppSettings { AppSettingsKeys.sqlEditorFontSizePoints, clamped.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// Global interface scale for typography and compact controls. @@ -246,7 +253,7 @@ class AppSettings { AppSettingsKeys.sqlHistoryMaxEntries, preset.toString(), ); - AppSettingsRevision.bump(); + SqlWorkspaceSettingsRevision.bump(); } /// UI theme mode (dark / light / system). diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index bd451572..2250f616 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -209,67 +209,57 @@ class _MainContentSplitState extends State<_MainContentSplit> { static const double _maxLeftWidth = 500; static const double _minWorkspaceWidth = 64; static const double _resizeHandleWidth = 6; - double _leftPanelWidth = 260; + final ValueNotifier _leftPanelWidth = ValueNotifier(260); + + @override + void dispose() { + _leftPanelWidth.dispose(); + super.dispose(); + } + + double _clampLeftWidth(double raw, double maxWidth) { + final maxLeft = maxWidth - _resizeHandleWidth - _minWorkspaceWidth; + if (maxLeft <= 0) return 0; + if (maxLeft < _minLeftWidth) return raw.clamp(0, maxLeft); + return raw.clamp(_minLeftWidth, math.min(_maxLeftWidth, maxLeft)); + } @override material.Widget build(material.BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final maxLeft = - constraints.maxWidth - _resizeHandleWidth - _minWorkspaceWidth; - double leftW; - if (maxLeft <= 0) { - leftW = 0; - } else if (maxLeft < _minLeftWidth) { - leftW = maxLeft; - } else { - leftW = _leftPanelWidth.clamp( - _minLeftWidth, - math.min(_maxLeftWidth, maxLeft), - ); - } return Row( children: [ - SizedBox( - width: leftW, + ValueListenableBuilder( + valueListenable: _leftPanelWidth, + builder: (context, rawWidth, connectionsPanel) { + final leftW = _clampLeftWidth(rawWidth, constraints.maxWidth); + return SizedBox(width: leftW, child: connectionsPanel); + }, child: material.RepaintBoundary( - child: ValueListenableBuilder( - valueListenable: widget.workspace, - builder: (context, ws, _) { - return ConnectionsPanel( - key: widget.connectionsPanelKey, - selectedConnectionId: ws.activeConnection?.id, - onConnectionSelected: widget.onConnectionSelected, - onRedisDatabaseSelected: widget.onRedisDatabaseSelected, - onMongoDBDatabaseSelected: - widget.onMongoDBDatabaseSelected, - onPostgresObjectSelected: - widget.onPostgresObjectSelected, - onPostgresOpenSqlWorkspace: - widget.onPostgresOpenSqlWorkspace, - onMysqlObjectSelected: widget.onMysqlObjectSelected, - onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, - ); - }, + 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, ), ), ), _VerticalResizeHandle( onDrag: (dx) { - setState(() { - final w = MediaQuery.sizeOf(context).width; - final ml = w - _resizeHandleWidth - _minWorkspaceWidth; - if (ml <= 0) return; - final next = _leftPanelWidth + dx; - if (ml < _minLeftWidth) { - _leftPanelWidth = next.clamp(0, ml); - } else { - _leftPanelWidth = next.clamp( - _minLeftWidth, - math.min(_maxLeftWidth, ml), - ); - } - }); + final ml = constraints.maxWidth - + _resizeHandleWidth - + _minWorkspaceWidth; + if (ml <= 0) return; + _leftPanelWidth.value = _clampLeftWidth( + _leftPanelWidth.value + dx, + constraints.maxWidth, + ); }, ), Expanded( @@ -302,6 +292,84 @@ class _MainContentSplitState extends State<_MainContentSplit> { } } +/// Rebuilds [ConnectionsPanel] only when the selected connection id changes. +class _ConnectionsPanelSlot extends StatefulWidget { + const _ConnectionsPanelSlot({ + required this.connectionsPanelKey, + required this.workspace, + required this.onConnectionSelected, + required this.onPostgresObjectSelected, + required this.onMysqlObjectSelected, + required this.onRedisDatabaseSelected, + required this.onMongoDBDatabaseSelected, + required this.onPostgresOpenSqlWorkspace, + required this.onMysqlOpenSqlWorkspace, + }); + + final GlobalKey connectionsPanelKey; + final ValueNotifier workspace; + final void Function(ConnectionRow) onConnectionSelected; + final void Function( + ConnectionRow, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) onPostgresObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + MysqlObjectKind kind, + ) onMysqlObjectSelected; + final void Function(ConnectionRow, int) onRedisDatabaseSelected; + final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; + final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; + + @override + State<_ConnectionsPanelSlot> createState() => _ConnectionsPanelSlotState(); +} + +class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { + int? _selectedConnectionId; + + @override + void initState() { + super.initState(); + _selectedConnectionId = widget.workspace.value.activeConnection?.id; + widget.workspace.addListener(_onWorkspaceChanged); + } + + @override + void dispose() { + widget.workspace.removeListener(_onWorkspaceChanged); + super.dispose(); + } + + void _onWorkspaceChanged() { + final next = widget.workspace.value.activeConnection?.id; + if (next != _selectedConnectionId) { + setState(() => _selectedConnectionId = next); + } + } + + @override + material.Widget build(material.BuildContext context) { + return ConnectionsPanel( + key: widget.connectionsPanelKey, + selectedConnectionId: _selectedConnectionId, + onConnectionSelected: widget.onConnectionSelected, + onRedisDatabaseSelected: widget.onRedisDatabaseSelected, + onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onPostgresObjectSelected: widget.onPostgresObjectSelected, + onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ); + } +} + class _VerticalResizeHandle extends StatelessWidget { const _VerticalResizeHandle({required this.onDrag}); diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 7d777f3f..b61720cd 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,4 +1,5 @@ -import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, CrossAxisAlignment, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, LayoutBuilder, HitTestBehavior, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:flutter/material.dart' as material show Alignment, Axis, Container, EdgeInsets, BoxDecoration, GestureDetector, Padding, BorderRadius, Center, Icon, Icons, MouseRegion, AnimatedContainer, AnimatedScale, Curves, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -73,7 +74,13 @@ class WorkspacePanel extends StatefulWidget { class _WorkspacePanelState extends State { int _editorTabIndex = 0; int _outputTabIndex = 0; - double _topFraction = 0.7; + final ValueNotifier _topFraction = ValueNotifier(0.7); + + @override + void dispose() { + _topFraction.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -186,104 +193,54 @@ class _WorkspacePanelState extends State { ); } - final topFlex = (_topFraction * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; - return material.Container( color: theme.colorScheme.background, - child: material.LayoutBuilder( - builder: (context, constraints) { - final totalHeight = constraints.maxHeight; - return Column( - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SectionBar( - title: 'Query', - tabs: const ['Query Editor', 'Query History'], - index: _editorTabIndex, - onTabChanged: (v) => setState(() => _editorTabIndex = v), - trailing: const _RunButton(), - ), - const Divider(height: 1), - Expanded( - child: IndexedStack( - index: _editorTabIndex, - children: const [ - QueryEditorTab(), - _PlaceholderTab(message: 'Query history'), - ], - ), - ), - ], - ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFraction = (_topFraction + dy / totalHeight).clamp(0.2, 0.8); - }); - }, + child: VerticalSplitPane( + fraction: _topFraction, + handleKey: const Key('workspace_panel_resize_handle'), + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionBar( + title: 'Query', + tabs: const ['Query Editor', 'Query History'], + index: _editorTabIndex, + onTabChanged: (v) => setState(() => _editorTabIndex = v), + trailing: const _RunButton(), + ), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _editorTabIndex, + children: const [ + QueryEditorTab(), + _PlaceholderTab(message: 'Query history'), + ], ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SectionBar( - title: 'Output', - tabs: const ['Data Output', 'Messages', 'Notifications'], - index: _outputTabIndex, - onTabChanged: (v) => setState(() => _outputTabIndex = v), - ), - const Divider(height: 1), - Expanded( - child: IndexedStack( - index: _outputTabIndex, - children: const [ - ResultsTab(), - _PlaceholderTab(message: 'Messages'), - _PlaceholderTab(message: 'Notifications'), - ], - ), - ), - ], - ), + ), + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionBar( + title: 'Output', + tabs: const ['Data Output', 'Messages', 'Notifications'], + index: _outputTabIndex, + onTabChanged: (v) => setState(() => _outputTabIndex = v), + ), + const Divider(height: 1), + Expanded( + child: IndexedStack( + index: _outputTabIndex, + children: const [ + ResultsTab(), + _PlaceholderTab(message: 'Messages'), + _PlaceholderTab(message: 'Notifications'), + ], ), - ], - ); - }, - ), - ); - } -} - -class _HorizontalResizeHandle extends StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - key: const Key('workspace_panel_resize_handle'), - behavior: material.HitTestBehavior.opaque, - onVerticalDragUpdate: (e) => onDrag(e.delta.dy), - child: material.Container( - height: 6, - color: theme.border.withValues(alpha: 0.15), + ), + ], ), ), ); diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 0382846f..ab5e0362 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// MongoDB connection form data. @@ -79,28 +80,35 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - // Rebuild on every keystroke so Save button reacts to validity - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); - } - - void _onFieldChanged() { - setState(() {}); + _formValidNotifier = FormValidityNotifier(() => _formData.isValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -255,7 +263,10 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo children: [ material.Checkbox( value: _useConnectionString, - onChanged: (v) => setState(() => _useConnectionString = v ?? false), + onChanged: (v) { + setState(() => _useConnectionString = v ?? false); + _formValidNotifier.seed(); + }, ), const Gap(8), const Text('Use connection string').small(), @@ -469,43 +480,53 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: material.Row( - children: [ - OutlineButton( - onPressed: _formData.isValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formData.isValid ? theme.primary : theme.mutedForeground, + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: _formData.isValid ? theme.primary : theme.mutedForeground, + ), ), - ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formData.isValid ? _save : null, - child: const Text('Save'), - ), - ], + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ); + }, ), ), ], diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 525215b9..a143e511 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows MySQL / MariaDB connection form dialog. @@ -47,26 +48,31 @@ class _MysqlConnectionFormContentState bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _databaseController.addListener(_onFieldChanged); - _usernameController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - void _onFieldChanged() => setState(() {}); - bool _looksLikeMysqlUri(String s) { final t = s.trim().toLowerCase(); return t.startsWith('mysql://') || t.startsWith('mariadb://'); } - bool get _formValid { + bool _computeFormValid() { final uri = _connectionStringController.text.trim(); if (uri.isNotEmpty) { return _looksLikeMysqlUri(uri); @@ -93,7 +99,7 @@ class _MysqlConnectionFormContentState } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -128,7 +134,7 @@ class _MysqlConnectionFormContentState } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 3306; @@ -163,12 +169,17 @@ class _MysqlConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _databaseController.removeListener(_onFieldChanged); - _usernameController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -431,54 +442,61 @@ class _MysqlConnectionFormContentState material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 24, vertical: 16), - child: material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - _formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formValid + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid ? theme.primary : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: - _formValid ? theme.primary : theme.mutedForeground, - ), - ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), + ), ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], ), ], - ), - ], + ); + }, ), ), ], diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 42386bdc..b98b2187 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/mysql_service.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/core/theme/querya_theme_scope.dart'; @@ -30,7 +31,7 @@ class MysqlSqlWorkspace extends material.StatefulWidget { class _MysqlSqlWorkspaceState extends material.State { final _sqlController = material.TextEditingController(); - double _topFractionState = 0.65; + final ValueNotifier _topFraction = ValueNotifier(0.65); MysqlLease? _lease; @@ -55,7 +56,7 @@ class _MysqlSqlWorkspaceState extends material.State { _appSettingsListener = () { unawaited(_loadWorkspaceSettings()); }; - AppSettingsRevision.listenable.addListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); }); @@ -103,7 +104,8 @@ class _MysqlSqlWorkspaceState extends material.State { @override void dispose() { - AppSettingsRevision.listenable.removeListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.removeListener(_appSettingsListener); + _topFraction.dispose(); if (_running) { MysqlService.instance.interrupt( widget.connectionRow, @@ -224,12 +226,9 @@ class _MysqlSqlWorkspaceState extends material.State { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - final topFlex = (_topFractionState * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; return material.LayoutBuilder( builder: (context, constraints) { - final totalHeight = constraints.maxHeight; return material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { @@ -240,85 +239,65 @@ class _MysqlSqlWorkspaceState extends material.State { }, child: material.Focus( autofocus: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _MysqlSqlToolbar( - onExecute: _running ? null : _execute, - running: _running, - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => - showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && - !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: - widget.connectionRow.databaseName, - sqlController: _sqlController, - ); - } - : null, - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, - ), - ), - ], + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFractionState = (_topFractionState + dy / totalHeight) - .clamp(0.2, 0.85); - }); - }, - ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - material.Container( - height: 44, - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, - ), - ), - ], + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - ), - ], + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), ), ), ); @@ -423,29 +402,3 @@ class _MysqlSqlToolbar extends material.StatelessWidget { ); } } - -class _HorizontalResizeHandle extends material.StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onVerticalDragUpdate: (e) => onDrag(e.delta.dy), - child: material.Container( - height: 6, - color: theme.border.withValues(alpha: 0.15), - ), - ), - ); - } -} diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index f870a4c7..f4c5fd1d 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; 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/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/core/theme/querya_theme_scope.dart'; @@ -54,7 +55,7 @@ class PostgresSqlWorkspace extends material.StatefulWidget { class _PostgresSqlWorkspaceState extends material.State { final _sqlController = material.TextEditingController(); - double _topFractionState = 0.65; + final ValueNotifier _topFraction = ValueNotifier(0.65); PgLease? _lease; @@ -92,7 +93,7 @@ class _PostgresSqlWorkspaceState extends material.State { _appSettingsListener = () { unawaited(_loadWorkspaceSettings()); }; - AppSettingsRevision.listenable.addListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { _syncPostgresSqlTreeContext(); unawaited(_loadWorkspaceSettings()); @@ -243,7 +244,8 @@ class _PostgresSqlWorkspaceState extends material.State { @override void dispose() { - AppSettingsRevision.listenable.removeListener(_appSettingsListener); + SqlWorkspaceSettingsRevision.listenable.removeListener(_appSettingsListener); + _topFraction.dispose(); if (_running) { PostgresService.instance.interrupt( widget.connectionRow, @@ -366,12 +368,9 @@ class _PostgresSqlWorkspaceState extends material.State { @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); - final topFlex = (_topFractionState * 100).round().clamp(20, 80); - final bottomFlex = 100 - topFlex; return material.LayoutBuilder( builder: (context, constraints) { - final totalHeight = constraints.maxHeight; return material.CallbackShortcuts( bindings: { const material.SingleActivator(LogicalKeyboardKey.f5): () { @@ -380,98 +379,76 @@ class _PostgresSqlWorkspaceState extends material.State { }, child: material.Focus( autofocus: true, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: topFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _SqlToolbar( - sessionDatabase: _effectiveSessionDatabase(), - onExecute: _running ? null : _execute, - running: _running, - autocommit: _autocommit, - onAutocommitChanged: (v) => - setState(() => _autocommit = v), - queryTimeoutSeconds: _queryTimeoutSeconds, - onQueryTimeoutChanged: _onStmtTimeoutChanged, - onOpenPreferences: () => - showPreferencesDialog(context), - onOpenHistory: widget.connectionRow.id != null && - !_running - ? () { - showSqlQueryHistoryDialog( - context: context, - connectionId: widget.connectionRow.id!, - databaseName: _effectiveSessionDatabase(), - sqlController: _sqlController, - ); - } - : null, - txOpen: _txOpen, - onBegin: _running - ? null - : () => _runTxCommand('BEGIN'), - onCommit: _running - ? null - : () => _runTxCommand('COMMIT'), - onRollback: _running - ? null - : () => _runTxCommand('ROLLBACK'), - ), - const Divider(height: 1), - Expanded( - child: QueryEditorTab( - controller: _sqlController, - fontSize: _editorFontSize, - ), - ), - ], + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SqlToolbar( + sessionDatabase: _effectiveSessionDatabase(), + onExecute: _running ? null : _execute, + running: _running, + autocommit: _autocommit, + onAutocommitChanged: (v) => + setState(() => _autocommit = v), + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + onOpenPreferences: () => showPreferencesDialog(context), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: _effectiveSessionDatabase(), + sqlController: _sqlController, + ); + } + : null, + txOpen: _txOpen, + onBegin: + _running ? null : () => _runTxCommand('BEGIN'), + onCommit: + _running ? null : () => _runTxCommand('COMMIT'), + onRollback: + _running ? null : () => _runTxCommand('ROLLBACK'), ), - ), - _HorizontalResizeHandle( - totalHeight: totalHeight, - onDrag: (dy) { - if (totalHeight <= 0) return; - setState(() { - _topFractionState = (_topFractionState + dy / totalHeight) - .clamp(0.2, 0.85); - }); - }, - ), - Expanded( - flex: bottomFlex, - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Container( - height: 44, - padding: const material.EdgeInsets.symmetric( - horizontal: 12, - ), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), - Expanded( - child: ResultsTab( - columns: _columns, - rows: _rows, - errorMessage: _error, - isLoading: _running, - affectedRows: _affectedRows, - statusLine: _statusLine, - ), - ), - ], + const Divider(height: 1), + Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), ), - ), - ], + ], + ), + bottom: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), ), ), ); @@ -623,29 +600,3 @@ class _SqlToolbar extends material.StatelessWidget { ); } } - -class _HorizontalResizeHandle extends material.StatelessWidget { - const _HorizontalResizeHandle({ - required this.totalHeight, - required this.onDrag, - }); - - final double totalHeight; - final void Function(double dy) onDrag; - - @override - material.Widget build(material.BuildContext context) { - final theme = Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: material.SystemMouseCursors.resizeRow, - child: material.GestureDetector( - behavior: material.HitTestBehavior.opaque, - onVerticalDragUpdate: (e) => onDrag(e.delta.dy), - child: material.Container( - height: 6, - color: theme.border.withValues(alpha: 0.15), - ), - ), - ); - } -} diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 06119d65..e89f990f 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows PostgreSQL connection form dialog. @@ -47,26 +48,26 @@ class _PostgresConnectionFormContentState bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); - _databaseController.addListener(_onFieldChanged); - _usernameController.addListener(_onFieldChanged); - _connectionStringController.addListener(_onFieldChanged); - } - - void _onFieldChanged() => setState(() {}); - - bool _looksLikePostgresUri(String s) { - final t = s.trim().toLowerCase(); - return t.startsWith('postgres://') || t.startsWith('postgresql://'); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - bool get _formValid { + bool _computeFormValid() { final uri = _connectionStringController.text.trim(); if (uri.isNotEmpty) { return _looksLikePostgresUri(uri); @@ -76,6 +77,11 @@ class _PostgresConnectionFormContentState return host.isNotEmpty && db.isNotEmpty; } + bool _looksLikePostgresUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('postgres://') || t.startsWith('postgresql://'); + } + void _showTestResult(String result) { _dismissTimer?.cancel(); setState(() { @@ -94,7 +100,7 @@ class _PostgresConnectionFormContentState } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -130,7 +136,7 @@ class _PostgresConnectionFormContentState } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 5432; @@ -163,12 +169,17 @@ class _PostgresConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); - _databaseController.removeListener(_onFieldChanged); - _usernameController.removeListener(_onFieldChanged); - _connectionStringController.removeListener(_onFieldChanged); + for (final c in [ + _nameController, + _hostController, + _portController, + _databaseController, + _usernameController, + _connectionStringController, + ]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -443,54 +454,61 @@ class _PostgresConnectionFormContentState material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 24, vertical: 16), - child: material.Wrap( - spacing: 12, - runSpacing: 8, - alignment: material.WrapAlignment.spaceBetween, - children: [ - OutlineButton( - onPressed: - _formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formValid + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid ? theme.primary : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: - _formValid ? theme.primary : theme.mutedForeground, - ), - ), - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), + ), ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], ), ], - ), - ], + ); + }, ), ), ], diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index e2dbb48e..2e4cceca 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows Redis connection form dialog. Returns ConnectionRow if saved, null if cancelled. @@ -41,20 +42,22 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo bool _isTesting = false; String? _testResult; Timer? _dismissTimer; + late final FormValidityNotifier _formValidNotifier; @override void initState() { super.initState(); - _nameController.addListener(_onFieldChanged); - _hostController.addListener(_onFieldChanged); - _portController.addListener(_onFieldChanged); + _formValidNotifier = FormValidityNotifier(_computeFormValid); + for (final c in [_nameController, _hostController, _portController]) { + _formValidNotifier.listenTo(c); + } + _formValidNotifier.seed(); } - void _onFieldChanged() => setState(() {}); - - bool get _formValid { + bool _computeFormValid() { final host = _hostController.text.trim(); - return host.isNotEmpty && (_nameController.text.trim().isNotEmpty || host.isNotEmpty); + return host.isNotEmpty && + (_nameController.text.trim().isNotEmpty || host.isNotEmpty); } void _showTestResult(String result) { @@ -75,7 +78,7 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo } Future _testConnection() async { - if (!_formValid) return; + if (!_formValidNotifier.value) return; _dismissTimer?.cancel(); _dismissTimer = null; setState(() { @@ -99,7 +102,7 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo } void _save() { - if (!_formValid) return; + if (!_formValidNotifier.value) return; final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 6379; @@ -120,9 +123,10 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo @override void dispose() { _dismissTimer?.cancel(); - _nameController.removeListener(_onFieldChanged); - _hostController.removeListener(_onFieldChanged); - _portController.removeListener(_onFieldChanged); + for (final c in [_nameController, _hostController, _portController]) { + _formValidNotifier.unlistenFrom(c); + } + _formValidNotifier.dispose(); _nameController.dispose(); _hostController.dispose(); _portController.dispose(); @@ -327,43 +331,53 @@ class _RedisConnectionFormContentState extends material.State<_RedisConnectionFo ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: material.Row( - children: [ - OutlineButton( - onPressed: _formValid && !_isTesting ? _testConnection : null, - leading: _isTesting - ? material.SizedBox( - width: 18, - height: 18, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: theme.primary, - ), - ) - : material.Icon( - material.Icons.link_rounded, - size: 18, - color: _formValid ? theme.primary : theme.mutedForeground, + child: ValueListenableBuilder( + valueListenable: _formValidNotifier.listenable, + builder: (context, formValid, _) { + return material.Row( + children: [ + OutlineButton( + onPressed: + formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: formValid + ? theme.primary + : theme.mutedForeground, ), - child: Text( - 'Test Connection', - style: material.TextStyle( - fontWeight: material.FontWeight.w500, - color: _formValid ? theme.primary : theme.mutedForeground, + ), ), - ), - ), - const material.Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), - PrimaryButton( - onPressed: _formValid ? _save : null, - child: const Text('Save'), - ), - ], + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: formValid ? _save : null, + child: const Text('Save'), + ), + ], + ); + }, ), ), ], diff --git a/lib/shared/widgets/form_validity_notifier.dart b/lib/shared/widgets/form_validity_notifier.dart new file mode 100644 index 00000000..24d2297e --- /dev/null +++ b/lib/shared/widgets/form_validity_notifier.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; + +/// Notifies when a derived form-valid flag changes (avoids full-form [setState]). +class FormValidityNotifier { + FormValidityNotifier(this._compute); + + final bool Function() _compute; + final ValueNotifier listenable = ValueNotifier(false); + + bool get value => listenable.value; + + void listenTo(material.TextEditingController controller) { + controller.addListener(_onChanged); + } + + void unlistenFrom(material.TextEditingController controller) { + controller.removeListener(_onChanged); + } + + void _onChanged() { + final next = _compute(); + if (next != listenable.value) { + listenable.value = next; + } + } + + void seed() => _onChanged(); + + void dispose() { + listenable.dispose(); + } +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index cb0d5f6a..62e6b05d 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -256,16 +256,30 @@ void main() { expect(AppSettingsRevision.listenable.value, start + 1); }); - test('mutating AppSettings notifies listenable', () async { + test('mutating SQL workspace settings notifies SqlWorkspaceSettingsRevision', + () async { var calls = 0; void listener() => calls++; - AppSettingsRevision.listenable.addListener(listener); - final before = AppSettingsRevision.listenable.value; + SqlWorkspaceSettingsRevision.listenable.addListener(listener); + final before = SqlWorkspaceSettingsRevision.listenable.value; await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(45); - expect(AppSettingsRevision.listenable.value, greaterThan(before)); + expect(SqlWorkspaceSettingsRevision.listenable.value, greaterThan(before)); expect(calls, greaterThan(0)); - AppSettingsRevision.listenable.removeListener(listener); + SqlWorkspaceSettingsRevision.listenable.removeListener(listener); + }); + + test('mutating theme settings does not notify SqlWorkspaceSettingsRevision', + () async { + var sqlCalls = 0; + void listener() => sqlCalls++; + + SqlWorkspaceSettingsRevision.listenable.addListener(listener); + final before = SqlWorkspaceSettingsRevision.listenable.value; + await AppSettings.instance.setThemeMode(ThemeMode.light); + expect(SqlWorkspaceSettingsRevision.listenable.value, before); + expect(sqlCalls, 0); + SqlWorkspaceSettingsRevision.listenable.removeListener(listener); }); }); } From d52290e31cb10745d289a9cfdcd78435360a3b1f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:06 +0300 Subject: [PATCH 16/32] chore(docker): add local compose stack for dev databases Provide postgres, mysql, mongo, and redis with seed data for manual testing. --- docker/.env.example | 19 ++++ docker/docker-compose.yml | 142 ++++++++++++++++++++++++++ docker/mongo/init/01_seed.js | 83 +++++++++++++++ docker/mysql/init/01_shop.sql | 87 ++++++++++++++++ docker/postgres/init/01_shop.sql | 77 ++++++++++++++ docker/postgres/init/02_analytics.sql | 17 +++ docker/redis/seed.sh | 30 ++++++ 7 files changed, 455 insertions(+) create mode 100644 docker/.env.example create mode 100644 docker/docker-compose.yml create mode 100644 docker/mongo/init/01_seed.js create mode 100644 docker/mysql/init/01_shop.sql create mode 100644 docker/postgres/init/01_shop.sql create mode 100644 docker/postgres/init/02_analytics.sql create mode 100755 docker/redis/seed.sh diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..d3e34c34 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,19 @@ +# Copy to .env and adjust if host ports conflict with local services. +# cp .env.example .env + +POSTGRES_PORT=5432 +MYSQL_PORT=3306 +REDIS_PORT=6379 +MONGO_PORT=27017 + +POSTGRES_USER=querya +POSTGRES_PASSWORD=querya +POSTGRES_DB=querya + +MYSQL_ROOT_PASSWORD=querya +MYSQL_DATABASE=querya +MYSQL_USER=querya +MYSQL_PASSWORD=querya + +MONGO_INITDB_ROOT_USERNAME=querya +MONGO_INITDB_ROOT_PASSWORD=querya diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..fd0ad3d1 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,142 @@ +# Querya Desktop — local database stack for manual testing. +# +# Start: +# cd docker && docker compose up -d +# +# Stop and remove volumes (re-run init scripts on next up): +# docker compose down -v +# +# ── Connection cheat sheet (host: localhost) ───────────────────────────── +# PostgreSQL port 5432 db querya user querya password querya +# schemas: shop.* + database analytics +# MySQL port 3306 db querya user querya password querya +# extra database: analytics +# Redis port 6379 no auth keys prefix querya:* +# MongoDB port 27017 db querya user querya password querya +# auth source: admin collections: users, products, orders +# ───────────────────────────────────────────────────────────────────────── + +name: querya-dev + +services: + postgres: + image: postgres:16-alpine + container_name: querya-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-querya} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-querya} + POSTGRES_DB: ${POSTGRES_DB:-querya} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-querya} -d ${POSTGRES_DB:-querya}", + ] + interval: 5s + timeout: 5s + retries: 12 + + mysql: + image: mysql:8.4 + container_name: querya-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-querya} + MYSQL_DATABASE: ${MYSQL_DATABASE:-querya} + MYSQL_USER: ${MYSQL_USER:-querya} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-querya} + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql/init:/docker-entrypoint-initdb.d:ro + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + healthcheck: + test: + [ + "CMD", + "mysqladmin", + "ping", + "-h", + "127.0.0.1", + "-u${MYSQL_USER:-querya}", + "-p${MYSQL_PASSWORD:-querya}", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: querya-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + + redis-seed: + image: redis:7-alpine + container_name: querya-redis-seed + depends_on: + redis: + condition: service_healthy + environment: + REDIS_HOST: redis + REDIS_PORT: "6379" + volumes: + - ./redis/seed.sh:/seed.sh:ro + entrypoint: ["/bin/sh", "/seed.sh"] + restart: "no" + + mongo: + image: mongo:7 + container_name: querya-mongo + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME:-querya} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-querya} + ports: + - "${MONGO_PORT:-27017}:27017" + volumes: + - mongo_data:/data/db + - ./mongo/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + [ + "CMD", + "mongosh", + "--quiet", + "-u", + "${MONGO_INITDB_ROOT_USERNAME:-querya}", + "-p", + "${MONGO_INITDB_ROOT_PASSWORD:-querya}", + "--authenticationDatabase", + "admin", + "--eval", + "db.adminCommand('ping').ok", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 30s + +volumes: + postgres_data: + mysql_data: + redis_data: + mongo_data: diff --git a/docker/mongo/init/01_seed.js b/docker/mongo/init/01_seed.js new file mode 100644 index 00000000..b7dd34a8 --- /dev/null +++ b/docker/mongo/init/01_seed.js @@ -0,0 +1,83 @@ +// Demo data for Querya manual testing (MongoDB). +const appDb = db.getSiblingDB('querya'); + +appDb.users.drop(); +appDb.products.drop(); +appDb.orders.drop(); + +appDb.users.insertMany([ + { + name: 'Alice Martin', + email: 'alice@example.com', + role: 'admin', + city: 'Berlin', + tags: ['staff', 'beta'], + active: true, + }, + { + name: 'Bob Smith', + email: 'bob@example.com', + role: 'customer', + city: 'London', + tags: ['beta'], + active: true, + }, + { + name: 'Carla Ruiz', + email: 'carla@example.com', + role: 'customer', + city: 'Madrid', + tags: [], + active: false, + }, +]); + +appDb.products.insertMany([ + { sku: 'SKU-001', title: 'Wireless Mouse', price: 29.99, stock: 120 }, + { sku: 'SKU-002', title: 'Mechanical Keyboard', price: 89.0, stock: 45 }, + { sku: 'SKU-003', title: 'USB-C Hub', price: 45.5, stock: 80 }, + { sku: 'SKU-004', title: '27" Monitor', price: 329.0, stock: 15 }, +]); + +appDb.orders.insertMany([ + { + customerEmail: 'alice@example.com', + status: 'paid', + total: 164.49, + placedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + lines: [ + { sku: 'SKU-001', qty: 1, unitPrice: 29.99 }, + { sku: 'SKU-003', qty: 1, unitPrice: 45.5 }, + { sku: 'SKU-002', qty: 1, unitPrice: 89.0 }, + ], + }, + { + customerEmail: 'bob@example.com', + status: 'shipped', + total: 404.0, + placedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + lines: [{ sku: 'SKU-004', qty: 1, unitPrice: 329.0 }], + }, + { + customerEmail: 'carla@example.com', + status: 'new', + total: 29.99, + placedAt: new Date(), + lines: [{ sku: 'SKU-001', qty: 1, unitPrice: 29.99 }], + }, +]); + +appDb.users.createIndex({ email: 1 }, { unique: true }); +appDb.products.createIndex({ sku: 1 }, { unique: true }); +appDb.orders.createIndex({ status: 1, placedAt: -1 }); + +const analyticsDb = db.getSiblingDB('analytics'); +analyticsDb.metrics.drop(); +analyticsDb.metrics.insertMany([ + { day: new Date(), orders: 4, revenue: 203.99 }, + { + day: new Date(Date.now() - 24 * 60 * 60 * 1000), + orders: 9, + revenue: 615.0, + }, +]); diff --git a/docker/mysql/init/01_shop.sql b/docker/mysql/init/01_shop.sql new file mode 100644 index 00000000..3cd98457 --- /dev/null +++ b/docker/mysql/init/01_shop.sql @@ -0,0 +1,87 @@ +-- Demo schema for Querya manual testing (MySQL / MariaDB-compatible). +USE querya; + +CREATE TABLE customers ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + city VARCHAR(80), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE products ( + id INT AUTO_INCREMENT PRIMARY KEY, + sku VARCHAR(32) NOT NULL UNIQUE, + title VARCHAR(160) NOT NULL, + price DECIMAL(10, 2) NOT NULL +) ENGINE=InnoDB; + +CREATE TABLE orders ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'new', + total DECIMAL(10, 2) NOT NULL DEFAULT 0, + placed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) +) ENGINE=InnoDB; + +CREATE TABLE order_lines ( + order_id INT NOT NULL, + product_id INT NOT NULL, + qty INT NOT NULL, + unit_price DECIMAL(10, 2) NOT NULL, + PRIMARY KEY (order_id, product_id), + CONSTRAINT fk_lines_order FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + CONSTRAINT fk_lines_product FOREIGN KEY (product_id) REFERENCES products (id) +) ENGINE=InnoDB; + +INSERT INTO customers (name, email, city) VALUES + ('Alice Martin', 'alice@example.com', 'Berlin'), + ('Bob Smith', 'bob@example.com', 'London'), + ('Carla Ruiz', 'carla@example.com', 'Madrid'); + +INSERT INTO products (sku, title, price) VALUES + ('SKU-001', 'Wireless Mouse', 29.99), + ('SKU-002', 'Mechanical Keyboard', 89.00), + ('SKU-003', 'USB-C Hub', 45.50), + ('SKU-004', '27 inch Monitor', 329.00); + +INSERT INTO orders (customer_id, status, total, placed_at) VALUES + (1, 'paid', 164.49, NOW() - INTERVAL 2 DAY), + (2, 'shipped', 404.49, NOW() - INTERVAL 1 DAY), + (3, 'new', 29.99, NOW()); + +INSERT INTO order_lines (order_id, product_id, qty, unit_price) VALUES + (1, 1, 1, 29.99), + (1, 3, 1, 45.50), + (1, 2, 1, 89.00), + (2, 4, 1, 329.00), + (2, 1, 1, 29.99), + (2, 3, 1, 45.50), + (3, 1, 1, 29.99); + +CREATE VIEW customer_spending AS +SELECT + c.id, + c.name, + c.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS lifetime_total +FROM customers c +LEFT JOIN orders o ON o.customer_id = c.id +GROUP BY c.id, c.name, c.city; + +CREATE DATABASE IF NOT EXISTS analytics; + +USE analytics; + +CREATE TABLE daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + revenue DECIMAL(12, 2) NOT NULL +) ENGINE=InnoDB; + +INSERT INTO daily_sales (day, orders, revenue) VALUES + (CURDATE() - INTERVAL 2 DAY, 12, 842.50), + (CURDATE() - INTERVAL 1 DAY, 9, 615.00), + (CURDATE(), 4, 203.99); diff --git a/docker/postgres/init/01_shop.sql b/docker/postgres/init/01_shop.sql new file mode 100644 index 00000000..bc540a53 --- /dev/null +++ b/docker/postgres/init/01_shop.sql @@ -0,0 +1,77 @@ +-- Demo schema for Querya manual testing (PostgreSQL). +CREATE SCHEMA IF NOT EXISTS shop; + +CREATE TABLE shop.customers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + city TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE shop.products ( + id SERIAL PRIMARY KEY, + sku TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + price NUMERIC(10, 2) NOT NULL CHECK (price >= 0) +); + +CREATE TABLE shop.orders ( + id SERIAL PRIMARY KEY, + customer_id INT NOT NULL REFERENCES shop.customers (id), + status TEXT NOT NULL DEFAULT 'new', + total NUMERIC(10, 2) NOT NULL DEFAULT 0, + placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE shop.order_lines ( + order_id INT NOT NULL REFERENCES shop.orders (id) ON DELETE CASCADE, + product_id INT NOT NULL REFERENCES shop.products (id), + qty INT NOT NULL CHECK (qty > 0), + unit_price NUMERIC(10, 2) NOT NULL, + PRIMARY KEY (order_id, product_id) +); + +INSERT INTO shop.customers (name, email, city) VALUES + ('Alice Martin', 'alice@example.com', 'Berlin'), + ('Bob Smith', 'bob@example.com', 'London'), + ('Carla Ruiz', 'carla@example.com', 'Madrid'); + +INSERT INTO shop.products (sku, title, price) VALUES + ('SKU-001', 'Wireless Mouse', 29.99), + ('SKU-002', 'Mechanical Keyboard', 89.00), + ('SKU-003', 'USB-C Hub', 45.50), + ('SKU-004', '27" Monitor', 329.00); + +INSERT INTO shop.orders (customer_id, status, total, placed_at) VALUES + (1, 'paid', 164.49, NOW() - INTERVAL '2 days'), + (2, 'shipped', 404.49, NOW() - INTERVAL '1 day'), + (3, 'new', 29.99, NOW()); + +INSERT INTO shop.order_lines (order_id, product_id, qty, unit_price) VALUES + (1, 1, 1, 29.99), + (1, 3, 1, 45.50), + (1, 2, 1, 89.00), + (2, 4, 1, 329.00), + (2, 1, 1, 29.99), + (2, 3, 1, 45.50), + (3, 1, 1, 29.99); + +CREATE OR REPLACE VIEW shop.customer_spending AS +SELECT + c.id, + c.name, + c.city, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS lifetime_total +FROM shop.customers c +LEFT JOIN shop.orders o ON o.customer_id = c.id +GROUP BY c.id, c.name, c.city; + +CREATE OR REPLACE FUNCTION shop.order_count_for_customer(p_customer_id INT) +RETURNS INT +LANGUAGE sql +STABLE +AS $$ + SELECT COUNT(*)::INT FROM shop.orders WHERE customer_id = p_customer_id; +$$; diff --git a/docker/postgres/init/02_analytics.sql b/docker/postgres/init/02_analytics.sql new file mode 100644 index 00000000..9aa868f1 --- /dev/null +++ b/docker/postgres/init/02_analytics.sql @@ -0,0 +1,17 @@ +-- Second database to exercise PostgreSQL tree / database switching. +CREATE DATABASE analytics; + +\connect analytics + +CREATE SCHEMA metrics; + +CREATE TABLE metrics.daily_sales ( + day DATE PRIMARY KEY, + orders INT NOT NULL, + revenue NUMERIC(12, 2) NOT NULL +); + +INSERT INTO metrics.daily_sales (day, orders, revenue) VALUES + (CURRENT_DATE - 2, 12, 842.50), + (CURRENT_DATE - 1, 9, 615.00), + (CURRENT_DATE, 4, 203.99); diff --git a/docker/redis/seed.sh b/docker/redis/seed.sh new file mode 100755 index 00000000..0066be9c --- /dev/null +++ b/docker/redis/seed.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +HOST="${REDIS_HOST:-redis}" +PORT="${REDIS_PORT:-6379}" + +echo "Waiting for Redis at ${HOST}:${PORT}..." +until redis-cli -h "$HOST" -p "$PORT" ping | grep -q PONG; do + sleep 1 +done + +if redis-cli -h "$HOST" -p "$PORT" EXISTS querya:seed:marker | grep -q 1; then + echo "Redis seed marker present — skipping." + exit 0 +fi + +echo "Seeding Redis demo keys..." + +redis-cli -h "$HOST" -p "$PORT" <<'EOF' +SET querya:demo:string "Hello from Querya Docker stack" +SET querya:config:version "1" +HSET querya:user:1 name "Alice Martin" email "alice@example.com" city "Berlin" +HSET querya:user:2 name "Bob Smith" email "bob@example.com" city "London" +RPUSH querya:tasks:open "Review PR" "Write docs" "Test Redis key editor" +SADD querya:tags:popular redis docker mongodb postgresql mysql +ZADD querya:leaderboard 980 "player_alpha" 875 "player_beta" 640 "player_gamma" +SET querya:seed:marker "1" +EOF + +echo "Redis seed complete." From c4f954811eaad31570e812e0376a771126fba0e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:12 +0300 Subject: [PATCH 17/32] feat(core): add deep collection equality helper Shared snapshot diffing for stats views to skip setState when polled data is unchanged. --- lib/core/util/deep_collection_equals.dart | 27 ++++++++++ .../util/deep_collection_equals_test.dart | 52 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 lib/core/util/deep_collection_equals.dart create mode 100644 test/core/util/deep_collection_equals_test.dart diff --git a/lib/core/util/deep_collection_equals.dart b/lib/core/util/deep_collection_equals.dart new file mode 100644 index 00000000..99f7a273 --- /dev/null +++ b/lib/core/util/deep_collection_equals.dart @@ -0,0 +1,27 @@ +/// Deep equality for JSON-like trees (maps, lists, primitives). +bool deepCollectionEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key)) return false; + if (!deepCollectionEquals(a[key], b[key])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!deepCollectionEquals(a[i], b[i])) return false; + } + return true; + } + return a == b; +} + +/// Updates [current] when [next] differs; returns true if changed. +bool replaceIfChanged(T? current, T? next, void Function(T? value) apply) { + if (deepCollectionEquals(current, next)) return false; + apply(next); + return true; +} diff --git a/test/core/util/deep_collection_equals_test.dart b/test/core/util/deep_collection_equals_test.dart new file mode 100644 index 00000000..69938137 --- /dev/null +++ b/test/core/util/deep_collection_equals_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; + +void main() { + group('deepCollectionEquals', () { + test('compares nested maps and lists', () { + const a = { + 'x': 1, + 'y': [1, 2, {'z': 'ok'}], + }; + const b = { + 'x': 1, + 'y': [1, 2, {'z': 'ok'}], + }; + const c = { + 'x': 1, + 'y': [1, 2, {'z': 'nope'}], + }; + expect(deepCollectionEquals(a, b), isTrue); + expect(deepCollectionEquals(a, c), isFalse); + }); + + test('replaceIfChanged skips identical snapshots', () { + var value = {'a': 1}; + var applyCount = 0; + expect( + replaceIfChanged(value, {'a': 1}, (v) { + applyCount++; + value = v!; + }), + isFalse, + ); + expect(applyCount, 0); + }); + }); + + group('convertMysqlResultRowsToStrings', () { + test('null cells become NULL', () { + final out = convertMysqlResultRowsToStrings( + const MysqlResultConvertJob( + rowValues: [ + [1, null, 'x'], + ], + ), + ); + expect(out, [ + ['1', 'NULL', 'x'], + ]); + }); + }); +} From bbdabf64281e268dd88c6684e09d0680942f6415 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:13 +0300 Subject: [PATCH 18/32] perf(ui): skip postgres stats rebuild on unchanged polls Use replaceIfChanged and hoist the version RegExp to avoid redundant rebuild work. --- lib/features/postgresql/postgres_stats_view.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index 4c8483ae..31c253ba 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -10,6 +11,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 5); const _summaryChipHeight = 88.0; const _gridCardMinHeight = 220.0; +final _pgVersionPattern = RegExp(r'PostgreSQL\s+([\d.]+)'); class PostgresStatsView extends material.StatefulWidget { const PostgresStatsView({ @@ -105,8 +107,8 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; setState(() { - _stats = stats; _loading = false; }); } catch (e) { @@ -127,7 +129,8 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; - setState(() => _stats = stats); + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); } catch (_) {} }); } @@ -553,7 +556,7 @@ class _PostgresStatsViewState extends material.State { } String _extractPgVersion(String full) { - final match = RegExp(r'PostgreSQL\s+([\d.]+)').firstMatch(full); + final match = _pgVersionPattern.firstMatch(full); return match?.group(1) ?? full; } From fe8997ff27e4a5e0a2cc0da80c1b8ee5b5942d98 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:13 +0300 Subject: [PATCH 19/32] perf(editor): cache syntax highlighter pairs by theme key Reuse HighlighterPair instances instead of rebuilding on every createPair call. --- lib/core/editor/syntax_highlight_service.dart | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/core/editor/syntax_highlight_service.dart b/lib/core/editor/syntax_highlight_service.dart index 14a20118..62a3b2a9 100644 --- a/lib/core/editor/syntax_highlight_service.dart +++ b/lib/core/editor/syntax_highlight_service.dart @@ -59,6 +59,29 @@ abstract final class SyntaxHighlightService { static HighlighterPair createPair({ required QueryaCodeLanguage language, required QueryaTheme queryaTheme, + }) { + final cacheKey = Object.hash( + language, + queryaTheme.editor, + Object.hashAll(queryaTheme.tokenColors), + ); + final cached = _pairCache[cacheKey]; + if (cached != null) return cached; + + final pair = _buildPair(language: language, queryaTheme: queryaTheme); + if (_pairCache.length >= _maxPairCacheEntries) { + _pairCache.remove(_pairCache.keys.first); + } + _pairCache[cacheKey] = pair; + return pair; + } + + static const _maxPairCacheEntries = 12; + static final Map _pairCache = {}; + + static HighlighterPair _buildPair({ + required QueryaCodeLanguage language, + required QueryaTheme queryaTheme, }) { final tokenColors = queryaTheme.tokenColors; return HighlighterPair( From 90a355dfb7de206ac47f67a44603d6d8abd313a1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:13 +0300 Subject: [PATCH 20/32] perf(ui): hoist SQL history preview whitespace RegExp Avoid allocating a RegExp on every preview line render. --- lib/features/main_screen/sql_query_history_dialog.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/main_screen/sql_query_history_dialog.dart b/lib/features/main_screen/sql_query_history_dialog.dart index bf5a1d98..2a8aa146 100644 --- a/lib/features/main_screen/sql_query_history_dialog.dart +++ b/lib/features/main_screen/sql_query_history_dialog.dart @@ -69,8 +69,10 @@ class _SqlQueryHistoryDialogContentState }); } + static final _whitespacePattern = RegExp(r'\s+'); + static String _previewOneLine(String sql) { - final collapsed = sql.replaceAll(RegExp(r'\s+'), ' ').trim(); + final collapsed = sql.replaceAll(_whitespacePattern, ' ').trim(); if (collapsed.length <= 96) return collapsed; return '${collapsed.substring(0, 93)}…'; } From 2d3d22f83dfde217beda2fc8f1a880453c24efe6 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:19 +0300 Subject: [PATCH 21/32] perf(mysql): format SQL result rows in a worker isolate Move cell string conversion off the UI thread for large result sets. --- lib/features/mysql/mysql_result_utils.dart | 19 +++++++++++++++++++ lib/features/mysql/mysql_sql_workspace.dart | 16 ++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 lib/features/mysql/mysql_result_utils.dart diff --git a/lib/features/mysql/mysql_result_utils.dart b/lib/features/mysql/mysql_result_utils.dart new file mode 100644 index 00000000..98e71dee --- /dev/null +++ b/lib/features/mysql/mysql_result_utils.dart @@ -0,0 +1,19 @@ +/// Serializable row batch for [convertMysqlResultRowsToStrings] in a worker isolate. +class MysqlResultConvertJob { + const MysqlResultConvertJob({ + required this.rowValues, + }); + + final List> rowValues; +} + +/// Converts MySQL result cell values to display strings off the UI thread. +List> convertMysqlResultRowsToStrings(MysqlResultConvertJob job) { + return job.rowValues + .map( + (row) => row + .map((value) => value == null ? 'NULL' : value.toString()) + .toList(), + ) + .toList(); +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index b98b2187..580c93b6 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/mysql_service.dart'; @@ -13,6 +14,7 @@ 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'; import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Ad-hoc SQL editor + results for MySQL / MariaDB. @@ -154,20 +156,22 @@ class _MysqlSqlWorkspaceState extends material.State { cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - final outRows = >[]; + final rawRows = >[]; var n = 0; final cap = _resultMaxRows; for (final row in rs.rows) { if (n >= cap) break; - outRows.add( - List.generate( - row.numOfColumns, - (i) => row.colAt(i) ?? 'NULL', - ), + rawRows.add( + List.generate(row.numOfColumns, (i) => row.colAt(i)), ); n++; } + final outRows = await compute( + convertMysqlResultRowsToStrings, + MysqlResultConvertJob(rowValues: rawRows), + ); + int? affected; if (cols.isEmpty && outRows.isEmpty) { affected = _affectedInt(rs.affectedRows); From bdc71f068b0cfb3bf119bc5136e54562a8bf469c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:20 +0300 Subject: [PATCH 22/32] perf(mongo): lazy JSON cache and stable document card actions Avoid hover setState and defer pretty JSON encoding until expand. --- .../mongodb/mongo_documents_view.dart | 193 +++++++++--------- 1 file changed, 96 insertions(+), 97 deletions(-) diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index e1d1a3f2..a5b98262 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultLimit = 25; +const _prettyJsonEncoder = JsonEncoder.withIndent(' '); /// Paginated document browser for a MongoDB collection. class MongoDocumentsView extends material.StatefulWidget { @@ -393,92 +394,111 @@ class _DocumentCard extends StatefulWidget { } class _DocumentCardState extends State<_DocumentCard> { - bool _hovered = false; bool _expanded = false; + late String _keysPreviewText; + String? _prettyJsonCache; + + @override + void initState() { + super.initState(); + _keysPreviewText = _computeKeysPreview(widget.document); + } + + @override + void didUpdateWidget(covariant _DocumentCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.document, widget.document)) { + _keysPreviewText = _computeKeysPreview(widget.document); + _prettyJsonCache = null; + } + } + + void _toggleExpanded() { + setState(() { + _expanded = !_expanded; + if (_expanded && _prettyJsonCache == null) { + _prettyJsonCache = _encodePrettyJson(widget.document); + } + }); + } @override Widget build(BuildContext context) { final cs = widget.colorScheme; final scs = widget.shadcnCs; final idStr = widget.document['_id']?.toString() ?? '—'; - final keysPreview = _keysPreview(widget.document); - - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - decoration: material.BoxDecoration( - color: _hovered - ? scs.muted.withValues(alpha: 0.15) - : cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), width: 1), - ), + + return material.Container( + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3), width: 1), + ), + clipBehavior: material.Clip.antiAlias, + child: material.Material( + color: cs.card, child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - // Header row - material.InkWell( - onTap: widget.onView, - borderRadius: material.BorderRadius.circular(8), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 16, vertical: 10), - child: Row( - children: [ - material.Icon(material.Icons.description_rounded, - size: 16, color: scs.mutedForeground), - const Gap(8), - Text( - idStr, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500), - ), - const Spacer(), - // Expand toggle - material.InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon( - _expanded - ? material.Icons.expand_less_rounded - : material.Icons.expand_more_rounded, - size: 18, - color: scs.mutedForeground, + material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: widget.onView, + hoverColor: scs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + child: Row( + children: [ + material.Icon(material.Icons.description_rounded, + size: 16, color: scs.mutedForeground), + const Gap(8), + Text( + idStr, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500), + ), + const Spacer(), + material.InkWell( + onTap: _toggleExpanded, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + _expanded + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: 18, + color: scs.mutedForeground, + ), ), ), - ), - const Gap(8), - // View - _SmallActionButton( - icon: material.Icons.edit_rounded, - color: const Color(0xFF42A5F5), - onTap: widget.onView, - ), - const Gap(4), - // Delete - _SmallActionButton( - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDelete, - ), - ], + const Gap(8), + _SmallActionButton( + icon: material.Icons.edit_rounded, + color: const Color(0xFF42A5F5), + onTap: widget.onView, + ), + const Gap(4), + _SmallActionButton( + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: widget.onDelete, + ), + ], + ), ), ), ), - // Preview / expanded JSON material.Padding( padding: const material.EdgeInsets.only( left: 16, right: 16, bottom: 10), child: _expanded ? material.SelectableText( - _prettyJson(widget.document), + _prettyJsonCache ?? '', style: material.TextStyle( fontSize: 12, fontFamily: 'monospace', @@ -486,7 +506,7 @@ class _DocumentCardState extends State<_DocumentCard> { ), ) : Text( - keysPreview, + _keysPreviewText, overflow: TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( @@ -502,16 +522,15 @@ class _DocumentCardState extends State<_DocumentCard> { ); } - /// Returns a compact list of top-level keys (excluding _id). - String _keysPreview(Map doc) { + static String _computeKeysPreview(Map doc) { final keys = doc.keys.where((k) => k != '_id').toList(); if (keys.isEmpty) return '{ }'; return keys.join(', '); } - String _prettyJson(Map doc) { + static String _encodePrettyJson(Map doc) { try { - return const JsonEncoder.withIndent(' ').convert(doc); + return _prettyJsonEncoder.convert(doc); } catch (_) { return doc.toString(); } @@ -520,7 +539,7 @@ class _DocumentCardState extends State<_DocumentCard> { // ─── Small icon-only action button ────────────────────────────────────────── -class _SmallActionButton extends StatefulWidget { +class _SmallActionButton extends StatelessWidget { const _SmallActionButton({ required this.icon, required this.color, @@ -531,34 +550,14 @@ class _SmallActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_SmallActionButton> createState() => _SmallActionButtonState(); -} - -class _SmallActionButtonState extends State<_SmallActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.InkWell( - onTap: widget.onTap, - borderRadius: material.BorderRadius.circular(4), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: const material.EdgeInsets.all(5), - decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.15) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: material.Icon(widget.icon, size: 15, color: widget.color), - ), - ), + return material.IconButton( + onPressed: onTap, + icon: material.Icon(icon, size: 15, color: color), + padding: const material.EdgeInsets.all(5), + constraints: const material.BoxConstraints(minWidth: 28, minHeight: 28), + splashRadius: 18, ); } } From 7a5a7f80b2aadc4d5b520fe234faab06d397d2bf Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:20 +0300 Subject: [PATCH 23/32] fix(ui): constrain QueryaDropdown label when width is fixed Ellipsize trigger text inside bounded width to prevent horizontal overflow. --- lib/shared/widgets/querya_dropdown.dart | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/shared/widgets/querya_dropdown.dart b/lib/shared/widgets/querya_dropdown.dart index 3480ec79..cfcd5860 100644 --- a/lib/shared/widgets/querya_dropdown.dart +++ b/lib/shared/widgets/querya_dropdown.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; @@ -56,6 +57,9 @@ class QueryaDropdown extends material.StatefulWidget { class _QueryaDropdownState extends material.State> { late material.MenuController _controller; bool _triggerHovered = false; + List? _cachedMenuChildren; + List>? _cachedMenuItems; + T? _cachedMenuValue; @override void initState() { @@ -69,6 +73,23 @@ class _QueryaDropdownState extends material.State> { if (widget.controller != oldWidget.controller) { _controller = widget.controller ?? material.MenuController(); } + if (!listEquals(oldWidget.items, widget.items) || + oldWidget.value != widget.value) { + _cachedMenuChildren = null; + } + } + + List _menuChildren(ColorScheme cs) { + if (_cachedMenuChildren != null && + listEquals(_cachedMenuItems, widget.items) && + _cachedMenuValue == widget.value) { + return _cachedMenuChildren!; + } + _cachedMenuItems = List>.from(widget.items); + _cachedMenuValue = widget.value; + _cachedMenuChildren = + widget.items.map((item) => _menuItem(item, cs)).toList(); + return _cachedMenuChildren!; } material.Widget _triggerLabelText({ @@ -148,7 +169,7 @@ class _QueryaDropdownState extends material.State> { ), child: material.Row( mainAxisAlignment: material.MainAxisAlignment.spaceBetween, - mainAxisSize: widget.expandToParent + mainAxisSize: (widget.expandToParent || fieldWidth != null) ? material.MainAxisSize.max : material.MainAxisSize.min, children: [ @@ -156,7 +177,7 @@ class _QueryaDropdownState extends material.State> { context: context, label: label, cs: cs, - expand: widget.expandToParent, + expand: widget.expandToParent || fieldWidth != null, ), material.SizedBox(width: chevronGap), material.Icon( @@ -198,7 +219,7 @@ class _QueryaDropdownState extends material.State> { final fieldWidth = widget.expandToParent ? null : (widget.width != null ? context.scaled(widget.width!) : null); - final menuChildren = widget.items.map((item) => _menuItem(item, cs)).toList(); + final menuChildren = _menuChildren(cs); final scaledMaxHeight = context.scaled(widget.menuMaxHeight); final effectiveMaxHeight = widget.items.length > QueryaDropdownTokens.menuScrollItemThreshold From 293ac7c6bd0fb6a04aa2f8f6fa29607aaca8db8f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:20 +0300 Subject: [PATCH 24/32] perf(redis): virtualize key member lists and fix TTL dialog lifecycle Use separated list builders for hash/list/set/zset rows and dispose TTL controller on close. --- lib/features/redis/redis_key_editor.dart | 219 +++++++++++++++-------- 1 file changed, 142 insertions(+), 77 deletions(-) diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index a3d9acef..5987b07d 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -312,9 +312,13 @@ class _RedisKeyEditorState extends material.State { const Divider(height: 1), // Content material.Expanded( - child: material.SingleChildScrollView( + child: material.Padding( padding: const material.EdgeInsets.all(16), - child: _buildContent(cs, shadcnCs), + child: widget.keyType == 'string' + ? material.SingleChildScrollView( + child: _buildContent(cs, shadcnCs), + ) + : _buildContent(cs, shadcnCs), ), ), ], @@ -499,7 +503,6 @@ class _RedisKeyEditorState extends material.State { children: [ Text('Hash fields (${entries.length})').semiBold(), const Gap(8), - // Add field row material.Row( children: [ material.Expanded( @@ -531,16 +534,24 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final entry in entries) ...[ - _FieldRow( - field: entry.key, - value: entry.value, - onDelete: () => _hashDel(entry.key), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: entries.isEmpty + ? material.Center(child: const Text('No fields').muted()) + : material.ListView.separated( + itemCount: entries.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) { + final entry = entries[index]; + return _FieldRow( + field: entry.key, + value: entry.value, + onDelete: () => _hashDel(entry.key), + colorScheme: cs, + shadcnCs: scs, + ); + }, + ), + ), ], ); } @@ -576,15 +587,20 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (var i = 0; i < _listValue.length; i++) ...[ - _IndexedValueRow( - index: i, - value: _listValue[i], - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _listValue.isEmpty + ? material.Center(child: const Text('No items').muted()) + : material.ListView.separated( + itemCount: _listValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, i) => _IndexedValueRow( + index: i, + value: _listValue[i], + colorScheme: cs, + shadcnCs: scs, + ), + ), + ), ], ); } @@ -620,15 +636,20 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final member in _setValue) ...[ - _MemberRow( - member: member, - onDelete: () => _setRemove(member), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _setValue.isEmpty + ? material.Center(child: const Text('No members').muted()) + : material.ListView.separated( + itemCount: _setValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) => _MemberRow( + member: _setValue[index], + onDelete: () => _setRemove(_setValue[index]), + colorScheme: cs, + shadcnCs: scs, + ), + ), + ), ], ); } @@ -675,16 +696,24 @@ class _RedisKeyEditorState extends material.State { ], ), const Gap(12), - for (final (member, score) in _zsetValue) ...[ - _ScoredMemberRow( - member: member, - score: score, - onDelete: () => _zsetRemove(member), - colorScheme: cs, - shadcnCs: scs, - ), - const Gap(4), - ], + material.Expanded( + child: _zsetValue.isEmpty + ? material.Center(child: const Text('No members').muted()) + : material.ListView.separated( + itemCount: _zsetValue.length, + separatorBuilder: (_, __) => const Gap(4), + itemBuilder: (context, index) { + final (member, score) = _zsetValue[index]; + return _ScoredMemberRow( + member: member, + score: score, + onDelete: () => _zsetRemove(member), + colorScheme: cs, + shadcnCs: scs, + ); + }, + ), + ), ], ); } @@ -692,43 +721,12 @@ class _RedisKeyEditorState extends material.State { // ─── TTL dialog ───────────────────────────────────────────────────────── void _showTtlDialog() { - final controller = - material.TextEditingController(text: _ttl > 0 ? '$_ttl' : ''); - showAppDialog( + showAppDialog( context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Set TTL'), - content: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Enter TTL in seconds (0 to remove)').muted().small(), - const Gap(8), - TextField( - controller: controller, - placeholder: const Text('Seconds'), - ), - ], - ), - actions: [ - GhostButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - PrimaryButton( - onPressed: () { - final val = int.tryParse(controller.text.trim()); - if (val != null) { - _setTtl(val); - } - Navigator.of(ctx).pop(); - }, - child: const Text('Apply'), - ), - ], - ); - }, + builder: (ctx) => _RedisTtlDialogContent( + initialTtl: _ttl, + onApply: _setTtl, + ), ); } @@ -769,6 +767,73 @@ class _RedisKeyEditorState extends material.State { } } +class _RedisTtlDialogContent extends material.StatefulWidget { + const _RedisTtlDialogContent({ + required this.initialTtl, + required this.onApply, + }); + + final int initialTtl; + final Future Function(int seconds) onApply; + + @override + material.State<_RedisTtlDialogContent> createState() => + _RedisTtlDialogContentState(); +} + +class _RedisTtlDialogContentState extends material.State<_RedisTtlDialogContent> { + late final material.TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController( + text: widget.initialTtl > 0 ? '${widget.initialTtl}' : '', + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + return AlertDialog( + title: const Text('Set TTL'), + content: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Enter TTL in seconds (0 to remove)').muted().small(), + const Gap(8), + TextField( + controller: _controller, + placeholder: const Text('Seconds'), + ), + ], + ), + actions: [ + GhostButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + onPressed: () { + final val = int.tryParse(_controller.text.trim()); + if (val != null) { + widget.onApply(val); + } + Navigator.of(context).pop(); + }, + child: const Text('Apply'), + ), + ], + ); + } +} + // ─── Shared row widgets ───────────────────────────────────────────────────── class _FieldRow extends StatelessWidget { From fc50a959796ad808e8282c74ce34138c6ff754a0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:24 +0300 Subject: [PATCH 25/32] feat(mysql): add serverStats for global status and database sizes Query SHOW GLOBAL STATUS/VARIABLES and information_schema for the stats dashboard. --- lib/core/database/mysql_connection.dart | 72 +++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 68657f16..ce6ee2ba 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -330,4 +330,76 @@ class MysqlConnection { final rs = await execute('SELECT VERSION()'); return rs.rows.first.colAt(0) ?? ''; } + + /// Key metrics for the MySQL stats dashboard (`SHOW GLOBAL STATUS` / `VARIABLES`). + Future> serverStats() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final stats = {}; + + stats['version'] = await serverVersion(); + + final status = await _showKeyValueRows( + "SHOW GLOBAL STATUS WHERE Variable_name IN (" + "'Uptime','Threads_connected','Threads_running','Max_used_connections'," + "'Questions','Slow_queries','Bytes_received','Bytes_sent','Connections'," + "'Open_tables','Opened_tables','Aborted_connects'" + ')', + ); + stats['status'] = status; + stats['uptime_seconds'] = int.tryParse(status['Uptime'] ?? '') ?? 0; + + stats['variables'] = await _showKeyValueRows( + "SHOW GLOBAL VARIABLES WHERE Variable_name IN (" + "'max_connections','port','datadir','character_set_server'," + "'collation_server','innodb_buffer_pool_size','version'" + ')', + ); + + const systemSchemas = { + 'information_schema', + 'mysql', + 'performance_schema', + 'sys', + }; + final dbRs = await execute( + 'SELECT table_schema, ' + 'COALESCE(SUM(data_length + index_length), 0) AS size_bytes, ' + 'COUNT(*) AS table_count ' + 'FROM information_schema.tables ' + "WHERE table_schema NOT IN ('information_schema','mysql'," + "'performance_schema','sys') " + 'GROUP BY table_schema ' + 'ORDER BY table_schema', + ); + final databases = >[]; + for (final row in dbRs.rows) { + final name = row.colAt(0); + if (name == null || systemSchemas.contains(name.toLowerCase())) { + continue; + } + databases.add({ + 'name': name, + 'size': int.tryParse(row.colAt(1) ?? '') ?? 0, + 'tables': int.tryParse(row.colAt(2) ?? '') ?? 0, + }); + } + stats['databases'] = databases; + + return stats; + } + + Future> _showKeyValueRows(String sql) async { + final rs = await execute(sql); + final out = {}; + for (final row in rs.rows) { + final key = row.colAt(0); + final value = row.colAt(1); + if (key != null && value != null) { + out[key] = value; + } + } + return out; + } } From 731fbb9b3e546822fae6636915e72531bf390ec8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:25 +0300 Subject: [PATCH 26/32] feat(mysql): add full server stats dashboard Replace the version-only stub with polled metrics cards and a databases table. --- lib/features/mysql/mysql_stats_view.dart | 532 ++++++++++++++++++++--- 1 file changed, 472 insertions(+), 60 deletions(-) diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index b04729b8..5617644a 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -1,9 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 5); +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; +final _mysqlVersionPattern = RegExp(r'(\d+\.\d+(?:\.\d+)?)'); -/// Summary when a MySQL connection is selected without a tree object. +/// Server dashboard when a MySQL connection is selected without a tree object. class MysqlStatsView extends material.StatefulWidget { const MysqlStatsView({ super.key, @@ -18,10 +27,10 @@ class MysqlStatsView extends material.StatefulWidget { class _MysqlStatsViewState extends material.State { MysqlLease? _lease; - String? _version; - int? _databaseCount; + Map? _stats; bool _loading = true; String? _error; + Timer? _timer; @override void initState() { @@ -33,6 +42,7 @@ class _MysqlStatsViewState extends material.State { void didUpdateWidget(covariant MysqlStatsView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); _disconnect(); _load(); } @@ -40,6 +50,7 @@ class _MysqlStatsViewState extends material.State { @override void dispose() { + _timer?.cancel(); _disconnect(); super.dispose(); } @@ -50,13 +61,13 @@ class _MysqlStatsViewState extends material.State { } Future _load() async { + _timer?.cancel(); _disconnect(); if (!mounted) return; setState(() { _loading = true; _error = null; - _version = null; - _databaseCount = null; + _stats = null; }); try { final lease = await MysqlService.instance.acquire( @@ -69,14 +80,25 @@ class _MysqlStatsViewState extends material.State { return; } _lease = lease; - final v = await lease.connection.serverVersion(); - final dbs = await lease.connection.listDatabases(); + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { if (!mounted) return; setState(() { - _version = v; - _databaseCount = dbs.length; + _error = e.toString(); _loading = false; }); + } + } + + Future _fetch() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() => _loading = false); } catch (e) { if (!mounted) return; setState(() { @@ -86,32 +108,44 @@ class _MysqlStatsViewState extends material.State { } } + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) return; + try { + final stats = await conn.serverStats(); + if (!mounted) return; + if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; + setState(() {}); + } catch (_) {} + }); + } + @override material.Widget build(material.BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = Theme.of(context).colorScheme; + final width = material.MediaQuery.sizeOf(context).width; if (_loading) { return material.Center( child: material.Column( mainAxisSize: material.MainAxisSize.min, children: [ - material.SizedBox( - width: 28, - height: 28, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), ), - const Gap(12), - const Text('Loading server info...').muted().small(), + const Gap(16), + const Text('Connecting...').muted().small(), ], ), ); } - if (_error != null) { + final err = _error; + if (err != null) { return material.Center( child: material.Padding( padding: const material.EdgeInsets.all(32), @@ -124,22 +158,16 @@ class _MysqlStatsViewState extends material.State { color: cs.destructive, ), const Gap(16), - const Text('Could not load server info').large().semiBold(), + const Text('Connection Error').large().semiBold(), const Gap(8), material.SelectableText( - _error!, - style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 13, - ), + err, + style: material.TextStyle(color: cs.mutedForeground, fontSize: 13), ), const Gap(24), OutlineButton( onPressed: _load, - leading: const material.Icon( - material.Icons.refresh_rounded, - size: 18, - ), + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), ), ], @@ -148,49 +176,433 @@ class _MysqlStatsViewState extends material.State { ); } - return material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, + final stats = _stats; + if (stats == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, stats), + const Gap(24), + _gridRow( + _connectionsCard(context, stats), + _queriesCard(context, stats), + ), + const Gap(16), + _gridRow( + _networkCard(context, stats), + _settingsCard(context, stats), + ), + const Gap(24), + _databasesCard(context, stats), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.SizedBox( + width: 28, + height: 28, + child: material.Image.asset( + 'assets/images/mysql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.storage_rounded, + size: 28, + color: cs.primary, + ), + ), + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text( + '${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 3306}', + ).muted().small(), + ], + ), + ), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - const Text('Server').large().semiBold(), + material.Expanded(child: left), const Gap(16), - material.Container( - width: double.infinity, - padding: const material.EdgeInsets.all(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + + String _status(Map stats, String key) => + (stats['status'] as Map?)?[key] ?? '—'; + + String _variable(Map stats, String key) => + (stats['variables'] as Map?)?[key] ?? '—'; + + material.Widget _summaryChips( + material.BuildContext context, Map stats) { + final cs = shadcn.Theme.of(context).colorScheme; + final versionFull = stats['version'] as String? ?? '—'; + final versionShort = _extractMysqlVersion(versionFull); + final uptimeSec = stats['uptime_seconds'] as int? ?? 0; + final connected = _status(stats, 'Threads_connected'); + final maxConn = _variable(stats, 'max_connections'); + final questions = _status(stats, 'Questions'); + + material.Widget chip(String label, String value, material.IconData icon) { + return material.Expanded( + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.35), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), - child: material.Column( + child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - const Text('Version').small().muted(), - const Gap(4), - material.SelectableText( - _version ?? '—', - style: material.TextStyle( - fontSize: 13, - color: cs.foreground, - ), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), ), - const Gap(16), - const Text('User databases (approx.)').small().muted(), - const Gap(4), - Text( - '${_databaseCount ?? 0}', - style: material.TextStyle( - fontSize: 20, - fontWeight: material.FontWeight.w600, - color: cs.foreground, + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ], ), ), ], ), ), + ), + ); + } + + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + chip('Version', versionShort, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', _formatUptime(uptimeSec), material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$connected / $maxConn', material.Icons.people_outline_rounded), + const Gap(12), + chip('Queries', questions, material.Icons.speed_rounded), + ], + ); + } + + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(title).semiBold(), + const Gap(12), + if (stretchBody) material.Expanded(child: body) else body, + ], + ), + ); + } + + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + + material.Widget _connectionsCard( + material.BuildContext context, Map stats) { + return _card( + context, + 'Connections', + _metricList( + context, + [ + _metricRow(context, 'Connected', _status(stats, 'Threads_connected')), + _metricRow(context, 'Running', _status(stats, 'Threads_running')), + _metricRow(context, 'Max used', _status(stats, 'Max_used_connections')), + _metricRow(context, 'Max allowed', _variable(stats, 'max_connections')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _queriesCard( + material.BuildContext context, Map stats) { + return _card( + context, + 'Queries', + _metricList( + context, + [ + _metricRow(context, 'Questions', _status(stats, 'Questions')), + _metricRow(context, 'Slow queries', _status(stats, 'Slow_queries')), + _metricRow(context, 'Open tables', _status(stats, 'Open_tables')), + _metricRow(context, 'Aborted connects', _status(stats, 'Aborted_connects')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _networkCard( + material.BuildContext context, Map stats) { + final bytesIn = int.tryParse(_status(stats, 'Bytes_received')) ?? 0; + final bytesOut = int.tryParse(_status(stats, 'Bytes_sent')) ?? 0; + return _card( + context, + 'Network', + _metricList( + context, + [ + _metricRow(context, 'Bytes in', _formatBytes(bytesIn)), + _metricRow(context, 'Bytes out', _formatBytes(bytesOut)), + _metricRow(context, 'Total connects', _status(stats, 'Connections')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _settingsCard( + material.BuildContext context, Map stats) { + final pool = int.tryParse(_variable(stats, 'innodb_buffer_pool_size')) ?? 0; + return _card( + context, + 'Server', + _metricList( + context, + [ + _metricRow(context, 'InnoDB buffer pool', _formatBytes(pool)), + _metricRow(context, 'Charset', _variable(stats, 'character_set_server')), + _metricRow(context, 'Collation', _variable(stats, 'collation_server')), + _metricRow(context, 'Port', _variable(stats, 'port')), + ], + stretch: true, + ), + minHeight: _gridCardMinHeight, + stretchBody: true, + ); + } + + material.Widget _databasesCard( + material.BuildContext context, Map stats) { + final databases = + stats['databases'] as List>? ?? []; + if (databases.isEmpty) return const material.SizedBox.shrink(); + + final cs = shadcn.Theme.of(context).colorScheme; + return _card( + context, + 'Databases', + material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(bottom: 8), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: const Text('Name').muted().xSmall(), + ), + material.SizedBox( + width: 100, + child: const Text('Size').muted().xSmall(), + ), + material.Expanded( + child: const Text('Tables').muted().xSmall(), + ), + ], + ), + ), + material.Divider(height: 1, color: cs.border.withValues(alpha: 0.3)), + for (final db in databases) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 5), + child: material.Row( + children: [ + material.SizedBox( + width: 180, + child: material.Text( + '${db['name']}', + style: material.TextStyle(fontSize: 13, color: cs.foreground), + overflow: material.TextOverflow.ellipsis, + ), + ), + material.SizedBox( + width: 100, + child: Text(_formatBytes((db['size'] as int?) ?? 0)) + .muted() + .xSmall(), + ), + material.Expanded( + child: Text('${db['tables'] ?? 0}').muted().xSmall(), + ), + ], + ), + ), + ], + ), + ); + } + + material.Widget _metricRow( + material.BuildContext context, String label, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 6), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); } + + String _extractMysqlVersion(String full) { + final match = _mysqlVersionPattern.firstMatch(full); + return match?.group(1) ?? full; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } } From 482956eaa2662d70084d5cb7a0283b9a78a2569c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:26 +0300 Subject: [PATCH 27/32] fix(mongo): redesign stats dashboard and skip unchanged polls Align card grid layout with other engines and fix header/dropdown overflow. --- lib/features/mongodb/mongo_stats_view.dart | 448 ++++++++++++++------- 1 file changed, 300 insertions(+), 148 deletions(-) diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 4b7be2e6..d8418cbf 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -8,8 +9,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _defaultAutoRefresh = Duration(seconds: 3); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; class MongoStatsView extends material.StatefulWidget { const MongoStatsView({ @@ -117,10 +118,12 @@ class _MongoStatsViewState extends material.State { {'serverStatus': 1}, ); if (!mounted) return; + final changed = + replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v); + if (!changed && !_loading) return; setState(() { - _serverStatus = status; _loading = false; - _lastFetchedAt = DateTime.now(); + if (changed) _lastFetchedAt = DateTime.now(); }); } catch (e) { if (mounted) { @@ -157,10 +160,10 @@ class _MongoStatsViewState extends material.State { {'serverStatus': 1}, ); if (!mounted) return; - setState(() { - _serverStatus = status; - _lastFetchedAt = DateTime.now(); - }); + if (!replaceIfChanged(_serverStatus, status, (v) => _serverStatus = v)) { + return; + } + setState(() => _lastFetchedAt = DateTime.now()); } catch (_) { // Keep last good snapshot on transient errors during auto-refresh. } @@ -245,29 +248,47 @@ class _MongoStatsViewState extends material.State { const Gap(24), _summaryChips(context, status), const Gap(24), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _memoryCard(context, status)), - const Gap(16), - material.Expanded(child: _operationsCard(context, status)), - ], + _gridRow( + _memoryCard(context, status), + _operationsCard(context, status), ), const Gap(16), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _connectionsCard(context, status)), - const Gap(16), - material.Expanded(child: _networkCard(context, status)), - ], + _gridRow( + _connectionsCard(context, status), + _networkCard(context, status), ), const Gap(24), _sectionCard(context, 'Server', _extractServerInfo(status)), const Gap(12), - _sectionCard(context, 'Storage', _extractStorageInfo(status)), - const Gap(12), - _sectionCard(context, 'Replication', _extractReplicationInfo(status)), + material.LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 900; + final storage = _extractStorageInfo(status); + final replication = _extractReplicationInfo(status); + if (!wide) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _sectionCard(context, 'Storage', storage), + const Gap(12), + _sectionCard(context, 'Replication', replication), + ], + ); + } + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _sectionCard(context, 'Storage', storage), + ), + const Gap(16), + material.Expanded( + child: _sectionCard(context, 'Replication', replication), + ), + ], + ); + }, + ), const Gap(12), _sectionCard(context, 'WiredTiger', _extractWiredTigerInfo(status)), ], @@ -281,90 +302,124 @@ class _MongoStatsViewState extends material.State { material.Widget _header(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; final last = _lastFetchedAt; - return material.Wrap( - crossAxisAlignment: material.WrapCrossAlignment.center, - spacing: 8, - runSpacing: 10, + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Container( - padding: const material.EdgeInsets.all(10), - decoration: material.BoxDecoration( - color: cs.primary.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(12), - ), - child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), - ), - material.ConstrainedBox( - constraints: const material.BoxConstraints(minWidth: 160, maxWidth: 400), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - Text(widget.connectionRow.name).large().semiBold(), - const Gap(4), - Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') - .muted() - .small(), - if (last != null) ...[ - const Gap(4), - Text('Last updated ${_formatClock(last)}').muted().xSmall(), - ], - ], - ), - ), - if (widget.onBack != null) - OutlineButton( - onPressed: widget.onBack, - leading: const material.Icon( - material.Icons.grid_view_rounded, - size: 18), - child: const Text('Explorer'), - ), - OutlineButton( - onPressed: _manualRefreshing ? null : _refreshNow, - leading: _manualRefreshing - ? const material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator(strokeWidth: 2), - ) - : const material.Icon(material.Icons.refresh_rounded, size: 18), - child: const Text('Refresh now'), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') + .muted() + .small(), + if (last != null) ...[ + const Gap(4), + Text('Last updated ${_formatClock(last)}').muted().xSmall(), + ], + ], + ), + ), + ], ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 4), - child: QueryaDropdown( - width: 132, - value: _autoRefreshInterval, - items: const [ - QueryaDropdownItem(value: null, label: 'Auto: off'), - QueryaDropdownItem(value: Duration(seconds: 3), label: 'Auto: 3 s'), - QueryaDropdownItem(value: Duration(seconds: 10), label: 'Auto: 10 s'), - QueryaDropdownItem(value: Duration(seconds: 30), label: 'Auto: 30 s'), - QueryaDropdownItem(value: Duration(seconds: 60), label: 'Auto: 60 s'), - ], - onSelected: (value) { - setState(() => _autoRefreshInterval = value); - _startTimer(); - }, - ), + const Gap(12), + material.Wrap( + spacing: 8, + runSpacing: 8, + alignment: material.WrapAlignment.end, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + if (widget.onBack != null) + OutlineButton( + onPressed: widget.onBack, + leading: const material.Icon( + material.Icons.grid_view_rounded, + size: 18), + child: const Text('Explorer'), + ), + OutlineButton( + onPressed: _manualRefreshing ? null : _refreshNow, + leading: _manualRefreshing + ? const material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + : const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh now'), + ), + material.SizedBox( + width: 132, + child: QueryaDropdown( + expandToParent: true, + value: _autoRefreshInterval, + items: const [ + QueryaDropdownItem(value: null, label: 'Auto: off'), + QueryaDropdownItem(value: Duration(seconds: 3), label: 'Auto: 3 s'), + QueryaDropdownItem(value: Duration(seconds: 10), label: 'Auto: 10 s'), + QueryaDropdownItem(value: Duration(seconds: 30), label: 'Auto: 30 s'), + QueryaDropdownItem(value: Duration(seconds: 60), label: 'Auto: 60 s'), + ], + onSelected: (value) { + setState(() => _autoRefreshInterval = value); + _startTimer(); + }, + ), + ), + ], ), ], ); } + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded(child: left), + const Gap(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(int seconds) { + if (seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + material.Widget _summaryChips(material.BuildContext context, Map status) { final cs = shadcn.Theme.of(context).colorScheme; final version = _getString(status, 'version') ?? '—'; - final uptime = _getInt(status, 'uptime') ?? 0; - final uptimeDays = (uptime / 86400).toStringAsFixed(1); + final uptime = _formatUptime(_getInt(status, 'uptime') ?? 0); final connections = _getNestedInt(status, 'connections', 'current') ?? 0; - final maxConnections = _getNestedInt(status, 'connections', 'available') ?? 0; + final available = _getNestedInt(status, 'connections', 'available') ?? 0; final ops = _getNestedInt(status, 'opcounters', 'query') ?? 0; material.Widget chip(String label, String value, material.IconData icon) { return material.Expanded( - child: material.SizedBox( - height: _summaryChipHeight, + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), child: material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: material.BoxDecoration( @@ -373,8 +428,12 @@ class _MongoStatsViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.Icon(icon, size: 20, color: cs.primary), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), + ), const Gap(12), material.Expanded( child: material.Column( @@ -384,7 +443,17 @@ class _MongoStatsViewState extends material.State { children: [ Text(label).muted().xSmall(), const Gap(2), - Text(value).semiBold().small(), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), ], ), ), @@ -395,23 +464,32 @@ class _MongoStatsViewState extends material.State { ); } return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ chip('Version', version, material.Icons.tag_rounded), const Gap(12), - chip('Uptime', '$uptimeDays days', material.Icons.schedule_rounded), + chip('Uptime', uptime, material.Icons.schedule_rounded), const Gap(12), - chip('Connections', '$connections / $maxConnections', material.Icons.people_outline_rounded), + chip('Connections', '$connections / $available', material.Icons.people_outline_rounded), const Gap(12), chip('Queries', '$ops', material.Icons.speed_rounded), ], ); } - material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { final cs = shadcn.Theme.of(context).colorScheme; return material.Container( width: double.infinity, - height: height, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), padding: const material.EdgeInsets.all(20), decoration: material.BoxDecoration( color: cs.card, @@ -419,17 +497,35 @@ class _MongoStatsViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.start, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(title).semiBold(), const Gap(12), - body, + if (stretchBody) material.Expanded(child: body) else body, ], ), ); } + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + material.Widget _memoryCard(material.BuildContext context, Map status) { final mem = status['mem'] as Map?; final resident = _getInt(mem, 'resident') ?? 0; @@ -439,17 +535,18 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Memory', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Resident', _formatBytes(resident)), - _row(context, 'Virtual', _formatBytes(virtual)), - _row(context, 'Mapped', _formatBytes(mapped)), - _row(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), + _metricList( + context, + [ + _metricRow(context, 'Resident', _formatBytes(resident)), + _metricRow(context, 'Virtual', _formatBytes(virtual)), + _metricRow(context, 'Mapped', _formatBytes(mapped)), + _metricRow(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -462,17 +559,18 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Operations', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Inserts', '$inserts'), - _row(context, 'Queries', '$queries'), - _row(context, 'Updates', '$updates'), - _row(context, 'Deletes', '$deletes'), + _metricList( + context, + [ + _metricRow(context, 'Inserts', '$inserts'), + _metricRow(context, 'Queries', '$queries'), + _metricRow(context, 'Updates', '$updates'), + _metricRow(context, 'Deletes', '$deletes'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -484,16 +582,17 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Connections', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Current', '$current'), - _row(context, 'Available', '$available'), - _row(context, 'Active clients', '$active'), + _metricList( + context, + [ + _metricRow(context, 'Current', '$current'), + _metricRow(context, 'Available', '$available'), + _metricRow(context, 'Active clients', '$active'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -505,16 +604,17 @@ class _MongoStatsViewState extends material.State { return _card( context, 'Network', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Bytes in', _formatBytes(bytesIn)), - _row(context, 'Bytes out', _formatBytes(bytesOut)), - _row(context, 'Requests', '$numRequests'), + _metricList( + context, + [ + _metricRow(context, 'Bytes in', _formatBytes(bytesIn)), + _metricRow(context, 'Bytes out', _formatBytes(bytesOut)), + _metricRow(context, 'Requests', '$numRequests'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -523,23 +623,76 @@ class _MongoStatsViewState extends material.State { return _card( context, title, - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in data.entries) _row(context, e.key, e.value)], + _twoColumnMetrics( + context, + data.entries.map((e) => MapEntry(e.key, e.value)).toList(), ), ); } - material.Widget _row(material.BuildContext context, String key, String value) { + material.Widget _twoColumnMetrics( + material.BuildContext context, + List> entries, + ) { + if (entries.length <= 4) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in entries) _metricRow(context, e.key, e.value)], + ); + } + final mid = (entries.length / 2).ceil(); + final left = entries.sublist(0, mid); + final right = entries.sublist(mid); + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in left) _metricRow(context, e.key, e.value)], + ), + ), + const Gap(24), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in right) _metricRow(context, e.key, e.value)], + ), + ), + ], + ); + } + + material.Widget _metricRow(material.BuildContext context, String label, String value) { final cs = shadcn.Theme.of(context).colorScheme; return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), + padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.SizedBox(width: 160, child: Text(key).muted().small()), - material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); @@ -582,8 +735,7 @@ class _MongoStatsViewState extends material.State { if (status['process'] != null) result['Process'] = status['process'].toString(); final uptime = _getInt(status, 'uptime'); if (uptime != null) { - final days = (uptime / 86400).toStringAsFixed(1); - result['Uptime'] = '$days days ($uptime seconds)'; + result['Uptime'] = '${_formatUptime(uptime)} ($uptime s)'; } return result; } From 4c1b4c31297c232be1f22a6a7fc2ba88fbe68026 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:01:26 +0300 Subject: [PATCH 28/32] fix(redis): redesign stats dashboard and skip unchanged polls Use equal-height metric cards, human-readable labels, and replaceIfChanged polling. --- lib/features/redis/redis_view.dart | 385 ++++++++++++++++++++++------- 1 file changed, 293 insertions(+), 92 deletions(-) diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index 2d6023a4..1e100dbd 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; @@ -9,8 +10,30 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; const _pollInterval = Duration(seconds: 3); -const _summaryChipHeight = 72.0; -const _gridCardHeight = 220.0; +const _summaryChipHeight = 88.0; +const _gridCardMinHeight = 220.0; + +const _redisFieldLabels = { + 'redis_version': 'Version', + 'redis_mode': 'Mode', + 'os': 'OS', + 'tcp_port': 'Port', + 'uptime_in_days': 'Uptime (days)', + 'config_file': 'Config file', + 'connected_clients': 'Connected', + 'blocked_clients': 'Blocked', + 'maxclients': 'Max clients', + 'client_recent_max_input_buffer': 'Max input buffer', + 'client_recent_max_output_buffer': 'Max output buffer', + 'rdb_bgsave_in_progress': 'BGSAVE in progress', + 'rdb_last_save_time': 'Last RDB save', + 'rdb_last_bgsave_status': 'Last BGSAVE status', + 'aof_enabled': 'AOF enabled', + 'aof_last_rewrite_time_sec': 'Last AOF rewrite', + 'role': 'Role', + 'connected_slaves': 'Connected replicas', + 'master_repl_offset': 'Repl offset', +}; class RedisView extends material.StatefulWidget { const RedisView({ @@ -119,10 +142,9 @@ class _RedisViewState extends material.State { final raw = await c.info(); final info = parseRedisInfo(raw); if (!mounted) return; - setState(() { - _info = info; - _loading = false; - }); + final changed = replaceIfChanged(_info, info, (v) => _info = v); + if (!changed && !_loading) return; + setState(() => _loading = false); } void _startTimer() { @@ -134,7 +156,8 @@ class _RedisViewState extends material.State { final raw = await c.info(); final info = parseRedisInfo(raw); if (!mounted) return; - setState(() => _info = info); + if (!replaceIfChanged(_info, info, (v) => _info = v)) return; + setState(() {}); } catch (_) {} }); } @@ -206,40 +229,57 @@ class _RedisViewState extends material.State { const Gap(24), _summaryChips(context, info), const Gap(24), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _memoryCard(context, info)), - const Gap(16), - material.Expanded(child: _statsCard(context, info)), - ], + _gridRow( + _memoryCard(context, info), + _statsCard(context, info), ), const Gap(16), - material.Row( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Expanded(child: _keyspaceCard(context, info)), - const Gap(16), - material.Expanded(child: _cpuCard(context, info)), - ], + _gridRow( + _cpuCard(context, info), + _keyspaceCard(context, info), ), const Gap(24), _sectionCard(context, 'Server', info['Server'], keys: const [ 'redis_version', 'redis_mode', 'os', 'tcp_port', 'uptime_in_days', 'config_file', ]), const Gap(12), - _sectionCard(context, 'Clients', info['Clients']), - const Gap(12), - _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ - 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', - 'aof_enabled', 'aof_last_rewrite_time_sec', - ]), + material.LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 900; + if (!wide) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _sectionCard(context, 'Clients', info['Clients']), + const Gap(12), + _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ + 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', + 'aof_enabled', 'aof_last_rewrite_time_sec', + ]), + ], + ); + } + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: _sectionCard(context, 'Clients', info['Clients']), + ), + const Gap(16), + material.Expanded( + child: _sectionCard(context, 'Persistence', info['Persistence'], keys: const [ + 'rdb_bgsave_in_progress', 'rdb_last_save_time', 'rdb_last_bgsave_status', + 'aof_enabled', 'aof_last_rewrite_time_sec', + ]), + ), + ], + ); + }, + ), const Gap(12), _sectionCard(context, 'Replication', info['Replication']), const Gap(12), _errorStatsCard(context, info), - const Gap(12), - _sectionCard(context, 'Keyspace', info['Keyspace']), ], ), ), @@ -292,17 +332,43 @@ class _RedisViewState extends material.State { ); } + material.Widget _gridRow(material.Widget left, material.Widget right) { + return material.IntrinsicHeight( + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Expanded(child: left), + const Gap(16), + material.Expanded(child: right), + ], + ), + ); + } + + String _formatUptime(RedisInfoSections info) { + final seconds = sectionInt(info, 'Server', 'uptime_in_seconds'); + if (seconds == null || seconds <= 0) return '0m'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + if (days > 0) return '${days}d ${hours}h'; + if (hours > 0) return '${hours}h ${minutes}m'; + return '${minutes}m'; + } + + String _labelFor(String key) => _redisFieldLabels[key] ?? key.replaceAll('_', ' '); + material.Widget _summaryChips(material.BuildContext context, RedisInfoSections info) { final cs = shadcn.Theme.of(context).colorScheme; final version = sectionValue(info, 'Server', 'redis_version') ?? '—'; - final uptime = sectionInt(info, 'Server', 'uptime_in_days') ?? 0; + final uptime = _formatUptime(info); final clients = sectionInt(info, 'Clients', 'connected_clients') ?? 0; final maxClients = sectionInt(info, 'Clients', 'maxclients') ?? 0; final ops = sectionInt(info, 'Stats', 'instantaneous_ops_per_sec') ?? 0; material.Widget chip(String label, String value, material.IconData icon) { return material.Expanded( - child: material.SizedBox( - height: _summaryChipHeight, + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(minHeight: _summaryChipHeight), child: material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: material.BoxDecoration( @@ -311,8 +377,12 @@ class _RedisViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), ), child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.Icon(icon, size: 20, color: cs.primary), + material.Padding( + padding: const material.EdgeInsets.only(top: 2), + child: material.Icon(icon, size: 20, color: cs.primary), + ), const Gap(12), material.Expanded( child: material.Column( @@ -322,7 +392,17 @@ class _RedisViewState extends material.State { children: [ Text(label).muted().xSmall(), const Gap(2), - Text(value).semiBold().small(), + material.Text( + value, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), ], ), ), @@ -333,10 +413,11 @@ class _RedisViewState extends material.State { ); } return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, children: [ chip('Version', version, material.Icons.tag_rounded), const Gap(12), - chip('Uptime', '$uptime days', material.Icons.schedule_rounded), + chip('Uptime', uptime, material.Icons.schedule_rounded), const Gap(12), chip('Clients', '$clients / $maxClients', material.Icons.people_outline_rounded), const Gap(12), @@ -345,11 +426,19 @@ class _RedisViewState extends material.State { ); } - material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + material.Widget _card( + material.BuildContext context, + String title, + material.Widget body, { + double? minHeight, + bool stretchBody = false, + }) { final cs = shadcn.Theme.of(context).colorScheme; return material.Container( width: double.infinity, - height: height, + constraints: minHeight != null + ? material.BoxConstraints(minHeight: minHeight) + : const material.BoxConstraints(), padding: const material.EdgeInsets.all(20), decoration: material.BoxDecoration( color: cs.card, @@ -357,17 +446,35 @@ class _RedisViewState extends material.State { border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), ), child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.start, + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text(title).semiBold(), const Gap(12), - body, + if (stretchBody) material.Expanded(child: body) else body, ], ), ); } + material.Widget _metricList( + material.BuildContext context, + List rows, { + bool stretch = false, + }) { + final column = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: rows, + ); + if (!stretch) return column; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + column, + const material.Spacer(), + ], + ); + } + material.Widget _memoryCard(material.BuildContext context, RedisInfoSections info) { final usedHuman = sectionValue(info, 'Memory', 'used_memory_human') ?? '—'; final peakHuman = sectionValue(info, 'Memory', 'used_memory_peak_human') ?? '—'; @@ -376,17 +483,18 @@ class _RedisViewState extends material.State { return _card( context, 'Memory', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Used memory', usedHuman), - _row(context, 'Peak', peakHuman), - _row(context, 'RSS', rss), - _row(context, 'Fragmentation', '${frag.toStringAsFixed(2)}x'), + _metricList( + context, + [ + _metricRow(context, 'Used', usedHuman), + _metricRow(context, 'Peak', peakHuman), + _metricRow(context, 'RSS', rss), + _metricRow(context, 'Fragmentation', '${frag.toStringAsFixed(2)}×'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -399,58 +507,96 @@ class _RedisViewState extends material.State { final hitRate = total > 0 ? (keyspaceHits / total * 100).toStringAsFixed(1) : '—'; return _card( context, - 'Stats', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Ops/s', '$ops'), - if (totalOps != null) _row(context, 'Total commands', '$totalOps'), - _row(context, 'Keyspace hits', '$keyspaceHits'), - _row(context, 'Keyspace misses', '$keyspaceMisses'), - _row(context, 'Hit rate', '$hitRate%'), + 'Performance', + _metricList( + context, + [ + _metricRow(context, 'Ops/s', '$ops'), + _metricRow(context, 'Total commands', totalOps?.toString() ?? '—'), + _metricRow(context, 'Hits / misses', '$keyspaceHits / $keyspaceMisses'), + _metricRow(context, 'Hit rate', hitRate == '—' ? hitRate : '$hitRate%'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } material.Widget _keyspaceCard(material.BuildContext context, RedisInfoSections info) { - final hits = sectionInt(info, 'Stats', 'keyspace_hits') ?? 0; - final misses = sectionInt(info, 'Stats', 'keyspace_misses') ?? 0; - final total = hits + misses; - final hitPct = total > 0 ? (hits / total * 100).toStringAsFixed(1) : '—'; + final keyspace = info['Keyspace']; + final rows = []; + if (keyspace != null && keyspace.isNotEmpty) { + final entries = keyspace.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + for (final entry in entries) { + rows.add(_metricRow(context, entry.key, _formatKeyspaceEntry(entry.value))); + } + } else { + rows.add( + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 8), + child: const Text('No keys in any database').muted().small(), + ), + ); + } return _card( context, - 'Keyspace hits / misses', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - _row(context, 'Hits', '$hits'), - _row(context, 'Misses', '$misses'), - _row(context, 'Hit rate', '$hitPct%'), - ], - ), - height: _gridCardHeight, + 'Keyspace', + _metricList(context, rows, stretch: true), + minHeight: _gridCardMinHeight, + stretchBody: true, ); } + String _formatKeyspaceEntry(String raw) { + var keys = 0; + var expires = 0; + int? avgTtlMs; + for (final part in raw.split(',')) { + final kv = part.split('='); + if (kv.length != 2) continue; + final name = kv[0].trim(); + final value = kv[1].trim(); + switch (name) { + case 'keys': + keys = int.tryParse(value) ?? 0; + case 'expires': + expires = int.tryParse(value) ?? 0; + case 'avg_ttl': + avgTtlMs = int.tryParse(value); + } + } + final ttlPart = (avgTtlMs != null && avgTtlMs > 0) + ? ' · avg TTL ${_formatDurationMs(avgTtlMs)}' + : ''; + return '$keys keys · $expires with TTL$ttlPart'; + } + + String _formatDurationMs(int ms) { + final seconds = ms ~/ 1000; + if (seconds < 60) return '${seconds}s'; + if (seconds < 3600) return '${seconds ~/ 60}m'; + if (seconds < 86400) return '${(seconds / 3600).toStringAsFixed(1)}h'; + return '${(seconds / 86400).toStringAsFixed(1)}d'; + } + material.Widget _cpuCard(material.BuildContext context, RedisInfoSections info) { final sys = sectionDouble(info, 'CPU', 'used_cpu_sys_main_thread'); final user = sectionDouble(info, 'CPU', 'used_cpu_user_main_thread'); return _card( context, 'CPU (main thread)', - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - if (sys != null) _row(context, 'System', sys.toStringAsFixed(2)), - if (user != null) _row(context, 'User', user.toStringAsFixed(2)), + _metricList( + context, + [ + _metricRow(context, 'System', sys?.toStringAsFixed(2) ?? '—'), + _metricRow(context, 'User', user?.toStringAsFixed(2) ?? '—'), ], + stretch: true, ), - height: _gridCardHeight, + minHeight: _gridCardMinHeight, + stretchBody: true, ); } @@ -463,14 +609,46 @@ class _RedisViewState extends material.State { return _card( context, title, - material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in entries) _row(context, e.key, e.value)], + _twoColumnMetrics( + context, + entries.map((e) => MapEntry(_labelFor(e.key), e.value)).toList(), ), ); } + material.Widget _twoColumnMetrics( + material.BuildContext context, + List> entries, + ) { + if (entries.length <= 4) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in entries) _metricRow(context, e.key, e.value)], + ); + } + final mid = (entries.length / 2).ceil(); + final left = entries.sublist(0, mid); + final right = entries.sublist(mid); + return material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in left) _metricRow(context, e.key, e.value)], + ), + ), + const Gap(24), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in right) _metricRow(context, e.key, e.value)], + ), + ), + ], + ); + } + material.Widget _errorStatsCard(material.BuildContext context, RedisInfoSections info) { final data = info['Errorstats']; if (data == null || data.isEmpty) return const material.SizedBox.shrink(); @@ -478,22 +656,45 @@ class _RedisViewState extends material.State { context, 'Error stats', material.Column( - mainAxisSize: material.MainAxisSize.min, crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [for (final e in data.entries) _row(context, e.key, e.value)], + children: [ + for (final e in data.entries) + _metricRow(context, _labelFor(e.key), e.value), + ], ), ); } - material.Widget _row(material.BuildContext context, String key, String value) { + material.Widget _metricRow(material.BuildContext context, String label, String value) { final cs = shadcn.Theme.of(context).colorScheme; return material.Padding( - padding: const material.EdgeInsets.symmetric(vertical: 4), + padding: const material.EdgeInsets.symmetric(vertical: 6), child: material.Row( crossAxisAlignment: material.CrossAxisAlignment.start, children: [ - material.SizedBox(width: 160, child: Text(key).muted().small()), - material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + material.Expanded( + flex: 3, + child: Text( + label, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + ).muted().small(), + ), + const Gap(12), + material.Flexible( + flex: 2, + child: material.SelectableText( + value, + textAlign: material.TextAlign.right, + maxLines: 2, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + height: 1.25, + ), + ), + ), ], ), ); From 8724041621e6949ae539c3cd810ff53765355bb9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:05:29 +0300 Subject: [PATCH 29/32] perf(redis): drop hover setState from explorer list rows Use InkWell hoverColor and always-visible row actions instead of per-row rebuilds. --- lib/features/redis/redis_databases_view.dart | 41 +++----- lib/features/redis/redis_explorer_view.dart | 59 +++++------- lib/features/redis/redis_keys_view.dart | 99 ++++++++------------ 3 files changed, 78 insertions(+), 121 deletions(-) diff --git a/lib/features/redis/redis_databases_view.dart b/lib/features/redis/redis_databases_view.dart index 5d35d6bd..3c4b46de 100644 --- a/lib/features/redis/redis_databases_view.dart +++ b/lib/features/redis/redis_databases_view.dart @@ -237,7 +237,7 @@ class _DbInfo { // ─── Tile widget ──────────────────────────────────────────────────────────── -class _DatabaseTile extends StatefulWidget { +class _DatabaseTile extends material.StatelessWidget { const _DatabaseTile({ required this.db, required this.colorScheme, @@ -248,34 +248,21 @@ class _DatabaseTile extends StatefulWidget { final _DbInfo db; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; - final VoidCallback onTap; - - @override - material.State<_DatabaseTile> createState() => _DatabaseTileState(); -} - -class _DatabaseTileState extends material.State<_DatabaseTile> { - bool _hovered = false; + final material.VoidCallback onTap; @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; - final scs = widget.shadcnCs; - final db = widget.db; + final cs = colorScheme; + final db = this.db; - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: material.Colors.transparent, child: material.InkWell( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + onTap: onTap, + hoverColor: shadcnCs.primary.withValues(alpha: 0.06), + child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 20, vertical: 10), - color: _hovered - ? scs.primary.withValues(alpha: 0.06) - : material.Colors.transparent, child: material.Row( children: [ material.Icon( @@ -283,7 +270,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { ? material.Icons.dns_rounded : material.Icons.dns_outlined, size: 18, - color: db.hasData ? scs.primary : scs.mutedForeground, + color: db.hasData ? shadcnCs.primary : shadcnCs.mutedForeground, ), const Gap(12), material.Expanded( @@ -302,7 +289,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { '${db.keys} keys • ${db.expires} with TTL', style: material.TextStyle( fontSize: 12, - color: scs.mutedForeground, + color: shadcnCs.mutedForeground, ), ), ], @@ -313,7 +300,7 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { padding: const material.EdgeInsets.symmetric( horizontal: 8, vertical: 3), decoration: material.BoxDecoration( - color: scs.primary.withValues(alpha: 0.12), + color: shadcnCs.primary.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(10), ), child: Text( @@ -321,13 +308,13 @@ class _DatabaseTileState extends material.State<_DatabaseTile> { style: material.TextStyle( fontSize: 11, fontWeight: material.FontWeight.w600, - color: scs.primary, + color: shadcnCs.primary, ), ), ), const Gap(8), material.Icon(material.Icons.chevron_right_rounded, - size: 18, color: scs.mutedForeground), + size: 18, color: shadcnCs.mutedForeground), ], ), ), diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index a8a7e35c..08c960f9 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -349,7 +349,7 @@ class _BreadcrumbBar extends StatelessWidget { } } -class _CrumbChip extends StatefulWidget { +class _CrumbChip extends material.StatelessWidget { const _CrumbChip({ required this.label, required this.isLast, @@ -358,44 +358,37 @@ class _CrumbChip extends StatefulWidget { final String label; final bool isLast; - final VoidCallback? onTap; - - @override - material.State<_CrumbChip> createState() => _CrumbChipState(); -} - -class _CrumbChipState extends material.State<_CrumbChip> { - bool _hovered = false; + final material.VoidCallback? onTap; @override material.Widget build(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: widget.onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + final child = isLast + ? Text(label).semiBold().small() + : Text(label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(); + + if (onTap == null) { + return material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: child, + ); + } + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + hoverColor: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: material.BoxDecoration( - color: _hovered && widget.onTap != null - ? cs.primary.withValues(alpha: 0.1) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: widget.isLast - ? Text(widget.label).semiBold().small() - : Text(widget.label, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500)) - .small(), + child: child, ), ), ); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 8cbcf4ba..afaedb4b 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -320,7 +320,7 @@ class _KeyInfo { // ─── Key tile widget ──────────────────────────────────────────────────────── -class _KeyTile extends StatefulWidget { +class _KeyTile extends material.StatelessWidget { const _KeyTile({ required this.keyInfo, required this.colorScheme, @@ -332,34 +332,27 @@ class _KeyTile extends StatefulWidget { final _KeyInfo keyInfo; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; - final VoidCallback onTap; - final VoidCallback onDelete; + final material.VoidCallback onTap; + final material.VoidCallback onDelete; - @override - material.State<_KeyTile> createState() => _KeyTileState(); -} - -class _KeyTileState extends material.State<_KeyTile> { - bool _hovered = false; - - Color _typeColor(String type) { + static Color _typeColor(String type, shadcn.ColorScheme scs) { switch (type) { case 'string': - return const Color(0xFF42A5F5); + return const material.Color(0xFF42A5F5); case 'hash': - return const Color(0xFFAB47BC); + return const material.Color(0xFFAB47BC); case 'list': - return const Color(0xFF66BB6A); + return const material.Color(0xFF66BB6A); case 'set': - return const Color(0xFFFFA726); + return const material.Color(0xFFFFA726); case 'zset': - return const Color(0xFFEF5350); + return const material.Color(0xFFEF5350); default: - return widget.shadcnCs.mutedForeground; + return scs.mutedForeground; } } - material.IconData _typeIcon(String type) { + static material.IconData _typeIcon(String type) { switch (type) { case 'string': return material.Icons.text_fields_rounded; @@ -376,7 +369,7 @@ class _KeyTileState extends material.State<_KeyTile> { } } - String _formatTtl(int ttl) { + static String _formatTtl(int ttl) { if (ttl == -1) return 'No TTL'; if (ttl == -2) return 'Missing'; if (ttl < 60) return '${ttl}s'; @@ -387,35 +380,25 @@ class _KeyTileState extends material.State<_KeyTile> { @override material.Widget build(material.BuildContext context) { - final cs = widget.colorScheme; - final scs = widget.shadcnCs; - final ki = widget.keyInfo; - final typeCol = _typeColor(ki.type); - - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + final cs = colorScheme; + final ki = keyInfo; + final typeCol = _typeColor(ki.type, shadcnCs); + + return material.Material( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + clipBehavior: material.Clip.antiAlias, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, + hoverColor: shadcnCs.muted.withValues(alpha: 0.15), borderRadius: material.BorderRadius.circular(8), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + child: material.Padding( padding: const material.EdgeInsets.symmetric( horizontal: 16, vertical: 10), - decoration: material.BoxDecoration( - color: _hovered - ? scs.muted.withValues(alpha: 0.15) - : cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.3), width: 1), - ), child: material.Row( children: [ material.Icon(_typeIcon(ki.type), size: 16, color: typeCol), const Gap(10), - // Type badge material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 6, vertical: 2), @@ -423,7 +406,7 @@ class _KeyTileState extends material.State<_KeyTile> { color: typeCol.withValues(alpha: 0.12), borderRadius: material.BorderRadius.circular(4), ), - child: Text( + child: material.Text( ki.type.toUpperCase(), style: material.TextStyle( fontSize: 10, @@ -434,7 +417,6 @@ class _KeyTileState extends material.State<_KeyTile> { ), ), const Gap(10), - // Key name material.Expanded( child: material.Text( ki.name, @@ -448,40 +430,35 @@ class _KeyTileState extends material.State<_KeyTile> { ), ), const Gap(8), - // TTL if (ki.ttl >= 0) material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: material.BoxDecoration( - color: scs.muted.withValues(alpha: 0.3), + color: shadcnCs.muted.withValues(alpha: 0.3), borderRadius: material.BorderRadius.circular(4), ), - child: Text( + child: material.Text( 'TTL ${_formatTtl(ki.ttl)}', style: material.TextStyle( fontSize: 10, - color: scs.mutedForeground, + color: shadcnCs.mutedForeground, ), ), ), - const Gap(8), - // Delete button (only on hover) - material.AnimatedOpacity( - opacity: _hovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 120), - child: material.InkWell( - onTap: widget.onDelete, - borderRadius: material.BorderRadius.circular(4), - child: const material.Padding( - padding: material.EdgeInsets.all(4), - child: material.Icon(material.Icons.delete_rounded, - size: 15, color: Color(0xFFEF5350)), - ), - ), + const Gap(4), + material.IconButton( + onPressed: onDelete, + icon: const material.Icon(material.Icons.delete_rounded, + size: 15, color: material.Color(0xFFEF5350)), + padding: const material.EdgeInsets.all(4), + constraints: + const material.BoxConstraints(minWidth: 28, minHeight: 28), + splashRadius: 18, + tooltip: 'Delete key', ), material.Icon(material.Icons.chevron_right_rounded, - size: 18, color: scs.mutedForeground), + size: 18, color: shadcnCs.mutedForeground), ], ), ), From 81ec4416f7c09e528e8c60ddd4c3590201d6a5cb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:05:29 +0300 Subject: [PATCH 30/32] perf(mongo): drop hover setState from explorer list rows Use InkWell hoverColor for row highlights and stateless action buttons. --- .../mongodb/mongo_collections_view.dart | 126 +++++++----------- .../mongodb/mongo_databases_view.dart | 118 ++++++---------- lib/features/mongodb/mongo_explorer_view.dart | 59 ++++---- 3 files changed, 120 insertions(+), 183 deletions(-) diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 76f907ed..0bb4cb33 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -362,7 +362,7 @@ class _MongoCollectionsViewState extends material.State { // ─── Row widget ────────────────────────────────────────────────────────────── -class _CollectionRow extends StatefulWidget { +class _CollectionRow extends StatelessWidget { const _CollectionRow({ required this.collection, required this.colorScheme, @@ -375,41 +375,31 @@ class _CollectionRow extends StatefulWidget { final VoidCallback onView; final VoidCallback onDrop; - @override - State<_CollectionRow> createState() => _CollectionRowState(); -} - -class _CollectionRowState extends State<_CollectionRow> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final cs = widget.colorScheme; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, - color: _hovered - ? cs.muted.withValues(alpha: 0.15) - : Colors.transparent, - padding: const material.EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: Row( - children: [ - _ActionButton( - label: 'View', - icon: material.Icons.visibility_rounded, - color: const Color(0xFF4CAF50), - onTap: widget.onView, - ), - const Gap(16), - material.Expanded( - child: material.InkWell( - onTap: widget.onView, + final cs = colorScheme; + return material.Material( + color: Colors.transparent, + child: material.InkWell( + onTap: onView, + hoverColor: cs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: onView, + ), + const Gap(16), + Expanded( child: Text( - widget.collection.name, + collection.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( color: cs.primary, fontSize: 14, @@ -417,26 +407,24 @@ class _CollectionRowState extends State<_CollectionRow> { ), ), ), - ), - material.SizedBox( - width: 100, - child: Text(widget.collection.documentCount?.toString() ?? '—') - .muted() - .small(), - ), - material.SizedBox( - width: 100, - child: Text(_formatSize(widget.collection.size)) - .muted() - .small(), - ), - _ActionButton( - label: 'Del', - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDrop, - ), - ], + SizedBox( + width: 100, + child: Text(collection.documentCount?.toString() ?? '—') + .muted() + .small(), + ), + SizedBox( + width: 100, + child: Text(_formatSize(collection.size)).muted().small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: onDrop, + ), + ], + ), ), ), ); @@ -455,7 +443,7 @@ class _CollectionRowState extends State<_CollectionRow> { } } -class _ActionButton extends StatefulWidget { +class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.icon, @@ -468,43 +456,29 @@ class _ActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: Colors.transparent, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, borderRadius: material.BorderRadius.circular(6), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.9) - : widget.color.withValues(alpha: 0.75), + color: color.withValues(alpha: 0.8), borderRadius: material.BorderRadius.circular(6), ), child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, - size: 14, color: material.Colors.white), + material.Icon(icon, size: 14, color: Colors.white), const Gap(5), Text( - widget.label, + label, style: const material.TextStyle( - color: material.Colors.white, + color: Colors.white, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 8630995b..087ac0dc 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -349,7 +349,7 @@ class _MongoDatabasesViewState extends State { // ─── Helper widgets ────────────────────────────────────────────────────────── -class _DatabaseRow extends StatefulWidget { +class _DatabaseRow extends StatelessWidget { const _DatabaseRow({ required this.database, required this.colorScheme, @@ -362,43 +362,31 @@ class _DatabaseRow extends StatefulWidget { final VoidCallback onView; final VoidCallback onDrop; - @override - State<_DatabaseRow> createState() => _DatabaseRowState(); -} - -class _DatabaseRowState extends State<_DatabaseRow> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final cs = widget.colorScheme; - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, - color: _hovered - ? cs.muted.withValues(alpha: 0.15) - : Colors.transparent, - padding: const material.EdgeInsets.symmetric( - horizontal: 20, vertical: 10), - child: Row( - children: [ - // View button - _ActionButton( - label: 'View', - icon: material.Icons.visibility_rounded, - color: const Color(0xFF4CAF50), - onTap: widget.onView, - ), - const Gap(16), - // Database name - material.Expanded( - child: material.InkWell( - onTap: widget.onView, + final cs = colorScheme; + return material.Material( + color: Colors.transparent, + child: material.InkWell( + onTap: onView, + hoverColor: cs.muted.withValues(alpha: 0.15), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: onView, + ), + const Gap(16), + Expanded( child: Text( - widget.database.name, + database.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, style: material.TextStyle( color: cs.primary, fontSize: 14, @@ -406,22 +394,18 @@ class _DatabaseRowState extends State<_DatabaseRow> { ), ), ), - ), - // Size - material.SizedBox( - width: 120, - child: Text(_formatSize(widget.database.sizeOnDisk)) - .muted() - .small(), - ), - // Delete button - _ActionButton( - label: 'Del', - icon: material.Icons.delete_rounded, - color: const Color(0xFFEF5350), - onTap: widget.onDrop, - ), - ], + SizedBox( + width: 120, + child: Text(_formatSize(database.sizeOnDisk)).muted().small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: onDrop, + ), + ], + ), ), ), ); @@ -440,7 +424,7 @@ class _DatabaseRowState extends State<_DatabaseRow> { } } -class _ActionButton extends StatefulWidget { +class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, required this.icon, @@ -453,43 +437,29 @@ class _ActionButton extends StatefulWidget { final Color color; final VoidCallback onTap; - @override - State<_ActionButton> createState() => _ActionButtonState(); -} - -class _ActionButtonState extends State<_ActionButton> { - bool _hovered = false; - @override Widget build(BuildContext context) { - return material.MouseRegion( - cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), + return material.Material( + color: Colors.transparent, child: material.InkWell( - onTap: widget.onTap, + onTap: onTap, borderRadius: material.BorderRadius.circular(6), - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: material.Curves.easeOut, + child: material.Container( padding: const material.EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: material.BoxDecoration( - color: _hovered - ? widget.color.withValues(alpha: 0.9) - : widget.color.withValues(alpha: 0.75), + color: color.withValues(alpha: 0.8), borderRadius: material.BorderRadius.circular(6), ), child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, - size: 14, color: material.Colors.white), + material.Icon(icon, size: 14, color: Colors.white), const Gap(5), Text( - widget.label, + label, style: const material.TextStyle( - color: material.Colors.white, + color: Colors.white, fontSize: 12, fontWeight: material.FontWeight.w500, ), diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 70d465ec..b7baea71 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -419,7 +419,7 @@ class _BreadcrumbBar extends StatelessWidget { } } -class _CrumbChip extends StatefulWidget { +class _CrumbChip extends material.StatelessWidget { const _CrumbChip({ required this.label, required this.isLast, @@ -428,44 +428,37 @@ class _CrumbChip extends StatefulWidget { final String label; final bool isLast; - final VoidCallback? onTap; - - @override - material.State<_CrumbChip> createState() => _CrumbChipState(); -} - -class _CrumbChipState extends material.State<_CrumbChip> { - bool _hovered = false; + final material.VoidCallback? onTap; @override material.Widget build(material.BuildContext context) { final cs = shadcn.Theme.of(context).colorScheme; - return material.MouseRegion( - cursor: widget.onTap != null - ? material.SystemMouseCursors.click - : material.SystemMouseCursors.basic, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: material.GestureDetector( - onTap: widget.onTap, - child: material.AnimatedContainer( - duration: const Duration(milliseconds: 120), + final child = isLast + ? Text(label).semiBold().small() + : Text(label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(); + + if (onTap == null) { + return material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: child, + ); + } + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: onTap, + hoverColor: cs.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: material.BoxDecoration( - color: _hovered && widget.onTap != null - ? cs.primary.withValues(alpha: 0.1) - : material.Colors.transparent, - borderRadius: material.BorderRadius.circular(4), - ), - child: widget.isLast - ? Text(widget.label).semiBold().small() - : Text(widget.label, - style: material.TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: material.FontWeight.w500)) - .small(), + child: child, ), ), ); From 93e6621a98cdeb1283657ac84c7ba8f1265130eb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:23:13 +0300 Subject: [PATCH 31/32] fix(test): remove unnecessary string interpolation in results_tab_test Satisfies unnecessary_string_interpolations analyzer rule in CI. --- test/features/main_screen/results_tab_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/main_screen/results_tab_test.dart b/test/features/main_screen/results_tab_test.dart index 1f77c119..647f9c16 100644 --- a/test/features/main_screen/results_tab_test.dart +++ b/test/features/main_screen/results_tab_test.dart @@ -40,7 +40,7 @@ void main() { final long = computeResultGridColumnWidths( columns: const ['payload'], rows: [ - ['${'x' * 80}'], + ['x' * 80], ], ).single; expect(long, greaterThan(short)); From 4f071798d0aa26dc4d22b608277a4f62b7769a23 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 13 Jun 2026 21:29:00 +0300 Subject: [PATCH 32/32] chore(release): prepare 0.4.1 changelog and dev docs Document #93 performance release, docker compose quick start, and keep pubspec at 0.4.0+6 so the main merge workflow can bump to 0.4.1+7. --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ docs/getting-started.md | 13 +++++++++++++ docs/roadmap.md | 1 + pubspec.yaml | 2 +- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca273cc..5a7c4d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.1] - 2026-06-13 + +Performance and UX release ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)). Git tag **`0.4.1`**. + +### Added + +- **MySQL server stats** — full dashboard (connections, queries, network, databases table) via `SHOW GLOBAL STATUS` / `information_schema`. +- **Virtual SQL result grid** — `VirtualResultGrid` with fixed column widths, cell copy, and `RepaintBoundary` instead of a materialized `Table`. +- **Lazy connection tree** — `lazyConnectionTreeList` / `ListView.builder` for large PostgreSQL and MySQL object lists; `ValueKey` on leaf rows. +- **Vertical split pane** — `ValueNotifier`-driven splitter drag without rebuilding entire workspace panels. +- **Docker dev stack** — `docker/` compose with PostgreSQL, MySQL, MongoDB, Redis and seed data for local testing. +- **`deep_collection_equals`** — shared snapshot diff helper for stats polling. + +### Changed + +- **UI scale preview** — decoupled from app-wide theme rebuilds; scale commits only on slider release. +- **Syntax highlighting** — debounced updates, worker isolate for all buffer sizes, highlighter pair cache by theme key. +- **Stats dashboards** — Redis, MongoDB, and PostgreSQL skip `setState` when polled data is unchanged; Redis/Mongo layouts redesigned; MySQL replaces version-only stub. +- **Connection forms** — `FormValidityNotifier` narrows rebuild scope to action buttons. +- **SQL workspace settings** — `SqlWorkspaceSettingsRevision` so theme/scale changes do not reload SQL tabs. +- **Connections panel** — rebuilds only when selected connection id changes. +- **MySQL results** — row string conversion moved to a worker isolate (`mysql_result_utils`). +- **Redis key editor** — virtualized hash/list/set/zset member lists. +- **Mongo documents** — lazy pretty-JSON cache; document cards without hover `setState`. +- **Explorer list rows** — Redis/Mongo database/key/collection rows use `InkWell` hover instead of hover `setState`. +- **QueryaDropdown** — caches menu children; ellipsizes trigger label in fixed-width layouts. + +### Fixed + +- **Connection menu** — new connection form opens after picking type from the menu ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93) follow-up). +- **Stats UI overflow** — Redis, MongoDB, and PostgreSQL stats cards no longer overflow in debug/profile layouts. +- **Redis TTL dialog** — disposes `TextEditingController` on close. + ## [0.4.0] - 2026-05-28 Theme system milestone (epic #37). Git tag **`0.4.0`** — use this release for binaries; earlier tag `0.3.0` was a pre-PR snapshot. diff --git a/docs/getting-started.md b/docs/getting-started.md index 832767f4..191c3c69 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -73,3 +73,16 @@ flutter build macos See the [User guide](user-guide.md) for preferences, the driver manager, and day-to-day usage, and [Security](security.md) for how credentials are stored. + +## Local dev databases (optional) + +The repo includes a Docker Compose stack under [`docker/`](../docker/) with +PostgreSQL, MySQL, MongoDB, and Redis plus seed data: + +```bash +cp docker/.env.example docker/.env # optional overrides +cd docker && docker compose up -d +``` + +Default credentials: user/password **`querya`**, database **`querya`** +(MongoDB auth source: **`admin`**). Stop with `docker compose down`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 491c5d92..b0a48dbe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.0 (epic #37):** runtime themes, VS Code `colors` + `tokenColors` import, SQL/JSON highlighting, P0 workbench migration, Preferences, tests, docs — [theme.md](theme.md). +- **Shipped in 0.4.1 ([#93](https://github.com/QueryaHub/Querya-Desktop/issues/93)):** UI performance — virtual result grid, lazy connection tree, decoupled scale preview, stats polling, MySQL stats dashboard, local `docker/` dev stack — [perf-baseline.md](perf-baseline.md). - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per [archive/code-forge-evaluation.md](archive/code-forge-evaluation.md) (**NO-GO** on `code_forge` for 0.3). diff --git a/pubspec.yaml b/pubspec.yaml index 3f764c8a..2e8cecdb 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.1+7 +version: 0.4.0+6