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/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/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/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( 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/goldens/app_dialog_overlay.png b/test/features/main_screen/goldens/app_dialog_overlay.png new file mode 100644 index 00000000..d7529b52 Binary files /dev/null and b/test/features/main_screen/goldens/app_dialog_overlay.png differ diff --git a/test/features/main_screen/goldens/querya_tab_strip.png b/test/features/main_screen/goldens/querya_tab_strip.png new file mode 100644 index 00000000..a769bbce Binary files /dev/null and b/test/features/main_screen/goldens/querya_tab_strip.png differ diff --git a/test/features/main_screen/goldens/title_bar_read_only_badge.png b/test/features/main_screen/goldens/title_bar_read_only_badge.png new file mode 100644 index 00000000..b1c54542 Binary files /dev/null and b/test/features/main_screen/goldens/title_bar_read_only_badge.png differ diff --git a/test/features/main_screen/shell_chrome_golden_test.dart b/test/features/main_screen/shell_chrome_golden_test.dart new file mode 100644 index 00000000..3e31bc49 --- /dev/null +++ b/test/features/main_screen/shell_chrome_golden_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; +import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('read-only badge visual baseline', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(220, 48)); + addTearDown(() => 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; + }); +} 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 [], + ), ), ), );