diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart index 70db9a3..69d06c0 100644 --- a/lib/core/sdui/sdui_tree_builder.dart +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Renders a sidebar-style tree from an SDUI schema with lazy expansion. @@ -224,8 +226,11 @@ class SduiTreeBuilderState extends material.State { ) else material.Icon( - _iconFor(node), - size: 16, + QueryaIcons.sduiNodeIcon( + node.icon, + expandable: node.expandable, + ), + size: QueryaIconSizes.sduiNode, ), const Gap(8), material.Expanded( @@ -252,32 +257,4 @@ class SduiTreeBuilderState extends material.State { final parts = node.id.split('.'); return parts.isNotEmpty ? parts.first : ''; } - - material.IconData _iconFor(SduiTreeNode node) { - switch (node.icon) { - case 'database': - return material.Icons.storage_outlined; - case 'table': - return material.Icons.table_chart_outlined; - case 'view': - case 'eye': - return material.Icons.visibility_outlined; - case 'folder': - case 'folder-table': - return material.Icons.folder_outlined; - case 'folder-eye': - return material.Icons.folder_special_outlined; - case 'folder-book': - case 'book': - return material.Icons.menu_book_outlined; - case 'columns': - return material.Icons.view_column_outlined; - case 'archive': - return material.Icons.inventory_2_outlined; - default: - return node.expandable - ? material.Icons.folder_outlined - : material.Icons.insert_drive_file_outlined; - } - } } diff --git a/lib/core/ui/querya_icon_sizes.dart b/lib/core/ui/querya_icon_sizes.dart new file mode 100644 index 0000000..6a9e48d --- /dev/null +++ b/lib/core/ui/querya_icon_sizes.dart @@ -0,0 +1,23 @@ +/// Semantic icon sizes for connection trees and shared chrome. +abstract final class QueryaIconSizes { + /// Leaf row icon (table name, view name, …). + static const double treeLeaf = 12; + + /// Group / schema / default tree row icon. + static const double treeGroup = 13; + + /// Expand chevron in tree rows. + static const double treeExpand = 13; + + /// Database / connection-level tree nodes. + static const double treeConnection = 14; + + /// Inline tree error indicator. + static const double treeError = 14; + + /// SDUI explorer tree nodes. + static const double sduiNode = 16; + + /// Menu / dialog leading icons. + static const double menuLeading = 18; +} diff --git a/lib/core/ui/querya_icons.dart b/lib/core/ui/querya_icons.dart new file mode 100644 index 0000000..f1d4daf --- /dev/null +++ b/lib/core/ui/querya_icons.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart' as material; + +/// Shared Material icon registry for connection trees and chrome. +abstract final class QueryaIcons { + // -- Tree entity icons (rounded, aligned across PG / MySQL / SQLite) -- + + static const material.IconData expandClosed = + material.Icons.chevron_right_rounded; + + static const material.IconData databasesFolder = + material.Icons.dns_rounded; + static const material.IconData database = material.Icons.storage_rounded; + static const material.IconData schemasFolder = + material.Icons.account_tree_rounded; + static const material.IconData schema = material.Icons.diamond_rounded; + static const material.IconData extension = material.Icons.extension_rounded; + static const material.IconData publicSchema = material.Icons.public_rounded; + + static const material.IconData tableGroup = + material.Icons.table_chart_rounded; + static const material.IconData tableLeaf = material.Icons.grid_on_rounded; + static const material.IconData viewGroup = material.Icons.view_agenda_rounded; + static const material.IconData viewLeaf = material.Icons.view_week_rounded; + static const material.IconData materializedViewGroup = + material.Icons.dynamic_feed_rounded; + static const material.IconData functionGroup = + material.Icons.functions_rounded; + static const material.IconData functionLeaf = material.Icons.code_rounded; + static const material.IconData sequence = + material.Icons.format_list_numbered_rounded; + static const material.IconData indexes = material.Icons.table_rows_rounded; + static const material.IconData triggers = material.Icons.bolt_rounded; + static const material.IconData types = material.Icons.category_rounded; + + static const material.IconData treeError = + material.Icons.error_outline_rounded; + static const material.IconData folder = material.Icons.folder_rounded; + + // -- Built-in connection types -- + + static material.IconData connectionIcon(String type) => switch (type) { + 'mongodb' => material.Icons.eco_rounded, + 'postgresql' => material.Icons.storage_rounded, + 'mysql' => material.Icons.table_chart_rounded, + 'redis' => material.Icons.memory_rounded, + 'sqlite' => material.Icons.folder_open_rounded, + _ => material.Icons.extension_rounded, + }; + + static String? connectionAsset(String type) => switch (type) { + 'postgresql' => 'assets/images/postgresql_icon.png', + 'mysql' => 'assets/images/mysql_icon.png', + 'redis' => 'assets/images/redis_icon.png', + 'mongodb' => 'assets/images/mongodb_icon.png', + _ => null, + }; + + // -- SDUI tree nodes (rounded to match native trees) -- + + static material.IconData sduiNodeIcon( + String? icon, { + required bool expandable, + }) { + switch (icon) { + case 'database': + return database; + case 'table': + return tableGroup; + case 'view': + case 'eye': + return viewGroup; + case 'folder': + case 'folder-table': + return folder; + case 'folder-eye': + return material.Icons.folder_special_rounded; + case 'folder-book': + case 'book': + return material.Icons.menu_book_rounded; + case 'columns': + return material.Icons.view_column_rounded; + case 'archive': + return material.Icons.inventory_2_rounded; + default: + return expandable + ? folder + : material.Icons.insert_drive_file_rounded; + } + } +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index d7f6d10..958721a 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -42,7 +42,6 @@ import 'package:flutter/material.dart' as material Colors, Tooltip, Color, - SelectableText, Padding, Widget, Navigator, @@ -67,6 +66,8 @@ import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; @@ -395,29 +396,6 @@ class ConnectionsPanelState extends State { } } - /// Icon for a connection type (matches New Connection dialog). - material.IconData _iconForType(String type) { - return switch (type) { - 'mongodb' => material.Icons.eco_rounded, - 'postgresql' => material.Icons.storage_rounded, - 'mysql' => material.Icons.table_chart_rounded, - 'redis' => material.Icons.memory_rounded, - 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.extension_rounded, - }; - } - - /// Asset path for connection type logo (null = use icon). - static String? _iconAssetForType(String type) { - return switch (type) { - 'postgresql' => 'assets/images/postgresql_icon.png', - 'mysql' => 'assets/images/mysql_icon.png', - 'redis' => 'assets/images/redis_icon.png', - 'mongodb' => 'assets/images/mongodb_icon.png', - _ => null, - }; - } - Widget _buildConnectionTile(ConnectionRow conn) { final isSelected = widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; @@ -436,8 +414,8 @@ class ConnectionsPanelState extends State { return _PostgresConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, @@ -449,8 +427,8 @@ class ConnectionsPanelState extends State { return _MysqlConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, @@ -462,8 +440,8 @@ class ConnectionsPanelState extends State { return _RedisConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), @@ -474,8 +452,8 @@ class ConnectionsPanelState extends State { return _MongoConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), @@ -486,8 +464,8 @@ class ConnectionsPanelState extends State { return _SqliteConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, @@ -499,8 +477,8 @@ class ConnectionsPanelState extends State { return _ExtensionConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), onObjectSelected: widget.onExtensionObjectSelected, @@ -511,8 +489,8 @@ class ConnectionsPanelState extends State { return _ConnectionTile( connection: conn, isSelected: isSelected, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), + icon: QueryaIcons.connectionIcon(conn.type), + iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onTap: () => widget.onConnectionSelected?.call(conn), ); @@ -602,7 +580,7 @@ class ConnectionsPanelState extends State { .getFolderIdByName(folderName); await _createConnection(folderId: folderId); }, - iconForType: _iconForType, + iconForType: QueryaIcons.connectionIcon, onRemoveConnection: _removeConnection, onConnectionTap: widget.onConnectionSelected, onRedisDatabaseTap: diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 6d343cf..19036f2 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -287,29 +287,14 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { ), ) else if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( left: 28, top: 4, bottom: 8, ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadTree, - child: const Text('Retry'), - ), - ], - ), + onRetry: _loadTree, ) else if (_schema != null) material.Padding( diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index cc1c5d4..a34e064 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -281,51 +281,18 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + title: 'Could not load databases', + message: _error!, + showTitleRow: true, + detailFontSize: 10, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4, right: 8), - child: material.ConstrainedBox( - constraints: const material.BoxConstraints( - maxWidth: double.infinity), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Row( - crossAxisAlignment: - material.CrossAxisAlignment.start, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 14, - color: theme.colorScheme.destructive, - ), - const Gap(6), - material.Expanded( - child: material.Text( - 'Could not load databases', - maxLines: 2, - overflow: material.TextOverflow.ellipsis, - style: material.TextStyle( - fontSize: 12, - color: theme.colorScheme.destructive, - ), - ), - ), - ], - ), - const Gap(6), - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 10, - height: 1.35, - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), + left: 28, + top: 4, + bottom: 4, + right: 8, ), + onRetry: _loadDatabases, ), for (final db in _databases) _MongoDatabaseNode( @@ -370,8 +337,8 @@ class _MongoDatabaseNode extends StatelessWidget { padding: const material.EdgeInsets.only(left: 16, top: 2, bottom: 2), child: _PgTreeRow( label: name, - icon: material.Icons.storage_rounded, - iconSize: 13, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index 3d801c6..98fe6e5 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -165,7 +165,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 16, color: theme.colorScheme.mutedForeground, ), @@ -244,16 +244,14 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), if (_databases.isNotEmpty) _MysqlDatabasesNode( @@ -307,8 +305,8 @@ class _MysqlDatabasesNode extends material.StatelessWidget { children: [ _PgTreeRow( label: 'Databases (${databases.length})', - icon: material.Icons.dns_rounded, - iconSize: 14, + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -445,13 +443,13 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.storage_rounded, - iconSize: 14, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -490,29 +488,9 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadTables, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + message: _error!, + onRetry: _loadTables, ), if (_tables.isNotEmpty || _views.isNotEmpty || @@ -530,8 +508,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.table, onRefresh: _loadTables, label: 'Tables', - icon: material.Icons.table_chart_rounded, - itemIcon: material.Icons.grid_on_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -549,8 +527,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.view, onRefresh: _loadTables, label: 'Views', - icon: material.Icons.view_agenda_rounded, - itemIcon: material.Icons.view_week_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -568,8 +546,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.procedure, onRefresh: _loadTables, label: 'Procedures', - icon: material.Icons.functions_rounded, - itemIcon: material.Icons.code_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _procedures, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -587,8 +565,8 @@ class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { objectKind: MysqlObjectKind.function, onRefresh: _loadTables, label: 'Functions', - icon: material.Icons.functions_rounded, - itemIcon: material.Icons.code_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _functions, onItemTap: widget.onMysqlObjectSelected == null ? null @@ -657,13 +635,13 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -688,7 +666,7 @@ class _MysqlObjectGroupState extends State<_MysqlObjectGroup> { ), label: item, icon: widget.itemIcon, - iconSize: 12, + iconSize: QueryaIconSizes.treeLeaf, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, diff --git a/lib/features/connections/connections_panel_pg_tree.dart b/lib/features/connections/connections_panel_pg_tree.dart index 51ead1e..a24d20d 100644 --- a/lib/features/connections/connections_panel_pg_tree.dart +++ b/lib/features/connections/connections_panel_pg_tree.dart @@ -61,7 +61,7 @@ class _PgTreeRow extends material.StatelessWidget { required this.label, this.leading, this.icon, - this.iconSize = 13, + this.iconSize = QueryaIconSizes.treeGroup, this.iconColor, this.trailing, this.onTap, @@ -216,13 +216,13 @@ class _PgDatabasesNodeState extends State<_PgDatabasesNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.dns_rounded, - iconSize: 14, + icon: QueryaIcons.databasesFolder, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -343,13 +343,13 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.storage_rounded, - iconSize: 14, + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), textStyle: material.TextStyle( fontSize: 12, @@ -371,7 +371,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Extensions', - icon: material.Icons.extension_rounded, + icon: QueryaIcons.extension, kind: PostgresObjectKind.databaseExtensions, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -381,7 +381,7 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { connection: widget.connection, databaseName: widget.databaseName, label: 'Foreign data', - icon: material.Icons.public_rounded, + icon: QueryaIcons.publicSchema, kind: PostgresObjectKind.databaseForeignData, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -405,29 +405,9 @@ class _PgDatabaseNodeState extends State<_PgDatabaseNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadSchemas, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + message: _error!, + onRetry: _loadSchemas, ), if (_schemas.isNotEmpty) _PgSchemasNode( @@ -484,11 +464,11 @@ class _PgDbToolRow extends material.StatelessWidget { child: _PgTreeRow( label: label, icon: icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: muted, trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: muted, ), onTap: onPostgresObjectSelected == null @@ -558,13 +538,13 @@ class _PgSchemasNodeState extends State<_PgSchemasNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.account_tree_rounded, - iconSize: 13, + icon: QueryaIcons.schemasFolder, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -714,13 +694,13 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 14, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), - icon: material.Icons.diamond_outlined, - iconSize: 13, + icon: QueryaIcons.schema, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.primary.withValues(alpha: 0.6), textStyle: material.TextStyle( fontSize: 12, @@ -755,29 +735,9 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { ), ) else if (_error != null) - material.Padding( - padding: const material.EdgeInsets.only( - left: 24, - top: 4, - bottom: 8, - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.SelectableText( - _error!, - style: material.TextStyle( - fontSize: 11, - color: theme.colorScheme.destructive, - ), - ), - const material.SizedBox(height: 6), - GhostButton( - onPressed: _loadObjects, - child: const Text('Retry'), - ), - ], - ), + TreeLoadError( + message: _error!, + onRetry: _loadObjects, ), if (_loaded && _error == null) ...[ _PgObjectGroup( @@ -789,7 +749,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Tables', - icon: material.Icons.table_chart_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -810,7 +771,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Views', - icon: material.Icons.view_agenda_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -831,7 +793,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Materialized views', - icon: material.Icons.dynamic_feed_rounded, + icon: QueryaIcons.materializedViewGroup, + itemIcon: QueryaIcons.materializedViewGroup, items: _matviews, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -852,7 +815,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Functions', - icon: material.Icons.functions_rounded, + icon: QueryaIcons.functionGroup, + itemIcon: QueryaIcons.functionLeaf, items: _functions, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -873,7 +837,8 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { widget.onPostgresOpenSqlWorkspace, onRefresh: _loadObjects, label: 'Sequences', - icon: material.Icons.format_list_numbered_rounded, + icon: QueryaIcons.sequence, + itemIcon: QueryaIcons.sequence, items: _sequences, onItemTap: widget.onPostgresObjectSelected != null ? (name) => widget.onPostgresObjectSelected!( @@ -890,7 +855,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Indexes', - icon: material.Icons.table_rows_rounded, + icon: QueryaIcons.indexes, kind: PostgresObjectKind.schemaIndexes, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -902,7 +867,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Triggers', - icon: material.Icons.bolt_rounded, + icon: QueryaIcons.triggers, kind: PostgresObjectKind.schemaTriggers, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -914,7 +879,7 @@ class _PgSchemaNodeState extends State<_PgSchemaNode> { databaseName: widget.databaseName, schemaName: widget.schemaName, label: 'Types', - icon: material.Icons.category_rounded, + icon: QueryaIcons.types, kind: PostgresObjectKind.schemaTypes, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -969,11 +934,11 @@ class _PgSchemaToolRow extends material.StatelessWidget { child: _PgTreeRow( label: label, icon: icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: muted, trailing: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: muted, ), onTap: onPostgresObjectSelected == null @@ -1006,6 +971,7 @@ class _PgObjectGroup extends StatefulWidget { required this.onRefresh, required this.label, required this.icon, + required this.itemIcon, required this.items, this.onPostgresOpenSqlWorkspace, this.onItemTap, @@ -1018,6 +984,7 @@ class _PgObjectGroup extends StatefulWidget { final VoidCallback onRefresh; final String label; final material.IconData icon; + final material.IconData itemIcon; final List items; final OnPostgresOpenSqlWorkspace? onPostgresOpenSqlWorkspace; final void Function(String itemName)? onItemTap; @@ -1045,13 +1012,13 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, - size: 13, + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -1076,8 +1043,8 @@ class _PgObjectGroupState extends State<_PgObjectGroup> { 'pg-${widget.objectKind.name}-${widget.databaseName}-${widget.schemaName}-$item', ), label: item, - icon: widget.icon, - iconSize: 12, + icon: widget.itemIcon, + iconSize: QueryaIconSizes.treeLeaf, iconColor: theme.colorScheme.primary.withValues(alpha: 0.5), textStyle: material.TextStyle( fontSize: 11, diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 5021b92..5bccba4 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -239,19 +239,14 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Tooltip( - message: _error!, - child: material.Text( - _error!, - overflow: material.TextOverflow.ellipsis, - maxLines: 2, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), - ), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadDatabases, ), if (_databases.isNotEmpty) _PgDatabasesNode( diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index cc6d687..c23254e 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -312,7 +312,7 @@ class _RedisDatabaseNode extends StatelessWidget { child: material.Row( children: [ material.Icon( - material.Icons.dns_rounded, + QueryaIcons.databasesFolder, size: 14, color: keys > 0 ? theme.colorScheme.primary.withValues(alpha: 0.7) diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index d054ee5..8a540a9 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -296,7 +296,7 @@ class _FolderTileState extends State<_FolderTile> { key: material.ValueKey('folder-conn-${conn.id}'), connection: conn, icon: widget.iconForType(conn.type), - iconAsset: ConnectionsPanelState._iconAssetForType( + iconAsset: QueryaIcons.connectionAsset( conn.type, ), onRemove: () => widget.onRemoveConnection(conn.id!), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index b403fb6..2efdee5 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -172,7 +172,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 16, color: theme.colorScheme.mutedForeground, ), @@ -251,16 +251,14 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { ), ), if (_error != null) - material.Padding( + TreeLoadError( + message: _error!, padding: const material.EdgeInsets.only( - left: 28, top: 4, bottom: 4), - child: material.Text( - 'Error loading schema', - overflow: material.TextOverflow.ellipsis, - maxLines: 1, - style: material.TextStyle( - fontSize: 11, color: theme.colorScheme.destructive), + left: 28, + top: 4, + bottom: 4, ), + onRetry: _loadTables, ), if (!_loading && _error == null) material.Padding( @@ -274,8 +272,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { objectKind: SqliteObjectKind.table, onRefresh: _loadTables, label: 'Tables', - icon: material.Icons.table_chart_rounded, - itemIcon: material.Icons.grid_on_rounded, + icon: QueryaIcons.tableGroup, + itemIcon: QueryaIcons.tableLeaf, items: _tables, onItemTap: widget.onSqliteObjectSelected == null ? null @@ -291,8 +289,8 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { objectKind: SqliteObjectKind.view, onRefresh: _loadTables, label: 'Views', - icon: material.Icons.view_agenda_rounded, - itemIcon: material.Icons.view_week_rounded, + icon: QueryaIcons.viewGroup, + itemIcon: QueryaIcons.viewLeaf, items: _views, onItemTap: widget.onSqliteObjectSelected == null ? null @@ -367,13 +365,13 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.standardCurve), child: material.Icon( - material.Icons.chevron_right_rounded, + QueryaIcons.expandClosed, size: 13, color: theme.colorScheme.mutedForeground, ), ), icon: widget.icon, - iconSize: 13, + iconSize: QueryaIconSizes.treeGroup, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, @@ -398,7 +396,7 @@ class _SqliteObjectGroupState extends State<_SqliteObjectGroup> { ), label: item, icon: widget.itemIcon, - iconSize: 12, + iconSize: QueryaIconSizes.treeLeaf, iconColor: theme.colorScheme.mutedForeground, textStyle: material.TextStyle( fontSize: 11, diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index c487676..25995f4 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -27,22 +28,10 @@ extension ConnectionTypeX on ConnectionType { ConnectionType.mongodb => 'MongoDB', ConnectionType.sqlite => 'SQLite', }; - material.IconData get icon => switch (this) { - ConnectionType.postgresql => material.Icons.storage_rounded, - ConnectionType.mysql => material.Icons.table_chart_rounded, - ConnectionType.redis => material.Icons.memory_rounded, - ConnectionType.mongodb => material.Icons.eco_rounded, - ConnectionType.sqlite => material.Icons.folder_open_rounded, - }; + material.IconData get icon => QueryaIcons.connectionIcon(name); /// Asset path for custom icon (from Downloads). - String? get iconAsset => switch (this) { - ConnectionType.postgresql => 'assets/images/postgresql_icon.png', - ConnectionType.mysql => 'assets/images/mysql_icon.png', - ConnectionType.redis => 'assets/images/redis_icon.png', - ConnectionType.mongodb => 'assets/images/mongodb_icon.png', - ConnectionType.sqlite => null, - }; + String? get iconAsset => QueryaIcons.connectionAsset(name); bool get isSql => this == ConnectionType.postgresql || this == ConnectionType.mysql || diff --git a/lib/features/main_screen/workspace_empty_hero.dart b/lib/features/main_screen/workspace_empty_hero.dart index d0ee2a0..4555d85 100644 --- a/lib/features/main_screen/workspace_empty_hero.dart +++ b/lib/features/main_screen/workspace_empty_hero.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/motion/querya_stagger.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -361,8 +362,8 @@ class _RecentConnectionRow extends StatelessWidget { children: [ DriverIcon( size: 20, - fallbackIcon: _iconForType(connection.type), - assetPath: _iconAssetForType(connection.type), + fallbackIcon: QueryaIcons.connectionIcon(connection.type), + assetPath: QueryaIcons.connectionAsset(connection.type), ), const material.SizedBox(width: 12), material.Expanded( @@ -459,27 +460,6 @@ class _QuickStartRow extends StatelessWidget { } } -material.IconData _iconForType(String type) { - return switch (type) { - 'mongodb' => material.Icons.eco_rounded, - 'postgresql' => material.Icons.storage_rounded, - 'mysql' => material.Icons.table_chart_rounded, - 'redis' => material.Icons.memory_rounded, - 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.extension_rounded, - }; -} - -String? _iconAssetForType(String type) { - return switch (type) { - 'postgresql' => 'assets/images/postgresql_icon.png', - 'mysql' => 'assets/images/mysql_icon.png', - 'redis' => 'assets/images/redis_icon.png', - 'mongodb' => 'assets/images/mongodb_icon.png', - _ => null, - }; -} - String _connectionSubtitle(ConnectionRow connection) { if (connection.type == 'sqlite') { final path = connection.databaseName ?? connection.connectionString; diff --git a/lib/shared/widgets/tree_load_error.dart b/lib/shared/widgets/tree_load_error.dart new file mode 100644 index 0000000..7bc6afd --- /dev/null +++ b/lib/shared/widgets/tree_load_error.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Inline error block for connection tree lazy-load failures. +class TreeLoadError extends material.StatelessWidget { + const TreeLoadError({ + super.key, + this.title, + required this.message, + this.onRetry, + this.retryLabel = 'Retry', + this.padding = const material.EdgeInsets.only( + left: 24, + top: 4, + bottom: 8, + ), + this.detailFontSize = 11, + this.showTitleRow = false, + }); + + final String? title; + final String message; + final VoidCallback? onRetry; + final String retryLabel; + final material.EdgeInsetsGeometry padding; + final double detailFontSize; + final bool showTitleRow; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final destructive = theme.colorScheme.destructive; + final muted = theme.colorScheme.mutedForeground; + + return material.Padding( + padding: padding, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (showTitleRow && title != null) + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + QueryaIcons.treeError, + size: QueryaIconSizes.treeError, + color: destructive, + ), + const Gap(6), + material.Expanded( + child: material.Text( + title!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + color: destructive, + ), + ), + ), + ], + ), + if (showTitleRow && title != null) const Gap(6), + material.SelectableText( + message, + style: material.TextStyle( + fontSize: detailFontSize, + height: showTitleRow ? 1.35 : null, + color: showTitleRow ? muted : destructive, + ), + ), + if (onRetry != null) ...[ + const material.SizedBox(height: 6), + GhostButton( + onPressed: onRetry, + child: Text(retryLabel), + ), + ], + ], + ), + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 3fcb27e..4c9f5bb 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -18,4 +18,5 @@ export 'querya_dropdown.dart' QueryaDropdownItem, QueryaDropdownTokens, kPreferencesLabelWidth; +export 'tree_load_error.dart'; export 'package:shadcn_flutter/shadcn_flutter.dart'; diff --git a/test/core/ui/querya_icons_test.dart b/test/core/ui/querya_icons_test.dart new file mode 100644 index 0000000..bdeb3b5 --- /dev/null +++ b/test/core/ui/querya_icons_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/ui/querya_icon_sizes.dart'; +import 'package:querya_desktop/core/ui/querya_icons.dart'; + +void main() { + group('QueryaIcons.connectionIcon', () { + test('maps built-in connection types', () { + expect( + QueryaIcons.connectionIcon('postgresql'), + material.Icons.storage_rounded, + ); + expect( + QueryaIcons.connectionIcon('mysql'), + material.Icons.table_chart_rounded, + ); + expect( + QueryaIcons.connectionIcon('redis'), + material.Icons.memory_rounded, + ); + expect( + QueryaIcons.connectionIcon('mongodb'), + material.Icons.eco_rounded, + ); + expect( + QueryaIcons.connectionIcon('sqlite'), + material.Icons.folder_open_rounded, + ); + }); + + test('falls back to extension icon for unknown types', () { + expect( + QueryaIcons.connectionIcon('clickhouse'), + material.Icons.extension_rounded, + ); + }); + }); + + group('QueryaIcons.connectionAsset', () { + test('returns bundled logos for known SQL/NoSQL drivers', () { + expect( + QueryaIcons.connectionAsset('postgresql'), + 'assets/images/postgresql_icon.png', + ); + expect(QueryaIcons.connectionAsset('sqlite'), isNull); + }); + }); + + group('QueryaIcons.sduiNodeIcon', () { + test('uses rounded tree icons for SDUI nodes', () { + expect( + QueryaIcons.sduiNodeIcon('database', expandable: false), + QueryaIcons.database, + ); + expect( + QueryaIcons.sduiNodeIcon('table', expandable: false), + QueryaIcons.tableGroup, + ); + expect( + QueryaIcons.sduiNodeIcon(null, expandable: true), + QueryaIcons.folder, + ); + expect( + QueryaIcons.sduiNodeIcon(null, expandable: false), + material.Icons.insert_drive_file_rounded, + ); + }); + }); + + test('tree size tokens are ordered leaf < group < sdui', () { + expect(QueryaIconSizes.treeLeaf, lessThan(QueryaIconSizes.treeGroup)); + expect(QueryaIconSizes.treeGroup, lessThan(QueryaIconSizes.sduiNode)); + }); +} diff --git a/test/shared/widgets/tree_load_error_test.dart b/test/shared/widgets/tree_load_error_test.dart new file mode 100644 index 0000000..3aa281f --- /dev/null +++ b/test/shared/widgets/tree_load_error_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/tree_load_error.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('TreeLoadError shows message and retry', (tester) async { + var retried = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: TreeLoadError( + message: 'connection refused', + onRetry: () => retried = true, + ), + ), + ); + + expect(find.text('connection refused'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pump(); + + expect(retried, isTrue); + }); + + testWidgets('TreeLoadError title row uses error icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const TreeLoadError( + title: 'Could not load', + message: 'timeout', + showTitleRow: true, + ), + ), + ); + + expect(find.text('Could not load'), findsOneWidget); + expect(find.byIcon(material.Icons.error_outline_rounded), findsOneWidget); + }); +}