From 1e36ac81026b8ba3e4a514b73adb383d94c3482c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 09:40:15 +0300 Subject: [PATCH 1/4] feat(connections): implement New Connection from URL menu action (#228) Wire the Connection menu item to a URI dialog that parses supported database URLs into ConnectionRow and persists them through LocalDb. --- .../connections/connection_url_parser.dart | 156 ++++++++++++++++++ .../new_connection_url_dialog.dart | 156 ++++++++++++++++++ lib/features/main_screen/main_screen.dart | 11 ++ .../main_screen/querya_window_title_bar.dart | 4 +- 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 lib/features/connections/connection_url_parser.dart create mode 100644 lib/features/connections/new_connection_url_dialog.dart diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart new file mode 100644 index 00000000..1634cbd7 --- /dev/null +++ b/lib/features/connections/connection_url_parser.dart @@ -0,0 +1,156 @@ +import 'package:querya_desktop/core/storage/local_db.dart'; + +const _supportedSchemes = { + 'postgresql', + 'postgres', + 'mysql', + 'sqlite', + 'mongodb', + 'mongodb+srv', + 'redis', + 'rediss', +}; + +/// Parses a database connection URL into a [ConnectionRow], or returns an error message. +({ConnectionRow? row, String? error}) parseConnectionUrlInput(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return (row: null, error: 'URL/URI is required.'); + } + + final uri = Uri.tryParse(trimmed); + if (uri == null || uri.scheme.isEmpty) { + return (row: null, error: 'Invalid URL/URI format.'); + } + + final scheme = uri.scheme.toLowerCase(); + if (!_supportedSchemes.contains(scheme)) { + return ( + row: null, + error: + 'Unsupported protocol "$scheme". Supported: postgresql, mysql, sqlite, mongodb, redis.', + ); + } + + final row = _buildConnectionRow(trimmed, uri, scheme); + if (row == null) { + return (row: null, error: 'Failed to parse connection URL.'); + } + return (row: row, error: null); +} + +ConnectionRow? _buildConnectionRow(String url, Uri uri, String scheme) { + String type; + int? defaultPort; + + if (scheme == 'postgresql' || scheme == 'postgres') { + type = 'postgresql'; + defaultPort = 5432; + } else if (scheme == 'mysql') { + type = 'mysql'; + defaultPort = 3306; + } else if (scheme == 'sqlite') { + type = 'sqlite'; + } else if (scheme == 'mongodb' || scheme == 'mongodb+srv') { + type = 'mongodb'; + defaultPort = 27017; + } else if (scheme == 'redis' || scheme == 'rediss') { + type = 'redis'; + defaultPort = 6379; + } else { + return null; + } + + String? host; + int? port; + String? username; + String? password; + String? databaseName; + String? authSource; + String? connectionString; + var useSSL = scheme == 'rediss'; + + if (type == 'sqlite') { + String path; + if (url.contains(':memory:')) { + path = ':memory:'; + } else if (url.startsWith('sqlite:///')) { + path = uri.path; + } else if (url.startsWith('sqlite://')) { + path = url.substring(9); + } else if (url.startsWith('sqlite:')) { + path = url.substring(7); + } else { + path = uri.path; + } + host = path; + } else { + host = uri.host.isEmpty ? null : uri.host; + port = uri.hasPort ? uri.port : defaultPort; + + if (uri.userInfo.isNotEmpty) { + final parts = uri.userInfo.split(':'); + if (parts.isNotEmpty) { + username = Uri.decodeComponent(parts[0]); + } + if (parts.length > 1) { + password = Uri.decodeComponent(parts.sublist(1).join(':')); + } + } + + databaseName = uri.pathSegments.firstOrNull; + if (databaseName != null && databaseName.isEmpty) { + databaseName = null; + } + + authSource = uri.queryParameters['authSource'] ?? + uri.queryParameters['authsource']; + + final sslQuery = uri.queryParameters['sslmode'] ?? uri.queryParameters['ssl']; + if (sslQuery != null) { + final lowerSsl = sslQuery.toLowerCase(); + if (lowerSsl == 'true' || lowerSsl == 'require' || lowerSsl == 'prefer') { + useSSL = true; + } + } + + if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { + connectionString = url; + } + } + + final name = _connectionName(type, host, databaseName); + + return ConnectionRow( + type: type, + name: name, + host: host, + port: port, + username: username, + password: password, + databaseName: databaseName, + authSource: authSource, + useSSL: useSSL, + connectionString: connectionString, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); +} + +String _connectionName(String type, String? host, String? databaseName) { + if (type == 'sqlite') { + return host == ':memory:' ? 'SQLite (Memory)' : 'SQLite (${host!.split('/').last})'; + } + + final cleanHost = host ?? 'localhost'; + final cleanDb = databaseName ?? ''; + final typeName = switch (type) { + 'postgresql' => 'PostgreSQL', + 'mysql' => 'MySQL', + 'mongodb' => 'MongoDB', + _ => 'Redis', + }; + if (cleanDb.isNotEmpty) { + return '$typeName: $cleanDb'; + } + return '$typeName: $cleanHost'; +} diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart new file mode 100644 index 00000000..23e60638 --- /dev/null +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_url_parser.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows a dialog to create a new database connection from a URI. +/// Returns the ConnectionRow or null if cancelled. +Future showNewConnectionUrlDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _NewConnectionUrlDialogContent(), + ), + ); +} + +class _NewConnectionUrlDialogContent extends material.StatefulWidget { + const _NewConnectionUrlDialogContent(); + + @override + material.State<_NewConnectionUrlDialogContent> createState() => + _NewConnectionUrlDialogContentState(); +} + +class _NewConnectionUrlDialogContentState + extends material.State<_NewConnectionUrlDialogContent> { + final _urlController = material.TextEditingController(); + String? _validationError; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + void _validateAndSubmit() { + final result = parseConnectionUrlInput(_urlController.text); + if (result.error != null) { + setState(() => _validationError = result.error); + return; + } + material.Navigator.of(context).pop(result.row); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 500, + minWidth: 380, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('New connection from URL').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create a connection by pasting a database URI (e.g. postgresql://user:pass@host:5432/db).', + ).muted().small(), + const material.SizedBox(height: 16), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _validationError != null + ? theme.destructive.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), + ), + ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.link_rounded, + size: 20, + color: _validationError != null + ? theme.destructive + : theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + onChanged: (_) { + if (_validationError != null) { + setState(() => _validationError = null); + } + }, + ), + ), + ], + ), + ), + if (_validationError != null) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), + ], + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), + ), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _validateAndSubmit, + child: const Text('Create'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 2e5b0d1c..e34ed870 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -16,6 +16,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -139,6 +140,15 @@ class _MainScreenState extends State { await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); } + Future _onNewDatabaseConnectionFromUrl() async { + await Future.delayed(const Duration(milliseconds: 100)); + if (!mounted) return; + final row = await showNewConnectionUrlDialog(context); + if (!mounted || row == null) return; + await LocalDb.instance.addConnection(row); + await _connectionsPanelKey.currentState?.reloadConnectionsFromDb(); + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -154,6 +164,7 @@ class _MainScreenState extends State { builder: (context, workspace, _) { return QueryaWindowTitleBar( onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, + onNewDatabaseConnectionFromUrl: _onNewDatabaseConnectionFromUrl, activeConnection: workspace.activeConnection, isReadOnly: workspace.isReadOnly, onReadOnlyChanged: () { diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index ef072943..10804ee6 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -14,6 +14,7 @@ class QueryaWindowTitleBar extends StatelessWidget { const QueryaWindowTitleBar({ super.key, required this.onNewDatabaseConnection, + required this.onNewDatabaseConnectionFromUrl, this.activeConnection, this.onConnect, this.onReconnect, @@ -25,6 +26,7 @@ class QueryaWindowTitleBar extends StatelessWidget { }); final Future Function() onNewDatabaseConnection; + final Future Function() onNewDatabaseConnectionFromUrl; final ConnectionRow? activeConnection; final VoidCallback? onConnect; final VoidCallback? onReconnect; @@ -162,7 +164,7 @@ class QueryaWindowTitleBar extends StatelessWidget { leading: const material.Icon( material.Icons.link_rounded, size: 18), - onPressed: (_) {}, + onPressed: (_) => onNewDatabaseConnectionFromUrl(), child: const Text('New Connection from URL'), ), MenuButton( From 4506cf098dda6b92fab9c2a796991369cf178822 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 09:40:17 +0300 Subject: [PATCH 2/4] test(connections): cover URL connection parser and dialog (#228) Add unit tests for URI parsing across supported drivers and widget tests for the new connection dialog validation and submit flow. --- .../connection_url_parser_test.dart | 136 ++++++++++++++++++ .../new_connection_url_dialog_test.dart | 106 ++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 test/features/connections/connection_url_parser_test.dart create mode 100644 test/features/connections/new_connection_url_dialog_test.dart diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart new file mode 100644 index 00000000..1e0d384c --- /dev/null +++ b/test/features/connections/connection_url_parser_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/connection_url_parser.dart'; + +void main() { + group('parseConnectionUrlInput', () { + test('returns error for empty input', () { + final result = parseConnectionUrlInput(' '); + expect(result.row, isNull); + expect(result.error, 'URL/URI is required.'); + }); + + test('returns error for invalid format', () { + final result = parseConnectionUrlInput('not a url'); + expect(result.row, isNull); + expect(result.error, 'Invalid URL/URI format.'); + }); + + test('returns error for unsupported scheme', () { + final result = parseConnectionUrlInput('ftp://localhost/db'); + expect(result.row, isNull); + expect(result.error, contains('Unsupported protocol')); + }); + + test('parses postgresql URL with credentials and database', () { + final result = parseConnectionUrlInput( + 'postgresql://alice:secret@db.example.com:5432/myapp', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'postgresql'); + expect(row.name, 'PostgreSQL: myapp'); + expect(row.host, 'db.example.com'); + expect(row.port, 5432); + expect(row.username, 'alice'); + expect(row.password, 'secret'); + expect(row.databaseName, 'myapp'); + expect(row.connectionString, 'postgresql://alice:secret@db.example.com:5432/myapp'); + expect(row.useSSL, false); + }); + + test('parses postgres alias scheme', () { + final result = parseConnectionUrlInput('postgres://localhost/appdb'); + expect(result.error, isNull); + expect(result.row!.type, 'postgresql'); + expect(result.row!.port, 5432); + expect(result.row!.databaseName, 'appdb'); + }); + + test('parses postgresql sslmode=require', () { + final result = parseConnectionUrlInput( + 'postgresql://localhost/postgres?sslmode=require', + ); + expect(result.error, isNull); + expect(result.row!.useSSL, true); + }); + + test('parses mysql URL', () { + final result = parseConnectionUrlInput( + 'mysql://root:p%40ss@127.0.0.1:3307/sakila', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mysql'); + expect(row.name, 'MySQL: sakila'); + expect(row.host, '127.0.0.1'); + expect(row.port, 3307); + expect(row.username, 'root'); + expect(row.password, 'p@ss'); + expect(row.connectionString, 'mysql://root:p%40ss@127.0.0.1:3307/sakila'); + }); + + test('parses sqlite file path', () { + final result = parseConnectionUrlInput('sqlite:///tmp/test.db'); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'sqlite'); + expect(row.host, '/tmp/test.db'); + expect(row.name, 'SQLite (test.db)'); + }); + + test('parses sqlite in-memory', () { + final result = parseConnectionUrlInput('sqlite:///:memory:'); + expect(result.error, isNull); + expect(result.row!.host, ':memory:'); + expect(result.row!.name, 'SQLite (Memory)'); + }); + + test('parses mongodb URL with authSource', () { + final result = parseConnectionUrlInput( + 'mongodb://admin:pass@mongo.local:27017/app?authSource=admin', + ); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'mongodb'); + expect(row.name, 'MongoDB: app'); + expect(row.authSource, 'admin'); + expect(row.connectionString, contains('mongodb://')); + }); + + test('parses mongodb+srv URL', () { + final result = parseConnectionUrlInput( + 'mongodb+srv://user:pass@cluster.example.net/mydb', + ); + expect(result.error, isNull); + expect(result.row!.type, 'mongodb'); + expect(result.row!.host, 'cluster.example.net'); + expect(result.row!.databaseName, 'mydb'); + }); + + test('parses redis URL', () { + final result = parseConnectionUrlInput('redis://:password@localhost:6379'); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'redis'); + expect(row.name, 'Redis: localhost'); + expect(row.password, 'password'); + expect(row.connectionString, isNull); + }); + + test('parses rediss URL with SSL enabled', () { + final result = parseConnectionUrlInput('rediss://localhost'); + expect(result.error, isNull); + expect(result.row!.type, 'redis'); + expect(result.row!.useSSL, true); + expect(result.row!.port, 6379); + }); + + test('password with colon is preserved', () { + final result = parseConnectionUrlInput( + 'postgresql://user:p%3Aart@localhost/mydb', + ); + expect(result.error, isNull); + expect(result.row!.password, 'p:art'); + }); + }); +} diff --git a/test/features/connections/new_connection_url_dialog_test.dart b/test/features/connections/new_connection_url_dialog_test.dart new file mode 100644 index 00000000..5af32e03 --- /dev/null +++ b/test/features/connections/new_connection_url_dialog_test.dart @@ -0,0 +1,106 @@ +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/features/connections/new_connection_url_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showNewConnectionUrlDialog', () { + testWidgets('dialog shows title and form controls', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('New connection from URL'), findsOneWidget); + expect(find.text('Create'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + ConnectionRow? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + + testWidgets('empty URL shows validation error', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(find.text('URL/URI is required.'), findsOneWidget); + }); + + testWidgets('valid URL returns parsed ConnectionRow', (tester) async { + ConnectionRow? result; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showNewConnectionUrlDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byType(TextField), + 'postgresql://user:pass@localhost:5432/mydb', + ); + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.type, 'postgresql'); + expect(result!.databaseName, 'mydb'); + expect(result!.username, 'user'); + expect(result!.password, 'pass'); + }); + }); +} From 134b75f366d2b63a9a46ca81e52dd6c1998f6ec1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 09:43:38 +0300 Subject: [PATCH 3/4] feat(ui): implement Help About and Documentation menu actions (#229) Add an About dialog with app version, MIT license notice, and repository link, and open project documentation in the browser via url_launcher. --- lib/core/app/app_links.dart | 9 ++ lib/core/app/external_link.dart | 13 ++ lib/features/help/about_dialog.dart | 118 ++++++++++++++++++ .../main_screen/querya_window_title_bar.dart | 6 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 lib/core/app/app_links.dart create mode 100644 lib/core/app/external_link.dart create mode 100644 lib/features/help/about_dialog.dart diff --git a/lib/core/app/app_links.dart b/lib/core/app/app_links.dart new file mode 100644 index 00000000..1d6cefd9 --- /dev/null +++ b/lib/core/app/app_links.dart @@ -0,0 +1,9 @@ +/// Canonical external URLs for Querya Desktop. +abstract final class AppLinks { + static const repository = + 'https://github.com/QueryaHub/Querya-Desktop'; + static const documentation = + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md'; + static const license = + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE'; +} diff --git a/lib/core/app/external_link.dart b/lib/core/app/external_link.dart new file mode 100644 index 00000000..49e13bef --- /dev/null +++ b/lib/core/app/external_link.dart @@ -0,0 +1,13 @@ +import 'package:querya_desktop/core/app/app_links.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens [url] in the system browser. +Future launchExternalUrl(String url) { + final uri = Uri.parse(url); + return launchUrl(uri, mode: LaunchMode.externalApplication); +} + +Future launchRepositoryUrl() => launchExternalUrl(AppLinks.repository); + +Future launchDocumentationUrl() => + launchExternalUrl(AppLinks.documentation); diff --git a/lib/features/help/about_dialog.dart b/lib/features/help/about_dialog.dart new file mode 100644 index 00000000..11eef0a7 --- /dev/null +++ b/lib/features/help/about_dialog.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart' as material; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:querya_desktop/core/app/external_link.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows the About Querya dialog. +Future showAboutDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: const _AboutDialogContent(), + ), + ); +} + +class _AboutDialogContent extends material.StatefulWidget { + const _AboutDialogContent(); + + @override + material.State<_AboutDialogContent> createState() => + _AboutDialogContentState(); +} + +class _AboutDialogContentState extends material.State<_AboutDialogContent> { + late final Future _packageInfo = PackageInfo.fromPlatform(); + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final wb = context.workbench; + + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 420, + minWidth: 320, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 28, 24, 8), + child: material.Column( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 48, + color: wb.accent, + ), + const material.SizedBox(height: 16), + const Text('Querya').large().semiBold(), + const material.SizedBox(height: 8), + FutureBuilder( + future: _packageInfo, + builder: (context, snapshot) { + final version = snapshot.data?.version ?? '…'; + return Text('Version $version').muted().small(); + }, + ), + const material.SizedBox(height: 16), + const Text( + 'A lightweight desktop SQL/NoSQL client.', + ).muted().small(), + const material.SizedBox(height: 12), + const Text( + 'Licensed under the MIT License.', + ).small(), + const material.SizedBox(height: 16), + GhostButton( + onPressed: () => launchRepositoryUrl(), + child: const Text('View repository'), + ), + ], + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Opens project documentation in the system browser. +Future openQueryaDocumentation() => launchDocumentationUrl(); diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 10804ee6..373b2cef 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -226,9 +227,10 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( subMenu: [ MenuButton( - onPressed: (_) {}, child: const Text('About')), + onPressed: (ctx) => showAboutDialog(ctx), + child: const Text('About')), MenuButton( - onPressed: (_) {}, + onPressed: (_) => openQueryaDocumentation(), child: const Text('Documentation')), ], child: const Text('Help'), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f09d0a3b..85e343b4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar = @@ -32,4 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 7792787e..f4b41b9b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context refresh_rate super_native_extensions + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d30bbdad..ff1df342 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,9 +10,11 @@ import device_info_plus import file_selector_macos import flutter_secure_storage_macos import irondash_engine_context +import package_info_plus import refresh_rate import sqflite_darwin import super_native_extensions +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin")) @@ -20,7 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RefreshRatePlugin.register(with: registry.registrar(forPlugin: "RefreshRatePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index bf4086c7..171641ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: file_selector: ^1.1.0 syntax_highlight: ^0.5.0 archive: ^4.0.9 + url_launcher: ^6.3.1 + package_info_plus: ^8.3.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index eab8f5b5..1bce8ba7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { BitsdojoWindowPluginRegisterWithRegistrar( @@ -26,4 +27,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("RefreshRatePluginCApi")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bf263147..134fbbb2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context refresh_rate super_native_extensions + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 9edc03358c85254d8c7e74e7c187977e9c7d78df Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 09:43:41 +0300 Subject: [PATCH 4/4] test(ui): cover About dialog and app links (#229) Add widget tests for the About dialog and unit tests for canonical documentation and repository URLs. --- test/core/app/app_links_test.dart | 24 +++++++++++ test/features/help/about_dialog_test.dart | 51 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 test/core/app/app_links_test.dart create mode 100644 test/features/help/about_dialog_test.dart diff --git a/test/core/app/app_links_test.dart b/test/core/app/app_links_test.dart new file mode 100644 index 00000000..89618178 --- /dev/null +++ b/test/core/app/app_links_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/app/app_links.dart'; + +void main() { + group('AppLinks', () { + test('repository points to Querya-Desktop GitHub', () { + expect(AppLinks.repository, 'https://github.com/QueryaHub/Querya-Desktop'); + }); + + test('documentation points to docs README on main', () { + expect( + AppLinks.documentation, + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/docs/README.md', + ); + }); + + test('license points to LICENSE file on main', () { + expect( + AppLinks.license, + 'https://github.com/QueryaHub/Querya-Desktop/blob/main/LICENSE', + ); + }); + }); +} diff --git a/test/features/help/about_dialog_test.dart b/test/features/help/about_dialog_test.dart new file mode 100644 index 00000000..7465ffa6 --- /dev/null +++ b/test/features/help/about_dialog_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/help/about_dialog.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('showAboutDialog', () { + testWidgets('dialog shows app name, license, and actions', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showAboutDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Querya'), findsOneWidget); + expect(find.textContaining('Version'), findsOneWidget); + expect(find.text('Licensed under the MIT License.'), findsOneWidget); + expect(find.text('View repository'), findsOneWidget); + expect(find.text('Close'), findsOneWidget); + }); + + testWidgets('Close dismisses the dialog', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showAboutDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Querya'), findsNothing); + }); + }); +}