diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart index 5da85b5..9a20bdb 100644 --- a/lib/core/sdui/sdui_form_builder.dart +++ b/lib/core/sdui/sdui_form_builder.dart @@ -145,21 +145,24 @@ class SduiFormBuilderState extends material.State { @override material.Widget build(material.BuildContext context) { - return material.Form( - key: _formKey, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - if (widget.schema.title != null) ...[ - Text(widget.schema.title!).large().semiBold(), - const Gap(12), - ], - for (var i = 0; i < widget.schema.fields.length; i++) ...[ - if (i > 0) const Gap(12), - _buildField(widget.schema.fields[i]), + return material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Form( + key: _formKey, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (widget.schema.title != null) ...[ + Text(widget.schema.title!).large().semiBold(), + const Gap(12), + ], + for (var i = 0; i < widget.schema.fields.length; i++) ...[ + if (i > 0) const Gap(12), + _buildField(widget.schema.fields[i]), + ], ], - ], + ), ), ); } diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart index bdeb803..2f63e33 100644 --- a/lib/features/connections/extension_connection_form.dart +++ b/lib/features/connections/extension_connection_form.dart @@ -200,10 +200,12 @@ class _ExtensionConnectionFormContentState minWidth: 440, ), borderColor: theme.muted, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + 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( @@ -218,96 +220,103 @@ class _ExtensionConnectionFormContentState ), ), material.Flexible( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Connection name').small().muted(), - const material.SizedBox(height: 4), - TextField( - controller: _nameController, - placeholder: const Text('My ClickHouse'), - ), - const material.SizedBox(height: 16), - if (_loading) - const material.Padding( - padding: material.EdgeInsets.all(24), - child: material.Center( - child: material.CircularProgressIndicator(), - ), - ) - else if (_loadError != null) - Text(_loadError!).muted().small() - else if (_schema != null) - SduiFormBuilder( - key: _formKey, - schema: _schema!, - initialValues: _initialValues, - keepExistingSecrets: _isEditing, + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection name').small().muted(), + const material.SizedBox(height: 4), + TextField( + controller: _nameController, + placeholder: const Text('My ClickHouse'), ), - if (_testMessage != null) ...[ - const material.SizedBox(height: 12), - material.SelectableText( - _testMessage!, - style: material.TextStyle( - fontSize: 12, - color: _testSucceeded - ? material.Colors.green - : theme.destructive, + const material.SizedBox(height: 16), + if (_loading) + const material.Padding( + padding: material.EdgeInsets.all(24), + child: material.Center( + child: material.CircularProgressIndicator(), + ), + ) + else if (_loadError != null) + Text(_loadError!).muted().small() + else if (_schema != null) + SduiFormBuilder( + key: _formKey, + schema: _schema!, + initialValues: _initialValues, + keepExistingSecrets: _isEditing, ), - ), + if (_testMessage != null) ...[ + const material.SizedBox(height: 12), + material.SelectableText( + _testMessage!, + style: material.TextStyle( + fontSize: 12, + color: _testSucceeded + ? material.Colors.green + : theme.destructive, + ), + ), + ], ], - ], + ), ), ), ), - 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), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: 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( - children: [ - OutlineButton( - onPressed: - _schema == null || _testing ? null : _testConnection, - leading: _testing - ? const material.SizedBox( - width: 14, - height: 14, - child: material.CircularProgressIndicator( - strokeWidth: 2, + child: material.Row( + children: [ + OutlineButton( + onPressed: + _schema == null || _testing ? null : _testConnection, + leading: _testing + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.bolt_rounded, + size: 16, ), - ) - : const material.Icon( - material.Icons.bolt_rounded, - size: 16, - ), - child: const Text('Test Connection'), - ), - const Spacer(), - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _schema == null ? null : _save, - child: const Text('Save'), - ), - ], + child: const Text('Test Connection'), + ), + const Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _schema == null ? null : _save, + child: const Text('Save'), + ), + ], + ), ), ), ], ), + ), ); } } diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 0671338..c908e3b 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -119,155 +119,167 @@ class _NewConnectionDialogContentState minHeight: math.min(320.0, dialogH), ), borderColor: theme.muted, - child: material.SizedBox( - height: dialogH, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - material.Padding( - padding: - material.EdgeInsets.fromLTRB(headerPadH, 20, headerPadH, 8), - child: Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Select your database').large().semiBold(), - const material.SizedBox(height: 6), - const Text( - 'Create new database connection. Find your database driver in the list below.', - ).muted().small(), - const material.SizedBox(height: 12), - material.Container( - decoration: material.BoxDecoration( - color: theme.muted.withValues(alpha: 0.2), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: theme.border.withValues(alpha: 0.4)), - ), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - child: material.Row( - children: [ - material.Icon( - material.Icons.search_rounded, - size: 20, - color: theme.mutedForeground, + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SizedBox( + height: dialogH, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Padding( + padding: + material.EdgeInsets.fromLTRB(headerPadH, 20, headerPadH, 8), + child: Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Select your database').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Create new database connection. Find your database driver in the list below.', + ).muted().small(), + const material.SizedBox(height: 12), + material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.2), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.4)), ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _searchController, - placeholder: const Text('Search...'), - onChanged: (v) => setState(() { - _searchQuery = v; - if (_selected != null && - !_filteredTypes.any( - (t) => _sameChoice(t, _selected))) { - _selected = null; - } - }), - ), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + material.Icons.search_rounded, + size: 20, + color: theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _searchController, + placeholder: const Text('Search...'), + onChanged: (v) => setState(() { + _searchQuery = v; + if (_selected != null && + !_filteredTypes.any( + (t) => _sameChoice(t, _selected))) { + _selected = null; + } + }), + ), + ), + ], ), - ], - ), + ), + const material.SizedBox(height: 12), + _FilterDropdowns( + stackVertically: stackFilters, + category: _category, + selected: _selected, + filteredTypes: _filteredTypes, + onCategoryChanged: (category) { + setState(() { + _category = category; + if (_selected != null && + !_categoryTypes + .any((t) => _sameChoice(t, _selected))) { + _selected = null; + } + }); + }, + onTypeChanged: (type) => setState(() => _selected = type), + ), + ], ), - const material.SizedBox(height: 12), - _FilterDropdowns( - stackVertically: stackFilters, - category: _category, - selected: _selected, - filteredTypes: _filteredTypes, - onCategoryChanged: (category) { - setState(() { - _category = category; - if (_selected != null && - !_categoryTypes - .any((t) => _sameChoice(t, _selected))) { - _selected = null; - } - }); + ), + ), + material.Expanded( + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + 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(context, 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: + const Text('No databases match your search.') + .muted() + .small(), + ) + : material.GridView.count( + crossAxisCount: crossAxisCount, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + childAspectRatio: aspect.clamp(0.4, 4.0), + shrinkWrap: true, + physics: const material.ClampingScrollPhysics(), + children: [ + for (final t in _filteredTypes) + _DbTypeCard( + choice: t, + theme: theme, + selected: _sameChoice(_selected, t), + onTap: () => setState(() => _selected = t), + ), + ], + ), + ); }, - onTypeChanged: (type) => setState(() => _selected = type), ), - ], - ), - ), - 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(context, 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: - const Text('No databases match your search.') - .muted() - .small(), - ) - : material.GridView.count( - crossAxisCount: crossAxisCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - childAspectRatio: aspect.clamp(0.4, 4.0), - shrinkWrap: true, - physics: const material.ClampingScrollPhysics(), - children: [ - for (final t in _filteredTypes) - _DbTypeCard( - choice: t, - theme: theme, - selected: _sameChoice(_selected, t), - onTap: () => setState(() => _selected = t), - ), - ], - ), - ); - }, - ), - ), - material.Container( - padding: material.EdgeInsets.symmetric( - horizontal: headerPadH, - vertical: 14, - ), - 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'), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: headerPadH, + vertical: 14, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3)), + ), ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _selected == null - ? null - : () => material.Navigator.of(context).pop(_selected), - child: const Text('Next'), + 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: _selected == null + ? null + : () => material.Navigator.of(context).pop(_selected), + child: const Text('Next'), + ), + ], ), - ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/connections/new_connection_url_dialog.dart b/lib/features/connections/new_connection_url_dialog.dart index 89a9527..077897e 100644 --- a/lib/features/connections/new_connection_url_dialog.dart +++ b/lib/features/connections/new_connection_url_dialog.dart @@ -89,131 +89,140 @@ class _NewConnectionUrlDialogContentState minWidth: 420, ), borderColor: theme.muted, - 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: isError - ? theme.destructive.withValues(alpha: 0.8) - : isSuccess - ? theme.primary.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( - isSuccess - ? material.Icons.check_circle_outline_rounded - : isError - ? material.Icons.error_outline_rounded - : material.Icons.link_rounded, - size: 20, - color: isError - ? theme.destructive - : isSuccess - ? theme.primary - : theme.mutedForeground, - ), - const material.SizedBox(width: 10), - material.Expanded( - child: TextField( - controller: _urlController, - placeholder: - const Text('database://user:pass@host:port/db'), - onSubmitted: (_) => _validateAndSubmit(), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: 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: isError + ? theme.destructive.withValues(alpha: 0.8) + : isSuccess + ? theme.primary.withValues(alpha: 0.8) + : theme.border.withValues(alpha: 0.4), ), ), - ], - ), - ), - if (isError) ...[ - const material.SizedBox(height: 8), - Text( - _validationError!, - style: material.TextStyle(color: theme.destructive), - ).small(), - ] else if (isSuccess) ...[ - material.Container( - margin: const material.EdgeInsets.only(top: 10), - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - decoration: material.BoxDecoration( - color: theme.primary.withValues(alpha: 0.1), - borderRadius: material.BorderRadius.circular(6), - border: material.Border.all( - color: theme.primary.withValues(alpha: 0.3), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + child: material.Row( + children: [ + material.Icon( + isSuccess + ? material.Icons.check_circle_outline_rounded + : isError + ? material.Icons.error_outline_rounded + : material.Icons.link_rounded, + size: 20, + color: isError + ? theme.destructive + : isSuccess + ? theme.primary + : theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: TextField( + controller: _urlController, + placeholder: + const Text('database://user:pass@host:port/db'), + onSubmitted: (_) => _validateAndSubmit(), + ), + ), + ], ), ), - child: material.Row( - children: [ - material.Icon( - material.Icons.task_alt_rounded, - size: 16, - color: theme.primary, + if (isError) ...[ + const material.SizedBox(height: 8), + Text( + _validationError!, + style: material.TextStyle(color: theme.destructive), + ).small(), + ] else if (isSuccess) ...[ + material.Container( + margin: const material.EdgeInsets.only(top: 10), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.primary.withValues(alpha: 0.1), + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: theme.primary.withValues(alpha: 0.3), + ), ), - const material.SizedBox(width: 8), - material.Expanded( - child: Text( - '${_parsedRow!.type.toUpperCase()} • ${_parsedRow!.name}', - style: material.TextStyle( - fontSize: 12, - fontWeight: material.FontWeight.w500, + child: material.Row( + children: [ + material.Icon( + material.Icons.task_alt_rounded, + size: 16, color: theme.primary, ), - overflow: material.TextOverflow.ellipsis, - ), + const material.SizedBox(width: 8), + material.Expanded( + child: Text( + '${_parsedRow!.type.toUpperCase()} • ${_parsedRow!.name}', + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w500, + color: theme.primary, + ), + overflow: material.TextOverflow.ellipsis, + ), + ), + ], ), - ], - ), - ), - ], - ], - ), - ), - 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'), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: 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)), + ), ), - const material.SizedBox(width: 12), - PrimaryButton( - onPressed: _validateAndSubmit, - child: const Text('Create'), + 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/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index 6f919c4..9f9d4c9 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -195,233 +195,242 @@ class _SqliteConnectionFormContentState maxHeight: dialogH, ), borderColor: theme.muted, - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - Text( - _isEditing - ? 'Edit SQLite Connection' - : 'New SQLite Connection', - ).large().semiBold(), - const Gap(6), - const Text('Connect to a local SQLite database file.') - .muted() - .small(), - ], - ), - ), - // Form body - material.ConstrainedBox( - constraints: material.BoxConstraints( - maxHeight: scrollH, - ), - child: material.SingleChildScrollView( - physics: const material.ClampingScrollPhysics(), - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 12), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - // Connection Name - const Text('Connection name').small().semiBold(), - const Gap(8), - TextField( - controller: _nameController, - placeholder: const Text('e.g. Local Cache'), - ), - if (_nameTouched && _nameController.text.trim().isEmpty) ...[ - const Gap(4), - Text( - 'Connection name is required.', - style: material.TextStyle( - color: theme.destructive, - fontSize: 11, - ), - ), - ], - const Gap(16), - // Database File Path - const Text('Database file path').small().semiBold(), - const Gap(8), - material.Row( + Text( + _isEditing + ? 'Edit SQLite Connection' + : 'New SQLite Connection', + ).large().semiBold(), + const Gap(6), + const Text('Connect to a local SQLite database file.') + .muted() + .small(), + ], + ), + ), + // Form body + material.ConstrainedBox( + constraints: material.BoxConstraints( + maxHeight: scrollH, + ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + physics: const material.ClampingScrollPhysics(), + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - material.Expanded( - child: TextField( - controller: _pathController, - placeholder: const Text('/path/to/database.db'), - ), - ), - const Gap(10), - OutlineButton( - onPressed: _pickFile, - child: const Text('Browse…'), + // Connection Name + const Text('Connection name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('e.g. Local Cache'), ), - ], - ), - if (_pathTouched && _pathController.text.trim().isEmpty) ...[ - const Gap(4), - Text( - 'Database file path is required.', - style: material.TextStyle( - color: theme.destructive, - fontSize: 11, + if (_nameTouched && _nameController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Connection name is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], + const Gap(16), + // Database File Path + const Text('Database file path').small().semiBold(), + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _pathController, + placeholder: const Text('/path/to/database.db'), + ), + ), + const Gap(10), + OutlineButton( + onPressed: _pickFile, + child: const Text('Browse…'), + ), + ], ), - ), - ], - const Gap(16), - // Read-only toggle - material.Row( - children: [ - material.Checkbox( - value: _readOnly, - onChanged: (v) => - setState(() => _readOnly = v ?? false), + if (_pathTouched && _pathController.text.trim().isEmpty) ...[ + const Gap(4), + Text( + 'Database file path is required.', + style: material.TextStyle( + color: theme.destructive, + fontSize: 11, + ), + ), + ], + const Gap(16), + // Read-only toggle + material.Row( + children: [ + material.Checkbox( + value: _readOnly, + onChanged: (v) => + setState(() => _readOnly = v ?? false), + ), + const Gap(8), + const Text('Read-only mode').small(), + ], ), - const Gap(8), - const Text('Read-only mode').small(), ], ), - ], + ), ), ), - ), - const material.Divider(height: 1), - // Test result banner - if (_testResult != null) - material.Padding( - padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), - child: material.Material( - color: material.Colors.transparent, - child: material.InkWell( - onTap: _dismissResult, - borderRadius: material.BorderRadius.circular(8), - child: material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: material.BoxDecoration( - color: _testResult == 'success' - ? theme.primary.withValues(alpha: 0.12) - : theme.destructive.withValues(alpha: 0.12), - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( + const material.Divider(height: 1), + // Test result banner + if (_testResult != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( color: _testResult == 'success' - ? theme.primary.withValues(alpha: 0.35) - : theme.destructive.withValues(alpha: 0.35), - width: 1, - ), - ), - child: material.Row( - children: [ - material.Icon( - _testResult == 'success' - ? material.Icons.check_circle_outline - : material.Icons.info_outline_rounded, - size: 18, + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( color: _testResult == 'success' - ? theme.primary - : theme.destructive, + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, ), - const Gap(10), - material.Expanded( - child: Text( + ), + child: material.Row( + children: [ + material.Icon( _testResult == 'success' - ? 'Connection successful!' - : _testResult!.startsWith('error:') - ? _testResult!.substring(7) - : 'Connection failed', - style: material.TextStyle( - fontSize: 13, - color: theme.foreground, - ), - ).small(), - ), - material.IconButton( - icon: material.Icon( - material.Icons.close, + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, size: 18, - color: theme.mutedForeground, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), ), - onPressed: _dismissResult, - style: material.IconButton.styleFrom( - minimumSize: const material.Size(28, 28), - padding: material.EdgeInsets.zero, + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), ), - ), - ], + ], + ), ), ), ), ), - ), - // Footer - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - 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, + // Footer + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + 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/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 88bf852..179028a 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -338,9 +338,11 @@ class _MongoConnectionFormContentState maxHeight: WindowLayout.connectionFormMongoMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: Column( @@ -370,25 +372,27 @@ class _MongoConnectionFormContentState ), const material.Divider(height: 1), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Connection string toggle - material.Row( - children: [ - material.Checkbox( - value: _useConnectionString, - onChanged: (v) { - setState(() => _useConnectionString = v ?? false); - _formValidNotifier.seed(); - }, - ), - const Gap(8), - const Text('Use connection string').small(), - ], - ), + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Connection string toggle + material.Row( + children: [ + material.Checkbox( + value: _useConnectionString, + onChanged: (v) { + setState(() => _useConnectionString = v ?? false); + _formValidNotifier.seed(); + }, + ), + const Gap(8), + const Text('Use connection string').small(), + ], + ), const Gap(16), if (_useConnectionString) ...[ const Text('Connection String').small().semiBold(), @@ -578,6 +582,7 @@ class _MongoConnectionFormContentState ), ), ), + ), const material.Divider(height: 1), if (_testResult != null) material.Padding( @@ -645,60 +650,64 @@ class _MongoConnectionFormContentState ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - 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.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + 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, ), - ) - : 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/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 47f573f..8cb7cfd 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -303,9 +303,11 @@ class _MysqlConnectionFormContentState maxHeight: WindowLayout.connectionFormMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -345,14 +347,16 @@ class _MysqlConnectionFormContentState ), const material.Divider(height: 1), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Connection Name').small().semiBold(), - const Gap(8), - TextField( + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( controller: _nameController, placeholder: const Text('My MySQL Server'), ), @@ -472,16 +476,17 @@ class _MysqlConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), - if (_useSSL) ...[ - const Gap(16), - SslCertificateFields( - rootCertController: _sslRootCertController, - clientCertController: _sslCertController, - clientKeyController: _sslKeyController, - onChanged: _syncUriSslParams, - ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], - ], + ), ), ), ), @@ -552,68 +557,72 @@ class _MysqlConnectionFormContentState ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - 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.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + 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, ), - ) - : 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/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 4442e66..2d35191 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -420,9 +420,11 @@ class _PostgresConnectionFormContentState maxHeight: WindowLayout.connectionFormMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ // Header material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), @@ -464,168 +466,199 @@ class _PostgresConnectionFormContentState const material.Divider(height: 1), // Form body material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Connection Name - const Text('Connection Name').small().semiBold(), - const Gap(8), - TextField( - controller: _nameController, - placeholder: const Text('My PostgreSQL Server'), - ), - const Gap(16), - const Text('Connection URI (optional)').small().semiBold(), - const Gap(4), - const Text( - 'If set, overrides Host / Port / Database below.', - ).muted().small(), - const Gap(4), - const Text( - 'Supported query params include sslmode (disable, require, ' - 'verify-ca, verify-full), connect_timeout and query_timeout ' - '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', - ).muted().small(), - const Gap(8), - TextField( - controller: _connectionStringController, - placeholder: const Text( - 'postgresql://user:pass@host:5432/dbname?sslmode=require', + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Connection Name + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('My PostgreSQL Server'), ), - ), - const Gap(16), - // Host + Port - material.Row( - children: [ - material.Expanded( - flex: 3, + const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'If set, overrides Host / Port / Database below.', + ).muted().small(), + const Gap(4), + const Text( + 'Supported query params include sslmode (disable, require, ' + 'verify-ca, verify-full), connect_timeout and query_timeout ' + '(seconds). If sslmode is omitted, Use SSL/TLS below applies.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text( + 'postgresql://user:pass@host:5432/dbname?sslmode=require', + ), + ), + const Gap(16), + // Host and Port Row + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Host').small().semiBold(), + const Gap(8), + TextField( + controller: _hostController, + placeholder: const Text('localhost'), + ), + ], + ), + ), + const Gap(16), + material.Expanded( + flex: 2, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Port').small().semiBold(), + const Gap(8), + TextField( + controller: _portController, + placeholder: const Text('5432'), + ), + ], + ), + ), + ], + ), + const Gap(16), + // Database + const Text('Database').small().semiBold(), + const Gap(8), + TextField( + controller: _databaseController, + placeholder: const Text('postgres'), + ), + const Gap(16), + // Username and Password Row + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Username').small().semiBold(), + const Gap(8), + TextField( + controller: _usernameController, + placeholder: const Text('postgres'), + ), + ], + ), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + children: [ + const Text('Password').small().semiBold(), + const Gap(8), + material.Stack( + children: [ + TextField( + controller: _passwordController, + placeholder: Text( + _isEditing + ? 'Leave blank to keep existing' + : 'Password', + ), + obscureText: !_showPassword, + ), + material.Positioned( + right: 8, + top: 0, + bottom: 0, + child: material.Center( + child: material.IconButton( + icon: material.Icon( + _showPassword + ? material.Icons.visibility_off + : material.Icons.visibility, + size: 20, + ), + onPressed: () => setState( + () => _showPassword = !_showPassword), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const Gap(16), + // SSL/TLS Toggle + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + if (_useSSL) ...[ + const Gap(16), + material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, children: [ - const Text('Host').small().semiBold(), + const Text('SSL Certificates (optional)') + .small() + .semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are ' + 'appended to the connection URI.', + ).muted().small(), const Gap(8), - TextField( - controller: _hostController, - placeholder: const Text('localhost'), + _buildSslFileField( + label: 'Root CA / SSL Root Certificate', + controller: _sslRootCertController, ), - ], - ), - ), - const Gap(12), - material.Expanded( - flex: 1, - child: material.Column( - crossAxisAlignment: - material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('Port').small().semiBold(), const Gap(8), - TextField( - controller: _portController, - placeholder: const Text('5432'), + _buildSslFileField( + label: 'SSL Client Certificate', + controller: _sslCertController, ), - ], - ), - ), - ], - ), - const Gap(16), - // Database - const Text('Database').small().semiBold(), - const Gap(8), - TextField( - controller: _databaseController, - placeholder: const Text('postgres'), - ), - const Gap(16), - // Username - const Text('Username').small().semiBold(), - const Gap(8), - TextField( - controller: _usernameController, - placeholder: const Text('postgres'), - ), - const Gap(16), - // Password - const Text('Password').small().semiBold(), - const Gap(8), - material.Stack( - children: [ - TextField( - controller: _passwordController, - placeholder: Text( - _isEditing - ? 'Leave blank to keep existing' - : 'Password', - ), - obscureText: !_showPassword, - ), - material.Positioned( - right: 8, - top: 0, - bottom: 0, - child: material.Center( - child: material.IconButton( - icon: material.Icon( - _showPassword - ? material.Icons.visibility_off - : material.Icons.visibility, - size: 20, + const Gap(8), + _buildSslFileField( + label: 'SSL Client Key', + controller: _sslKeyController, ), - onPressed: () => setState( - () => _showPassword = !_showPassword), - padding: material.EdgeInsets.zero, - constraints: const material.BoxConstraints(), - ), + ], ), ), ], - ), - const Gap(16), - // SSL toggle - material.Row( - children: [ - material.Checkbox( - value: _useSSL, - onChanged: (v) => - setState(() => _useSSL = v ?? false), - ), - const Gap(8), - const Text('Use SSL/TLS').small(), - ], - ), - if (_useSSL) ...[ - const Gap(16), - const Text('SSL Certificates (optional)') - .small() - .semiBold(), - const Gap(4), - const Text( - 'Root CA, client certificate, and client key are ' - 'appended to the connection URI.', - ).muted().small(), - const Gap(8), - _buildSslFileField( - label: 'Root CA / SSL Root Certificate', - controller: _sslRootCertController, - ), - const Gap(8), - _buildSslFileField( - label: 'SSL Client Certificate', - controller: _sslCertController, - ), - const Gap(8), - _buildSslFileField( - label: 'SSL Client Key', - controller: _sslKeyController, - ), ], - ], + ), ), ), ), @@ -698,68 +731,72 @@ class _PostgresConnectionFormContentState ), ), // Footer — Wrap avoids overflow on narrow viewports (e.g. in tests) - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - 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.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + 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, ), - ) - : 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 f006c45..4f98d43 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -277,9 +277,11 @@ class _RedisConnectionFormContentState maxHeight: WindowLayout.connectionFormMaxHeight, ), borderColor: theme.muted, - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), child: material.Column( @@ -304,14 +306,16 @@ class _RedisConnectionFormContentState ), const material.Divider(height: 1), material.Expanded( - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - const Text('Connection Name').small().semiBold(), - const Gap(8), - TextField( + child: material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( controller: _nameController, placeholder: const Text('My Redis Server'), ), @@ -435,6 +439,7 @@ class _RedisConnectionFormContentState ), ), ), + ), const material.Divider(height: 1), if (_testResult != null) material.Padding( @@ -502,60 +507,64 @@ class _RedisConnectionFormContentState ), ), ), - material.Container( - padding: const material.EdgeInsets.symmetric( - horizontal: 24, vertical: 16), - 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.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + 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, ), - ) - : 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/ssl_certificate_fields.dart b/lib/shared/widgets/ssl_certificate_fields.dart index 7b21480..cfadd96 100644 --- a/lib/shared/widgets/ssl_certificate_fields.dart +++ b/lib/shared/widgets/ssl_certificate_fields.dart @@ -19,35 +19,38 @@ class SslCertificateFields extends material.StatelessWidget { @override material.Widget build(material.BuildContext context) { - return material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - const Text('SSL Certificates (optional)').small().semiBold(), - const Gap(4), - const Text( - 'Root CA, client certificate, and client key are appended to the ' - 'connection URI.', - ).muted().small(), - const Gap(8), - _SslFileField( - label: 'Root CA / SSL Root Certificate', - controller: rootCertController, - onChanged: onChanged, - ), - const Gap(8), - _SslFileField( - label: 'SSL Client Certificate', - controller: clientCertController, - onChanged: onChanged, - ), - const Gap(8), - _SslFileField( - label: 'SSL Client Key', - controller: clientKeyController, - onChanged: onChanged, - ), - ], + return material.FocusTraversalGroup( + policy: material.WidgetOrderTraversalPolicy(), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('SSL Certificates (optional)').small().semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are appended to the ' + 'connection URI.', + ).muted().small(), + const Gap(8), + _SslFileField( + label: 'Root CA / SSL Root Certificate', + controller: rootCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Certificate', + controller: clientCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Key', + controller: clientKeyController, + onChanged: onChanged, + ), + ], + ), ); } } diff --git a/test/features/connections/connection_dialog_focus_traversal_test.dart b/test/features/connections/connection_dialog_focus_traversal_test.dart new file mode 100644 index 0000000..50cc21d --- /dev/null +++ b/test/features/connections/connection_dialog_focus_traversal_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/new_connection_url_dialog.dart'; +import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('Connection Dialogs Focus Traversal', () { + testWidgets('NewConnectionUrlDialog contains FocusTraversalGroups with WidgetOrderTraversalPolicy', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showNewConnectionUrlDialog(context), + child: const material.Text('Open URL Dialog'), + ), + ), + ), + ); + + await tester.tap(find.text('Open URL Dialog')); + await tester.pumpAndSettle(); + + final widgetOrderGroups = tester + .widgetList( + find.byType(material.FocusTraversalGroup), + ) + .where((g) => g.policy is material.WidgetOrderTraversalPolicy) + .toList(); + + expect(widgetOrderGroups.length, greaterThanOrEqualTo(2)); + }); + + testWidgets('showPostgresConnectionForm contains FocusTraversalGroups with WidgetOrderTraversalPolicy', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showPostgresConnectionForm(context), + child: const material.Text('Open Postgres Form'), + ), + ), + ), + ); + + await tester.tap(find.text('Open Postgres Form')); + await tester.pumpAndSettle(); + + final widgetOrderGroups = tester + .widgetList( + find.byType(material.FocusTraversalGroup), + ) + .where((g) => g.policy is material.WidgetOrderTraversalPolicy) + .toList(); + + expect(widgetOrderGroups.length, greaterThanOrEqualTo(3)); + }); + + testWidgets('SslCertificateFields is wrapped in FocusTraversalGroup with WidgetOrderTraversalPolicy', (tester) async { + final rootCertCtrl = TextEditingController(); + final clientCertCtrl = TextEditingController(); + final clientKeyCtrl = TextEditingController(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SslCertificateFields( + rootCertController: rootCertCtrl, + clientCertController: clientCertCtrl, + clientKeyController: clientKeyCtrl, + ), + ), + ), + ); + + final traversalGroup = find.descendant( + of: find.byType(SslCertificateFields), + matching: find.byType(material.FocusTraversalGroup), + ); + expect(traversalGroup, findsOneWidget); + + final groupWidget = tester.widget(traversalGroup); + expect(groupWidget.policy, isA()); + }); + }); +}