From 2efa2be3d73b8798961344c8eccda932d813b572 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Jul 2026 19:56:08 +0300 Subject: [PATCH 1/3] feat(ui): surface recent connections on empty workspace (#339) Persist last-selected connection ids and open them from the task-oriented empty state instead of only marketing quick-start copy. --- lib/core/storage/app_settings.dart | 44 +++ lib/features/main_screen/main_screen.dart | 8 + .../main_screen/workspace_empty_hero.dart | 345 +++++++++++++++--- lib/features/main_screen/workspace_panel.dart | 3 + test/core/storage/app_settings_test.dart | 20 + .../workspace_empty_hero_golden_test.dart | 1 + .../workspace_empty_hero_test.dart | 43 ++- 7 files changed, 413 insertions(+), 51 deletions(-) diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 19011202..386a1a26 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -41,6 +41,9 @@ const double kDefaultConnectionsPanelWidth = 260; const double kMinConnectionsPanelWidth = 180; const double kMaxConnectionsPanelWidth = 500; +/// How many recently selected connections to surface on the empty workspace. +const int kMaxRecentConnections = 5; + /// Fixed tick marks on the interface scale slider (75% … 200%). const List kUiScalePresets = [ 0.75, @@ -119,6 +122,7 @@ abstract final class AppSettingsKeys { static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; static const connectionsPanelWidth = 'connections_panel_width'; + static const recentConnectionIds = 'recent_connection_ids'; static const motionLevel = 'motion_level'; static const updateChannel = 'update_channel'; static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; @@ -268,6 +272,46 @@ class AppSettings { ); } + /// Most recently selected connection ids (newest first). + Future> getRecentConnectionIds() async { + final raw = await LocalDb.instance.getAppSetting( + AppSettingsKeys.recentConnectionIds, + ); + if (raw == null || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + final ids = []; + for (final item in decoded) { + final id = item is int ? item : int.tryParse('$item'); + if (id != null && id > 0 && !ids.contains(id)) { + ids.add(id); + } + if (ids.length >= kMaxRecentConnections) break; + } + return ids; + } catch (_) { + return const []; + } + } + + /// Records [connectionId] as the most recently selected connection. + Future recordRecentConnection(int connectionId) async { + if (connectionId <= 0) return; + final current = await getRecentConnectionIds(); + final next = [ + connectionId, + ...current.where((id) => id != connectionId), + ]; + if (next.length > kMaxRecentConnections) { + next.removeRange(kMaxRecentConnections, next.length); + } + await LocalDb.instance.setAppSetting( + AppSettingsKeys.recentConnectionIds, + jsonEncode(next), + ); + } + /// Max SQL history rows kept per connection + database (oldest trimmed). Future getSqlHistoryMaxEntries() async { final v = await LocalDb.instance.getAppSetting( diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 0c074e6c..b441fede 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -44,6 +44,10 @@ class _MainScreenState extends State { void _onConnectionSelected(ConnectionRow connection) { _workspace.value = _workspace.value.selectConnection(connection); + final id = connection.id; + if (id != null) { + unawaited(AppSettings.instance.recordRecentConnection(id)); + } } void _onPostgresObjectSelected( @@ -264,6 +268,7 @@ class _MainScreenState extends State { onRequestNewConnectionFromUrl: _onNewDatabaseConnectionFromUrl, onRequestOpenSqlite: _openSqliteFromHero, + onOpenConnection: _onConnectionSelected, ), ), ], @@ -295,6 +300,7 @@ class _MainContentSplit extends StatefulWidget { required this.onRequestNewConnection, required this.onRequestNewConnectionFromUrl, required this.onRequestOpenSqlite, + required this.onOpenConnection, }); final GlobalKey connectionsPanelKey; @@ -331,6 +337,7 @@ class _MainContentSplit extends StatefulWidget { final VoidCallback onRequestNewConnection; final VoidCallback onRequestNewConnectionFromUrl; final VoidCallback onRequestOpenSqlite; + final void Function(ConnectionRow) onOpenConnection; @override State<_MainContentSplit> createState() => _MainContentSplitState(); @@ -442,6 +449,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { onRequestNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, onRequestOpenSqlite: widget.onRequestOpenSqlite, + onOpenConnection: widget.onOpenConnection, ); }, ), diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index ed7e5119..4c49b538 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -1,25 +1,100 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.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'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Task-oriented empty workspace shown before a connection is selected. -class WorkspaceEmptyHero extends StatelessWidget { +class WorkspaceEmptyHero extends StatefulWidget { const WorkspaceEmptyHero({ super.key, required this.onNewConnection, this.onNewConnectionFromUrl, this.onOpenSqlite, + this.onOpenConnection, + this.recentConnections, }); final VoidCallback onNewConnection; final VoidCallback? onNewConnectionFromUrl; final VoidCallback? onOpenSqlite; + final ValueChanged? onOpenConnection; + + /// When provided (e.g. in tests), skips loading recent connections from storage. + final List? recentConnections; + + @override + State createState() => _WorkspaceEmptyHeroState(); +} + +class _WorkspaceEmptyHeroState extends State { + List _recent = const []; + var _loaded = false; + + @override + void initState() { + super.initState(); + final injected = widget.recentConnections; + if (injected != null) { + _recent = injected; + _loaded = true; + } else { + unawaited(_loadRecent()); + } + } + + @override + void didUpdateWidget(covariant WorkspaceEmptyHero oldWidget) { + super.didUpdateWidget(oldWidget); + final injected = widget.recentConnections; + if (injected != null) { + _recent = injected; + _loaded = true; + } else if (oldWidget.recentConnections != null) { + unawaited(_loadRecent()); + } + } + + Future _loadRecent() async { + final recentIds = await AppSettings.instance.getRecentConnectionIds(); + final all = await LocalDb.instance.getConnections(); + final byId = { + for (final conn in all) + if (conn.id != null) conn.id!: conn, + }; + + final ordered = []; + for (final id in recentIds) { + final conn = byId.remove(id); + if (conn != null) ordered.add(conn); + if (ordered.length >= kMaxRecentConnections) break; + } + + if (ordered.length < kMaxRecentConnections) { + final remaining = byId.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + for (final conn in remaining) { + ordered.add(conn); + if (ordered.length >= kMaxRecentConnections) break; + } + } + + if (!mounted) return; + setState(() { + _recent = ordered; + _loaded = true; + }); + } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final wb = context.workbench; + final showRecent = _loaded && _recent.isNotEmpty; return material.LayoutBuilder( builder: (context, constraints) { @@ -71,27 +146,27 @@ class WorkspaceEmptyHero extends StatelessWidget { children: [ PrimaryButton( key: const Key('empty_new_connection'), - onPressed: onNewConnection, + onPressed: widget.onNewConnection, leading: const material.Icon( material.Icons.add_link_rounded, size: 18, ), child: const Text('New connection'), ), - if (onNewConnectionFromUrl != null) + if (widget.onNewConnectionFromUrl != null) OutlineButton( key: const Key('empty_new_from_url'), - onPressed: onNewConnectionFromUrl, + onPressed: widget.onNewConnectionFromUrl, leading: const material.Icon( material.Icons.link_rounded, size: 18, ), child: const Text('New from URL'), ), - if (onOpenSqlite != null) + if (widget.onOpenSqlite != null) OutlineButton( key: const Key('empty_open_sqlite'), - onPressed: onOpenSqlite, + onPressed: widget.onOpenSqlite, leading: const material.Icon( material.Icons.folder_open_rounded, size: 18, @@ -101,53 +176,60 @@ class WorkspaceEmptyHero extends StatelessWidget { ], ), material.SizedBox(height: compact ? 28 : 40), - material.Container( - padding: material.EdgeInsets.all(compact ? 16 : 20), - decoration: material.BoxDecoration( - color: wb.surface, - borderRadius: material.BorderRadius.circular(12), - border: material.Border.all( - color: wb.borderSubtle.withValues(alpha: 0.55), + if (showRecent) + _RecentConnectionsSection( + connections: _recent, + onOpenConnection: widget.onOpenConnection, + compact: compact, + ) + else + material.Container( + padding: material.EdgeInsets.all(compact ? 16 : 20), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.55), + ), ), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Text( - 'Quick start', - style: material.TextStyle( - color: cs.foreground, - fontSize: 14, - fontWeight: material.FontWeight.w600, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + 'Quick start', + style: material.TextStyle( + color: cs.foreground, + fontSize: 14, + fontWeight: material.FontWeight.w600, + ), ), - ), - const material.SizedBox(height: 12), - _QuickStartRow( - icon: material.Icons.dns_rounded, - title: 'Server databases', - description: - 'PostgreSQL, MySQL, MongoDB, Redis and extension drivers', - color: cs.primary, - ), - const material.SizedBox(height: 12), - _QuickStartRow( - icon: material.Icons.insert_drive_file_rounded, - title: 'Local database', - description: - 'Open an existing SQLite file or create a connection', - color: cs.primary, - ), - const material.SizedBox(height: 12), - _QuickStartRow( - icon: material.Icons.security_rounded, - title: 'Credentials stay protected', - description: - 'Passwords are stored in your operating system secure store', - color: cs.primary, - ), - ], + const material.SizedBox(height: 12), + _QuickStartRow( + icon: material.Icons.dns_rounded, + title: 'Server databases', + description: + 'PostgreSQL, MySQL, MongoDB, Redis and extension drivers', + color: cs.primary, + ), + const material.SizedBox(height: 12), + _QuickStartRow( + icon: material.Icons.insert_drive_file_rounded, + title: 'Local database', + description: + 'Open an existing SQLite file or create a connection', + color: cs.primary, + ), + const material.SizedBox(height: 12), + _QuickStartRow( + icon: material.Icons.security_rounded, + title: 'Credentials stay protected', + description: + 'Passwords are stored in your operating system secure store', + color: cs.primary, + ), + ], + ), ), - ), ], ), ), @@ -158,6 +240,135 @@ class WorkspaceEmptyHero extends StatelessWidget { } } +class _RecentConnectionsSection extends StatelessWidget { + const _RecentConnectionsSection({ + required this.connections, + required this.onOpenConnection, + required this.compact, + }); + + final List connections; + final ValueChanged? onOpenConnection; + final bool compact; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final wb = context.workbench; + + return material.Container( + padding: material.EdgeInsets.all(compact ? 12 : 16), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all( + color: wb.borderSubtle.withValues(alpha: 0.55), + ), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + 'Recent connections', + style: material.TextStyle( + color: cs.foreground, + fontSize: 14, + fontWeight: material.FontWeight.w600, + ), + ), + const material.SizedBox(height: 8), + for (var i = 0; i < connections.length; i++) ...[ + if (i > 0) const material.SizedBox(height: 4), + _RecentConnectionRow( + connection: connections[i], + onTap: onOpenConnection == null + ? null + : () => onOpenConnection!(connections[i]), + ), + ], + ], + ), + ); + } +} + +class _RecentConnectionRow extends StatelessWidget { + const _RecentConnectionRow({ + required this.connection, + required this.onTap, + }); + + final ConnectionRow connection; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final subtitle = _connectionSubtitle(connection); + + return material.Material( + color: material.Colors.transparent, + child: material.InkWell( + key: Key('empty_recent_${connection.id ?? connection.name}'), + borderRadius: material.BorderRadius.circular(8), + onTap: onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + child: material.Row( + children: [ + DriverIcon( + size: 20, + fallbackIcon: _iconForType(connection.type), + assetPath: _iconAssetForType(connection.type), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Text( + connection.name.isEmpty + ? connection.type + : connection.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + color: cs.foreground, + fontSize: 13, + fontWeight: material.FontWeight.w500, + ), + ), + if (subtitle.isNotEmpty) ...[ + const material.SizedBox(height: 2), + material.Text( + subtitle, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 12, + ), + ), + ], + ], + ), + ), + material.Icon( + material.Icons.chevron_right_rounded, + size: 18, + color: cs.mutedForeground, + ), + ], + ), + ), + ), + ); + } +} + class _QuickStartRow extends StatelessWidget { const _QuickStartRow({ required this.icon, @@ -207,3 +418,37 @@ class _QuickStartRow extends StatelessWidget { ); } } + +material.IconData _iconForType(String type) { + return switch (type) { + 'mongodb' => material.Icons.eco_rounded, + 'postgresql' => material.Icons.storage_rounded, + 'mysql' => material.Icons.table_chart_rounded, + 'redis' => material.Icons.memory_rounded, + 'sqlite' => material.Icons.folder_open_rounded, + _ => material.Icons.extension_rounded, + }; +} + +String? _iconAssetForType(String type) { + return switch (type) { + 'postgresql' => 'assets/images/postgresql_icon.png', + 'mysql' => 'assets/images/mysql_icon.png', + 'redis' => 'assets/images/redis_icon.png', + 'mongodb' => 'assets/images/mongodb_icon.png', + _ => null, + }; +} + +String _connectionSubtitle(ConnectionRow connection) { + if (connection.type == 'sqlite') { + final path = connection.databaseName ?? connection.connectionString; + return path?.trim().isNotEmpty == true ? path!.trim() : 'SQLite'; + } + final host = connection.host?.trim(); + if (host == null || host.isEmpty) { + return connection.type; + } + final port = connection.port; + return port != null ? '$host:$port' : host; +} diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 9941806d..19ef9a87 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -54,6 +54,7 @@ class WorkspacePanel extends StatefulWidget { this.onRequestNewConnection, this.onRequestNewConnectionFromUrl, this.onRequestOpenSqlite, + this.onOpenConnection, }); /// Currently selected connection from the sidebar. @@ -118,6 +119,7 @@ class WorkspacePanel extends StatefulWidget { final void Function()? onRequestNewConnection; final void Function()? onRequestNewConnectionFromUrl; final void Function()? onRequestOpenSqlite; + final void Function(ConnectionRow connection)? onOpenConnection; @override State createState() => _WorkspacePanelState(); @@ -137,6 +139,7 @@ class _WorkspacePanelState extends State { onNewConnection: widget.onRequestNewConnection ?? () {}, onNewConnectionFromUrl: widget.onRequestNewConnectionFromUrl, onOpenSqlite: widget.onRequestOpenSqlite, + onOpenConnection: widget.onOpenConnection, ), ); } diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index db01410e..ec00eb0f 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -229,6 +229,26 @@ void main() { kMaxConnectionsPanelWidth, ); }); + + test('recent connections record newest first and cap length', () async { + expect(await AppSettings.instance.getRecentConnectionIds(), isEmpty); + + await AppSettings.instance.recordRecentConnection(1); + await AppSettings.instance.recordRecentConnection(2); + await AppSettings.instance.recordRecentConnection(3); + expect(await AppSettings.instance.getRecentConnectionIds(), [3, 2, 1]); + + await AppSettings.instance.recordRecentConnection(1); + expect(await AppSettings.instance.getRecentConnectionIds(), [1, 3, 2]); + + for (var id = 10; id < 10 + kMaxRecentConnections; id++) { + await AppSettings.instance.recordRecentConnection(id); + } + final ids = await AppSettings.instance.getRecentConnectionIds(); + expect(ids.length, kMaxRecentConnections); + expect(ids.first, 10 + kMaxRecentConnections - 1); + expect(ids.contains(1), isFalse); + }); }); group('theme settings', () { diff --git a/test/features/main_screen/workspace_empty_hero_golden_test.dart b/test/features/main_screen/workspace_empty_hero_golden_test.dart index 05129112..5bb2dfef 100644 --- a/test/features/main_screen/workspace_empty_hero_golden_test.dart +++ b/test/features/main_screen/workspace_empty_hero_golden_test.dart @@ -16,6 +16,7 @@ void main() { onNewConnection: () {}, onNewConnectionFromUrl: () {}, onOpenSqlite: () {}, + recentConnections: const [], ), ), ), diff --git a/test/features/main_screen/workspace_empty_hero_test.dart b/test/features/main_screen/workspace_empty_hero_test.dart index 8d22db6e..9e16a34c 100644 --- a/test/features/main_screen/workspace_empty_hero_test.dart +++ b/test/features/main_screen/workspace_empty_hero_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/main_screen/workspace_empty_hero.dart'; @@ -19,6 +20,7 @@ void main() { onNewConnection: () => newTapped = true, onNewConnectionFromUrl: () => urlTapped = true, onOpenSqlite: () => sqliteTapped = true, + recentConnections: const [], ), ), ), @@ -30,6 +32,7 @@ void main() { expect(find.text('Open SQLite file'), findsOneWidget); expect(find.text('Start working with your data'), findsOneWidget); expect(find.textContaining('dark interface'), findsNothing); + expect(find.text('Quick start'), findsOneWidget); await tester.tap(find.text('New connection')); await tester.tap(find.text('New from URL')); @@ -39,6 +42,41 @@ void main() { expect(sqliteTapped, isTrue); }); + testWidgets('WorkspaceEmptyHero opens a recent connection', (tester) async { + ConnectionRow? opened; + const recent = ConnectionRow( + id: 7, + type: 'postgresql', + name: 'Prod DB', + host: 'db.example.com', + port: 5432, + createdAt: '2026-01-01T00:00:00Z', + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 900, + height: 700, + child: WorkspaceEmptyHero( + onNewConnection: () {}, + recentConnections: const [recent], + onOpenConnection: (conn) => opened = conn, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Recent connections'), findsOneWidget); + expect(find.text('Prod DB'), findsOneWidget); + expect(find.text('db.example.com:5432'), findsOneWidget); + expect(find.text('Quick start'), findsNothing); + + await tester.tap(find.text('Prod DB')); + expect(opened?.id, 7); + }); + testWidgets('WorkspaceEmptyHero quick start uses workbench surface color', (tester) async { const surface = material.Color(0xFFABCDEF); @@ -52,7 +90,10 @@ void main() { child: material.SizedBox( width: 900, height: 700, - child: WorkspaceEmptyHero(onNewConnection: () {}), + child: WorkspaceEmptyHero( + onNewConnection: () {}, + recentConnections: const [], + ), ), ), ); From 655838485c7f3ead566e3b80000b3fa06cb93e3b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Jul 2026 19:56:10 +0300 Subject: [PATCH 2/3] refactor(ui): route remaining overlays through showAppDialog (#339) Align SQLite/extension DDL and theme dialogs with the shared Querya blur/dismiss overlay pattern. --- lib/features/extensions/extension_table_view.dart | 6 +++--- lib/features/settings/theme_color_picker_dialog.dart | 4 ++-- lib/features/settings/theme_remote_install_dialog.dart | 3 +-- lib/features/sqlite/sqlite_table_view.dart | 6 +++--- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 2fd4328e..e62f572a 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -177,13 +177,13 @@ class _ExtensionTableViewState extends material.State { } Future _openDdlDialog() async { - material.showDialog( + unawaited(showAppDialog( context: context, barrierDismissible: false, builder: (ctx) => const material.Center( child: material.CircularProgressIndicator(), ), - ); + )); try { final meta = await ExtensionDriverSession.instance.getObjectMetadata( @@ -198,7 +198,7 @@ class _ExtensionTableViewState extends material.State { ? meta.ddl! : '-- No DDL metadata returned by extension driver for ${widget.tableName}\nSELECT * FROM $_qualifiedName LIMIT 10;'; - await material.showDialog( + await showAppDialog( context: context, builder: (ctx) => material.AlertDialog( title: material.Text( diff --git a/lib/features/settings/theme_color_picker_dialog.dart b/lib/features/settings/theme_color_picker_dialog.dart index 4426f3eb..0ceca4ad 100644 --- a/lib/features/settings/theme_color_picker_dialog.dart +++ b/lib/features/settings/theme_color_picker_dialog.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart' as material; -import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Simple color picker dialog for the theme editor. Future showThemeColorPickerDialog({ @@ -8,7 +8,7 @@ Future showThemeColorPickerDialog({ }) async { var picked = ColorDerivative.fromColor(initial); - return material.showDialog( + return showAppDialog( context: context, builder: (dialogContext) { return material.AlertDialog( diff --git a/lib/features/settings/theme_remote_install_dialog.dart b/lib/features/settings/theme_remote_install_dialog.dart index e8d294ef..2790674e 100644 --- a/lib/features/settings/theme_remote_install_dialog.dart +++ b/lib/features/settings/theme_remote_install_dialog.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; /// Dialog for installing a theme from a public HTTPS URL. Future showThemeRemoteInstallDialog( material.BuildContext context, ) async { - return material.showDialog( + return showAppDialog( context: context, builder: (dialogContext) => const _ThemeRemoteInstallDialog(), ); diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index cf972226..fd3f4f7d 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -194,18 +194,18 @@ class _SqliteTableViewState extends material.State { Future _showDdlDialog() async { final conn = _connection; if (conn == null || !conn.isConnected) return; - material.showDialog( + unawaited(showAppDialog( context: context, barrierDismissible: false, builder: (_) => const material.Center( child: material.CircularProgressIndicator(), ), - ); + )); try { final ddl = await conn.getObjectDdl(widget.tableName); if (!mounted) return; material.Navigator.of(context).pop(); - await material.showDialog( + await showAppDialog( context: context, builder: (ctx) => material.AlertDialog( title: material.Text( From 7652ca939e56b0682d5a2026759c80e218747b7e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 19 Jul 2026 19:56:10 +0300 Subject: [PATCH 3/3] test(ui): add shell chrome golden baselines (#339) Capture read-only badge, tab strip, and shared dialog overlay visuals for regression coverage. --- .../goldens/app_dialog_overlay.png | Bin 0 -> 17570 bytes .../main_screen/goldens/querya_tab_strip.png | Bin 0 -> 688 bytes .../goldens/title_bar_read_only_badge.png | Bin 0 -> 827 bytes .../main_screen/shell_chrome_golden_test.dart | 115 ++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 test/features/main_screen/goldens/app_dialog_overlay.png create mode 100644 test/features/main_screen/goldens/querya_tab_strip.png create mode 100644 test/features/main_screen/goldens/title_bar_read_only_badge.png create mode 100644 test/features/main_screen/shell_chrome_golden_test.dart diff --git a/test/features/main_screen/goldens/app_dialog_overlay.png b/test/features/main_screen/goldens/app_dialog_overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..d7529b52e7b67d8e1de639b11d6a2971e4eecb39 GIT binary patch literal 17570 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 tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Center( + child: QueryaReadOnlyBadge(), + ), + ), + ); + await tester.pump(); + + await expectLater( + find.byType(QueryaReadOnlyBadge), + matchesGoldenFile('goldens/title_bar_read_only_badge.png'), + ); + }); + + testWidgets('tab strip visual baseline', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(520, 64)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Center( + child: material.SizedBox( + width: 480, + child: QueryaTabStrip( + labels: const ['Server', 'SQL', 'History'], + selectedIndex: 1, + onSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + await expectLater( + find.byType(QueryaTabStrip), + matchesGoldenFile('goldens/querya_tab_strip.png'), + ); + }); + + testWidgets('app dialog visual baseline', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(640, 420)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + late material.BuildContext ctx; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) { + ctx = context; + return const material.SizedBox.expand( + child: material.ColoredBox( + color: material.Color(0xFF1A1B1E), + child: material.Center( + child: material.Text('Workspace'), + ), + ), + ); + }, + ), + ), + ); + await tester.pump(); + + final future = showAppDialog( + context: ctx, + builder: (dialogContext) => material.AlertDialog( + title: const material.Text('Confirm action'), + content: const material.SizedBox( + width: 280, + child: material.Text('This dialog uses the shared Querya overlay.'), + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(dialogContext).pop(), + child: const material.Text('Cancel'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(dialogContext).pop(), + child: const material.Text('Confirm'), + ), + ], + ), + ); + + // Advance past the overlay enter animation without waiting forever. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + await expectLater( + find.byType(ShadcnApp), + matchesGoldenFile('goldens/app_dialog_overlay.png'), + ); + + material.Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await future; + }); +}