diff --git a/lib/shared/widgets/querya_badge.dart b/lib/shared/widgets/querya_badge.dart new file mode 100644 index 00000000..bd7ada99 --- /dev/null +++ b/lib/shared/widgets/querya_badge.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// Semantic status variant for [QueryaBadge.status]. +enum QueryaBadgeStatus { + success, + warning, + error, + info, + neutral, +} + +enum _BadgeVariant { + custom, + primaryKey, + foreignKey, + dataType, + status, + readOnly, +} + +/// Standard semantic badge widget for database types, constraints, and status indicators. +class QueryaBadge extends material.StatelessWidget { + const QueryaBadge({ + super.key, + required this.label, + this.icon, + this.backgroundColor, + this.foregroundColor, + this.borderColor, + this.isMonospace = false, + }) : _variant = _BadgeVariant.custom, + _status = null; + + /// Primary Key badge (distinctive type1 gold/amber badge). + const QueryaBadge.primaryKey({ + super.key, + this.label = 'PK', + }) : icon = null, + backgroundColor = null, + foregroundColor = null, + borderColor = null, + isMonospace = false, + _variant = _BadgeVariant.primaryKey, + _status = null; + + /// Foreign Key badge (distinctive type2 blue/cyan badge). + const QueryaBadge.foreignKey({ + super.key, + this.label = 'FK', + }) : icon = null, + backgroundColor = null, + foregroundColor = null, + borderColor = null, + isMonospace = false, + _variant = _BadgeVariant.foreignKey, + _status = null; + + /// SQL / NoSQL data type badge (monospace label with subtle tint). + const QueryaBadge.dataType( + this.label, { + super.key, + }) : icon = null, + backgroundColor = null, + foregroundColor = null, + borderColor = null, + isMonospace = true, + _variant = _BadgeVariant.dataType, + _status = null; + + /// Operational or connection status badge. + const QueryaBadge.status( + this.label, { + super.key, + required QueryaBadgeStatus status, + }) : icon = null, + backgroundColor = null, + foregroundColor = null, + borderColor = null, + isMonospace = false, + _variant = _BadgeVariant.status, + _status = status; + + /// Read-only indicator badge. + const QueryaBadge.readOnly({ + super.key, + this.label = 'READ ONLY', + }) : icon = null, + backgroundColor = null, + foregroundColor = null, + borderColor = null, + isMonospace = false, + _variant = _BadgeVariant.readOnly, + _status = null; + + final String label; + final material.Widget? icon; + final material.Color? backgroundColor; + final material.Color? foregroundColor; + final material.Color? borderColor; + final bool isMonospace; + + final _BadgeVariant _variant; + final QueryaBadgeStatus? _status; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + final palette = context.semanticPalette; + + material.Color fg; + material.Color bg; + material.Color border; + material.Widget? effectiveIcon = icon; + String effectiveLabel = label; + + switch (_variant) { + case _BadgeVariant.primaryKey: + fg = palette.type1; + bg = fg.withValues(alpha: 0.12); + border = fg.withValues(alpha: 0.3); + effectiveIcon = const material.Icon(material.Icons.vpn_key_rounded); + break; + case _BadgeVariant.foreignKey: + fg = palette.type2; + bg = fg.withValues(alpha: 0.12); + border = fg.withValues(alpha: 0.3); + effectiveIcon = const material.Icon(material.Icons.link_rounded); + break; + case _BadgeVariant.dataType: + fg = workbench?.mutedForeground ?? cs.mutedForeground; + bg = cs.muted.withValues(alpha: 0.3); + border = cs.border.withValues(alpha: 0.5); + effectiveLabel = label.toUpperCase(); + break; + case _BadgeVariant.status: + material.IconData? statusIcon; + switch (_status ?? QueryaBadgeStatus.neutral) { + case QueryaBadgeStatus.success: + fg = workbench?.success ?? palette.success; + statusIcon = material.Icons.check_circle_outline_rounded; + break; + case QueryaBadgeStatus.warning: + fg = workbench?.warning ?? const material.Color(0xFFE88C30); + statusIcon = material.Icons.warning_amber_rounded; + break; + case QueryaBadgeStatus.error: + fg = workbench?.destructive ?? palette.destructive; + statusIcon = material.Icons.error_outline_rounded; + break; + case QueryaBadgeStatus.info: + fg = workbench?.accent ?? cs.primary; + statusIcon = material.Icons.info_outline_rounded; + break; + case QueryaBadgeStatus.neutral: + fg = workbench?.mutedForeground ?? cs.mutedForeground; + statusIcon = null; + break; + } + bg = fg.withValues(alpha: 0.12); + border = fg.withValues(alpha: 0.3); + if (statusIcon != null) { + effectiveIcon = material.Icon(statusIcon); + } + break; + case _BadgeVariant.readOnly: + fg = workbench?.mutedForeground ?? cs.mutedForeground; + bg = cs.muted.withValues(alpha: 0.4); + border = cs.border.withValues(alpha: 0.6); + effectiveIcon = const material.Icon(material.Icons.lock_outline_rounded); + break; + case _BadgeVariant.custom: + fg = foregroundColor ?? cs.foreground; + bg = backgroundColor ?? cs.muted.withValues(alpha: 0.35); + border = borderColor ?? fg.withValues(alpha: 0.2); + break; + } + + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular(4), + border: material.Border.all(color: border, width: 1), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + if (effectiveIcon != null) ...[ + material.IconTheme( + data: material.IconThemeData( + size: 11, + color: fg, + ), + child: effectiveIcon, + ), + const Gap(4), + ], + material.Text( + effectiveLabel, + style: material.TextStyle( + fontSize: 10, + fontWeight: material.FontWeight.w600, + fontFamily: isMonospace ? 'monospace' : null, + color: fg, + letterSpacing: 0.3, + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/querya_empty_state.dart b/lib/shared/widgets/querya_empty_state.dart new file mode 100644 index 00000000..e08814f7 --- /dev/null +++ b/lib/shared/widgets/querya_empty_state.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// Standard empty state placeholder for lists, search results, tabs, and query outputs. +class QueryaEmptyState extends material.StatelessWidget { + const QueryaEmptyState({ + super.key, + required this.title, + this.description, + this.icon, + this.action, + this.actionLabel, + this.onAction, + this.compact = false, + }); + + /// Main message or title. + final String title; + + /// Optional secondary explanatory text. + final String? description; + + /// Optional icon widget or icon data. + final material.Widget? icon; + + /// Optional custom action widget (e.g. a button). + final material.Widget? action; + + /// Optional text label for default action button. + final String? actionLabel; + + /// Optional callback for default action button. + final material.VoidCallback? onAction; + + /// When true, renders with tighter padding and smaller fonts (suitable for sidebars or popovers). + final bool compact; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + + final iconColor = (workbench?.mutedForeground ?? cs.mutedForeground).withValues(alpha: 0.6); + final iconBgColor = cs.muted.withValues(alpha: 0.25); + + material.Widget? iconWidget = icon; + if (iconWidget != null) { + final iconDimension = compact ? 36.0 : 54.0; + final iconSize = compact ? 18.0 : 28.0; + + iconWidget = material.Container( + width: iconDimension, + height: iconDimension, + decoration: material.BoxDecoration( + color: iconBgColor, + shape: material.BoxShape.circle, + ), + child: material.Center( + child: material.IconTheme( + data: material.IconThemeData( + size: iconSize, + color: iconColor, + ), + child: iconWidget, + ), + ), + ); + } + + material.Widget? actionWidget = action; + if (actionWidget == null && actionLabel != null && onAction != null) { + actionWidget = OutlineButton( + size: compact ? ButtonSize.small : ButtonSize.normal, + onPressed: onAction, + child: Text(actionLabel!), + ); + } + + return material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(compact ? 16.0 : 32.0), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + if (iconWidget != null) ...[ + iconWidget, + Gap(compact ? 10 : 16), + ], + material.Text( + title, + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: compact ? 13 : 15, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + if (description != null && description!.isNotEmpty) ...[ + Gap(compact ? 4 : 6), + material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: 360), + child: material.Text( + description!, + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: compact ? 11 : 12, + color: workbench?.mutedForeground ?? cs.mutedForeground, + height: 1.4, + ), + ), + ), + ], + if (actionWidget != null) ...[ + Gap(compact ? 12 : 20), + actionWidget, + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/querya_icon_button.dart b/lib/shared/widgets/querya_icon_button.dart new file mode 100644 index 00000000..bd4352f0 --- /dev/null +++ b/lib/shared/widgets/querya_icon_button.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; + +/// Density sizing presets for Querya icon and toolbar buttons. +enum QueryaIconButtonDensity { + /// 28x28 box with 15px icon — ideal for dense trees, tab bars, and data grid calc bars. + dense(28, 15), + + /// 32x32 box with 18px icon — standard for top toolbars and header chrome. + standard(32, 18); + + const QueryaIconButtonDensity(this.boxSize, this.iconSize); + + final double boxSize; + final double iconSize; +} + +/// Standard icon button with density presets, hover/active states, and built-in tooltip. +class QueryaIconButton extends material.StatelessWidget { + const QueryaIconButton({ + super.key, + required this.icon, + this.tooltip, + this.onPressed, + this.density = QueryaIconButtonDensity.standard, + this.isActive = false, + this.isDestructive = false, + this.color, + this.activeColor, + this.borderRadius, + }); + + /// The icon widget or Icon to render. + final material.Widget icon; + + /// Optional tooltip message shown on hover using [kQueryaTooltipWait]. + final String? tooltip; + + /// Callback when pressed. If null, the button is disabled. + final material.VoidCallback? onPressed; + + /// Sizing density: dense (28px) or standard (32px). + final QueryaIconButtonDensity density; + + /// Whether the button is currently in an active/toggled state. + final bool isActive; + + /// Whether clicking triggers a destructive action (tinted red on hover). + final bool isDestructive; + + /// Custom icon color in normal state. + final material.Color? color; + + /// Custom icon color in active state. + final material.Color? activeColor; + + /// Custom border radius. Defaults to 6px. + final material.BorderRadius? borderRadius; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + final radius = borderRadius ?? material.BorderRadius.circular(6); + + final isEnabled = onPressed != null; + + final destructiveColor = workbench?.destructive ?? cs.destructive; + final accentColor = workbench?.accent ?? cs.primary; + final mutedColor = workbench?.mutedForeground ?? cs.mutedForeground; + + // Normal icon color + final normalColor = color ?? + (isDestructive + ? destructiveColor + : (isEnabled ? cs.foreground : cs.mutedForeground.withValues(alpha: 0.5))); + + // Effective icon color + final effectiveIconColor = isActive + ? (activeColor ?? (isDestructive ? destructiveColor : accentColor)) + : normalColor; + + // Effective background color for active state + material.Color? effectiveBgColor; + if (isActive) { + effectiveBgColor = isDestructive + ? destructiveColor.withValues(alpha: 0.16) + : accentColor.withValues(alpha: 0.14); + } + + final hoverColor = isDestructive + ? destructiveColor.withValues(alpha: 0.12) + : cs.muted.withValues(alpha: 0.35); + + material.Widget button = material.Material( + color: effectiveBgColor ?? material.Colors.transparent, + shape: material.RoundedRectangleBorder( + borderRadius: radius, + side: isActive + ? material.BorderSide( + color: (isDestructive ? destructiveColor : accentColor) + .withValues(alpha: 0.3), + width: 1, + ) + : material.BorderSide.none, + ), + clipBehavior: material.Clip.antiAlias, + child: material.InkWell( + onTap: onPressed, + borderRadius: radius, + hoverColor: hoverColor, + splashColor: (isDestructive ? destructiveColor : accentColor) + .withValues(alpha: 0.2), + child: material.SizedBox( + width: density.boxSize, + height: density.boxSize, + child: material.Center( + child: material.IconTheme( + data: material.IconThemeData( + size: density.iconSize, + color: isEnabled + ? effectiveIconColor + : mutedColor.withValues(alpha: 0.4), + ), + child: icon, + ), + ), + ), + ), + ); + + if (tooltip != null && tooltip!.isNotEmpty) { + button = material.Tooltip( + message: tooltip!, + waitDuration: kQueryaTooltipWait, + child: button, + ); + } + + return button; + } +} + +/// Standard toolbar button with optional leading icon, label, and shortcut hint. +class QueryaToolbarButton extends material.StatelessWidget { + const QueryaToolbarButton({ + super.key, + required this.label, + this.icon, + this.tooltip, + this.shortcutHint, + this.onPressed, + this.density = QueryaIconButtonDensity.standard, + this.isActive = false, + this.isDestructive = false, + }); + + final String label; + final material.Widget? icon; + final String? tooltip; + final String? shortcutHint; + final material.VoidCallback? onPressed; + final QueryaIconButtonDensity density; + final bool isActive; + final bool isDestructive; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + final isEnabled = onPressed != null; + + final destructiveColor = workbench?.destructive ?? cs.destructive; + final accentColor = workbench?.accent ?? cs.primary; + final radius = material.BorderRadius.circular(6); + + final effectiveFgColor = isActive + ? (isDestructive ? destructiveColor : accentColor) + : (isDestructive + ? destructiveColor + : (isEnabled ? cs.foreground : cs.mutedForeground.withValues(alpha: 0.5))); + + final effectiveBgColor = isActive + ? (isDestructive + ? destructiveColor.withValues(alpha: 0.16) + : accentColor.withValues(alpha: 0.14)) + : material.Colors.transparent; + + final hoverColor = isDestructive + ? destructiveColor.withValues(alpha: 0.12) + : cs.muted.withValues(alpha: 0.35); + + material.Widget content = material.Row( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + if (icon != null) ...[ + material.IconTheme( + data: material.IconThemeData( + size: density.iconSize, + color: effectiveFgColor, + ), + child: icon!, + ), + const Gap(6), + ], + material.Text( + label, + style: material.TextStyle( + fontSize: density == QueryaIconButtonDensity.dense ? 11 : 12, + fontWeight: material.FontWeight.w500, + color: effectiveFgColor, + ), + ), + if (shortcutHint != null) ...[ + const Gap(6), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.4), + borderRadius: material.BorderRadius.circular(3), + ), + child: material.Text( + shortcutHint!, + style: material.TextStyle( + fontSize: 9, + fontWeight: material.FontWeight.w500, + color: cs.mutedForeground, + ), + ), + ), + ], + ], + ); + + material.Widget button = material.Material( + color: effectiveBgColor, + shape: material.RoundedRectangleBorder( + borderRadius: radius, + side: isActive + ? material.BorderSide( + color: (isDestructive ? destructiveColor : accentColor) + .withValues(alpha: 0.3), + width: 1, + ) + : material.BorderSide.none, + ), + clipBehavior: material.Clip.antiAlias, + child: material.InkWell( + onTap: onPressed, + borderRadius: radius, + hoverColor: hoverColor, + child: material.Container( + height: density.boxSize, + padding: material.EdgeInsets.symmetric( + horizontal: density == QueryaIconButtonDensity.dense ? 8 : 10, + ), + alignment: material.Alignment.center, + child: content, + ), + ), + ); + + if (tooltip != null && tooltip!.isNotEmpty) { + button = material.Tooltip( + message: tooltip!, + waitDuration: kQueryaTooltipWait, + child: button, + ); + } + + return button; + } +} diff --git a/lib/shared/widgets/querya_modal_dialog.dart b/lib/shared/widgets/querya_modal_dialog.dart new file mode 100644 index 00000000..229f3f53 --- /dev/null +++ b/lib/shared/widgets/querya_modal_dialog.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +import 'app_dialog.dart'; +import 'querya_dialog_card.dart'; + +/// Standard modal dialog shell with title, optional description, icon, content, and actions. +class QueryaModalDialog extends material.StatelessWidget { + const QueryaModalDialog({ + super.key, + required this.title, + this.description, + this.icon, + this.iconColor, + this.iconBackgroundColor, + this.content, + this.actions, + this.constraints = const material.BoxConstraints(maxWidth: 480), + this.onClose, + this.showCloseButton = false, + }); + + /// Dialog title (plain string or custom widget). + final material.Widget title; + + /// Optional subtitle or explanatory description. + final material.Widget? description; + + /// Optional header icon. + final material.Widget? icon; + + /// Foreground color for the icon. Defaults to primary/accent color. + final material.Color? iconColor; + + /// Background color for the icon badge. Defaults to iconColor with alpha. + final material.Color? iconBackgroundColor; + + /// Main dialog body content. + final material.Widget? content; + + /// Bottom action buttons (e.g. Cancel, Submit). + final List? actions; + + /// Card sizing constraints. Defaults to max width of 480px. + final material.BoxConstraints constraints; + + /// Callback when close button is clicked. + final material.VoidCallback? onClose; + + /// Whether to show the top-right close button. + final bool showCloseButton; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + + material.Widget? leadingIcon = icon; + if (leadingIcon != null) { + final effectiveIconColor = + iconColor ?? workbench?.accent ?? cs.primary; + final effectiveBgColor = iconBackgroundColor ?? + effectiveIconColor.withValues(alpha: 0.14); + + leadingIcon = material.Container( + padding: const material.EdgeInsets.all(8), + decoration: material.BoxDecoration( + color: effectiveBgColor, + borderRadius: material.BorderRadius.circular(8), + ), + child: material.IconTheme( + data: material.IconThemeData( + color: effectiveIconColor, + size: 22, + ), + child: leadingIcon, + ), + ); + } + + return QueryaDialogCard( + constraints: constraints, + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Header + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (leadingIcon != null) ...[ + leadingIcon, + const Gap(14), + ], + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.DefaultTextStyle( + style: material.TextStyle( + fontSize: 16, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + child: title, + ), + if (description != null) ...[ + const Gap(4), + material.DefaultTextStyle( + style: material.TextStyle( + fontSize: 13, + color: workbench?.mutedForeground ?? cs.mutedForeground, + ), + child: description!, + ), + ], + ], + ), + ), + if (showCloseButton || onClose != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 8), + child: material.IconButton( + icon: const material.Icon(material.Icons.close_rounded, size: 18), + splashRadius: 16, + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + color: cs.mutedForeground, + hoverColor: cs.muted.withValues(alpha: 0.3), + onPressed: onClose ?? () => Navigator.of(context).pop(), + ), + ), + ], + ), + + // Content + if (content != null) ...[ + const Gap(16), + content!, + ], + + // Actions + if (actions != null && actions!.isNotEmpty) ...[ + const Gap(20), + material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + for (var i = 0; i < actions!.length; i++) ...[ + if (i > 0) const Gap(8), + actions![i], + ], + ], + ), + ], + ], + ), + ), + ); + } +} + +/// Standard confirmation dialog with Confirm and Cancel actions. +class QueryaConfirmDialog extends material.StatelessWidget { + const QueryaConfirmDialog({ + super.key, + required this.title, + required this.message, + this.confirmLabel = 'Confirm', + this.cancelLabel = 'Cancel', + this.isDestructive = false, + this.icon, + this.onConfirm, + this.onCancel, + }); + + final String title; + final String message; + final String confirmLabel; + final String cancelLabel; + final bool isDestructive; + final material.IconData? icon; + final material.VoidCallback? onConfirm; + final material.VoidCallback? onCancel; + + /// Displays the confirmation dialog and returns true if confirmed, false otherwise. + static Future show({ + required material.BuildContext context, + required String title, + required String message, + String confirmLabel = 'Confirm', + String cancelLabel = 'Cancel', + bool isDestructive = false, + material.IconData? icon, + bool barrierDismissible = true, + }) { + return showAppDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (ctx) => QueryaConfirmDialog( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + isDestructive: isDestructive, + icon: icon, + onConfirm: () => Navigator.of(ctx).pop(true), + onCancel: () => Navigator.of(ctx).pop(false), + ), + ); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + + final effectiveIconData = icon ?? + (isDestructive + ? material.Icons.warning_amber_rounded + : material.Icons.help_outline_rounded); + + final iconColor = isDestructive + ? (workbench?.destructive ?? cs.destructive) + : (workbench?.accent ?? cs.primary); + + return QueryaModalDialog( + title: Text(title), + description: Text(message), + icon: material.Icon(effectiveIconData), + iconColor: iconColor, + actions: [ + OutlineButton( + onPressed: onCancel ?? () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + if (isDestructive) + DestructiveButton( + onPressed: onConfirm ?? () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ) + else + PrimaryButton( + onPressed: onConfirm ?? () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/querya_search_field.dart b/lib/shared/widgets/querya_search_field.dart new file mode 100644 index 00000000..18cf5293 --- /dev/null +++ b/lib/shared/widgets/querya_search_field.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// Standard search and filter input field with debounce, clear button, and shortcut support. +class QueryaSearchField extends material.StatefulWidget { + const QueryaSearchField({ + super.key, + this.controller, + this.focusNode, + this.placeholder = 'Search...', + this.onChanged, + this.onSubmitted, + this.debounceDuration = const Duration(milliseconds: 250), + this.autofocus = false, + this.height = 32, + this.shortcutHint, + this.width, + }); + + /// Optional external controller. If omitted, an internal controller is managed. + final material.TextEditingController? controller; + + /// Optional external focus node. + final material.FocusNode? focusNode; + + /// Placeholder hint text. + final String placeholder; + + /// Callback fired after debounce duration when input text changes. + final ValueChanged? onChanged; + + /// Callback fired immediately upon Enter / submit. + final ValueChanged? onSubmitted; + + /// Debounce delay. Set to [Duration.zero] for immediate invocation. + final Duration debounceDuration; + + /// Whether to autofocus this search field. + final bool autofocus; + + /// Sizing height. Default is 32px for compact toolbar fit. + final double height; + + /// Optional keybinding hint displayed at the trailing end (e.g. 'ESC', 'Ctrl+F'). + final String? shortcutHint; + + /// Optional fixed width constraint. + final double? width; + + @override + material.State createState() => _QueryaSearchFieldState(); +} + +class _QueryaSearchFieldState extends material.State { + late material.TextEditingController _controller; + late material.FocusNode _focusNode; + Timer? _debounceTimer; + bool _hasText = false; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? material.TextEditingController(); + _focusNode = widget.focusNode ?? material.FocusNode(); + _hasText = _controller.text.isNotEmpty; + + _controller.addListener(_handleControllerChange); + } + + @override + void didUpdateWidget(covariant QueryaSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != null && widget.controller != _controller) { + if (oldWidget.controller == null) { + _controller.dispose(); + } else { + _controller.removeListener(_handleControllerChange); + } + _controller = widget.controller!; + _hasText = _controller.text.isNotEmpty; + _controller.addListener(_handleControllerChange); + } + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _controller.removeListener(_handleControllerChange); + if (widget.controller == null) { + _controller.dispose(); + } + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _handleControllerChange() { + final hasText = _controller.text.isNotEmpty; + if (hasText != _hasText) { + setState(() => _hasText = hasText); + } + } + + void _onInputChanged(String value) { + if (widget.onChanged == null) return; + + if (widget.debounceDuration == Duration.zero) { + widget.onChanged!(value); + return; + } + + _debounceTimer?.cancel(); + _debounceTimer = Timer(widget.debounceDuration, () { + if (mounted) { + widget.onChanged!(value); + } + }); + } + + void _clear() { + _debounceTimer?.cancel(); + _controller.clear(); + widget.onChanged?.call(''); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + + final searchIconColor = _hasText + ? (workbench?.accent ?? cs.primary) + : (workbench?.mutedForeground ?? cs.mutedForeground); + + material.Widget content = material.Container( + height: widget.height, + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: _hasText + ? (workbench?.accent ?? cs.primary).withValues(alpha: 0.5) + : cs.border.withValues(alpha: 0.7), + width: 1, + ), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + material.Padding( + padding: const material.EdgeInsets.only(left: 8, right: 6), + child: material.Icon( + material.Icons.search_rounded, + size: 16, + color: searchIconColor, + ), + ), + material.Expanded( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () { + if (_hasText) { + _clear(); + } else { + _focusNode.unfocus(); + } + }, + }, + child: material.TextField( + controller: _controller, + focusNode: _focusNode, + autofocus: widget.autofocus, + onChanged: _onInputChanged, + onSubmitted: widget.onSubmitted, + style: material.TextStyle( + fontSize: 12, + color: cs.foreground, + ), + cursorHeight: 14, + cursorColor: workbench?.accent ?? cs.primary, + decoration: material.InputDecoration( + hintText: widget.placeholder, + hintStyle: material.TextStyle( + fontSize: 12, + color: workbench?.mutedForeground ?? cs.mutedForeground, + ), + isDense: true, + border: material.InputBorder.none, + focusedBorder: material.InputBorder.none, + enabledBorder: material.InputBorder.none, + contentPadding: const material.EdgeInsets.symmetric(vertical: 7), + ), + ), + ), + ), + if (_hasText) + material.Padding( + padding: const material.EdgeInsets.only(right: 4), + child: material.IconButton( + icon: const material.Icon(material.Icons.close_rounded, size: 14), + splashRadius: 12, + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + color: cs.mutedForeground, + hoverColor: cs.muted.withValues(alpha: 0.3), + tooltip: 'Clear search (Esc)', + onPressed: _clear, + ), + ) + else if (widget.shortcutHint != null) + material.Padding( + padding: const material.EdgeInsets.only(right: 6), + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 4, vertical: 1.5), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.3), + borderRadius: material.BorderRadius.circular(3), + ), + child: material.Text( + widget.shortcutHint!, + style: material.TextStyle( + fontSize: 9, + fontWeight: material.FontWeight.w500, + color: cs.mutedForeground, + ), + ), + ), + ), + ], + ), + ); + + if (widget.width != null) { + content = material.SizedBox(width: widget.width, child: content); + } + + return content; + } +} diff --git a/lib/shared/widgets/querya_spinner.dart b/lib/shared/widgets/querya_spinner.dart new file mode 100644 index 00000000..02c2a75a --- /dev/null +++ b/lib/shared/widgets/querya_spinner.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; + +/// Preset sizes for the [QueryaSpinner] indicator. +enum QueryaSpinnerSize { + /// Small (14px diameter, strokeWidth 2.0) — for buttons, tree rows, and inline badges. + sm(14, 2.0), + + /// Medium (20px diameter, strokeWidth 2.5) — for toolbars, dialogs, and panels. + md(20, 2.5), + + /// Large (32px diameter, strokeWidth 3.0) — for full page loaders and tab overviews. + lg(32, 3.0); + + const QueryaSpinnerSize(this.dimension, this.strokeWidth); + + final double dimension; + final double strokeWidth; +} + +/// Theme-aware progress indicator with size presets and optional inline label. +class QueryaSpinner extends material.StatelessWidget { + const QueryaSpinner({ + super.key, + this.size = QueryaSpinnerSize.md, + this.customDimension, + this.strokeWidth, + this.color, + this.label, + }); + + /// Preset size: [QueryaSpinnerSize.sm], [QueryaSpinnerSize.md], or [QueryaSpinnerSize.lg]. + final QueryaSpinnerSize size; + + /// Custom dimension (width and height in px) overriding preset. + final double? customDimension; + + /// Custom stroke width overriding preset. + final double? strokeWidth; + + /// Custom spinner color. If omitted, uses the active theme's accent color. + final material.Color? color; + + /// Optional label displayed to the right of the spinner. + final String? label; + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final workbench = QueryaThemeScope.maybeOf(context)?.workbench; + + final dimension = customDimension ?? size.dimension; + final stroke = strokeWidth ?? size.strokeWidth; + final effectiveColor = color ?? workbench?.accent ?? cs.primary; + + final spinner = material.SizedBox( + width: dimension, + height: dimension, + child: material.CircularProgressIndicator( + strokeWidth: stroke, + valueColor: material.AlwaysStoppedAnimation(effectiveColor), + ), + ); + + if (label == null || label!.isEmpty) { + return spinner; + } + + return material.Row( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.center, + children: [ + spinner, + const Gap(8), + material.Text( + label!, + style: material.TextStyle( + fontSize: size == QueryaSpinnerSize.sm ? 11 : 12, + color: workbench?.mutedForeground ?? cs.mutedForeground, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 85dea9c7..e5824624 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -20,6 +20,12 @@ export 'querya_dropdown.dart' QueryaDropdownTokens, kPreferencesLabelWidth; export 'connection_tree_loading_row.dart'; +export 'querya_badge.dart'; +export 'querya_empty_state.dart'; +export 'querya_icon_button.dart'; +export 'querya_modal_dialog.dart'; +export 'querya_search_field.dart'; +export 'querya_spinner.dart'; export 'tree_load_error.dart'; export 'package:querya_desktop/core/motion/querya_motion.dart'; export 'package:querya_desktop/core/motion/querya_motion_context.dart'; diff --git a/test/shared/widgets/querya_badge_test.dart b/test/shared/widgets/querya_badge_test.dart new file mode 100644 index 00000000..e6e5edd0 --- /dev/null +++ b/test/shared/widgets/querya_badge_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaBadge', () { + testWidgets('renders primary key badge with key icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaBadge.primaryKey(), + ), + ); + + expect(find.text('PK'), findsOneWidget); + expect(find.byIcon(material.Icons.vpn_key_rounded), findsOneWidget); + }); + + testWidgets('renders foreign key badge with link icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaBadge.foreignKey(), + ), + ); + + expect(find.text('FK'), findsOneWidget); + expect(find.byIcon(material.Icons.link_rounded), findsOneWidget); + }); + + testWidgets('renders data type badge in uppercase', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaBadge.dataType('uuid'), + ), + ); + + expect(find.text('UUID'), findsOneWidget); + }); + + testWidgets('renders status badge with status icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaBadge.status( + 'Connected', + status: QueryaBadgeStatus.success, + ), + ), + ); + + expect(find.text('Connected'), findsOneWidget); + expect(find.byIcon(material.Icons.check_circle_outline_rounded), findsOneWidget); + }); + + testWidgets('renders read-only badge with lock icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaBadge.readOnly(), + ), + ); + + expect(find.text('READ ONLY'), findsOneWidget); + expect(find.byIcon(material.Icons.lock_outline_rounded), findsOneWidget); + }); + }); +} diff --git a/test/shared/widgets/querya_empty_state_test.dart b/test/shared/widgets/querya_empty_state_test.dart new file mode 100644 index 00000000..b44c59ab --- /dev/null +++ b/test/shared/widgets/querya_empty_state_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaEmptyState', () { + testWidgets('renders title, description, icon, and handles action', (tester) async { + var actionFired = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaEmptyState( + title: 'No collections found', + description: 'Try adjusting your search filter or create a new collection.', + icon: const material.Icon(material.Icons.folder_open_rounded), + actionLabel: 'Create Collection', + onAction: () => actionFired = true, + ), + ), + ); + + expect(find.text('No collections found'), findsOneWidget); + expect( + find.text('Try adjusting your search filter or create a new collection.'), + findsOneWidget, + ); + expect(find.byIcon(material.Icons.folder_open_rounded), findsOneWidget); + expect(find.text('Create Collection'), findsOneWidget); + + await tester.tap(find.text('Create Collection')); + expect(actionFired, isTrue); + }); + + testWidgets('renders properly in compact mode', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaEmptyState( + title: 'No matches', + compact: true, + ), + ), + ); + + expect(find.text('No matches'), findsOneWidget); + }); + }); +} diff --git a/test/shared/widgets/querya_icon_button_test.dart b/test/shared/widgets/querya_icon_button_test.dart new file mode 100644 index 00000000..0755d28b --- /dev/null +++ b/test/shared/widgets/querya_icon_button_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaIconButton', () { + testWidgets('renders icon and triggers onPressed', (tester) async { + var pressed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaIconButton( + icon: const material.Icon(material.Icons.play_arrow), + tooltip: 'Run Query', + onPressed: () => pressed = true, + ), + ), + ); + + expect(find.byIcon(material.Icons.play_arrow), findsOneWidget); + expect(find.byType(material.Tooltip), findsOneWidget); + + await tester.tap(find.byIcon(material.Icons.play_arrow)); + expect(pressed, isTrue); + }); + + testWidgets('respects dense density preset (28px)', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaIconButton( + icon: material.Icon(material.Icons.refresh), + density: QueryaIconButtonDensity.dense, + ), + ), + ); + + final box = tester.widget( + find.ancestor( + of: find.byIcon(material.Icons.refresh), + matching: find.byType(material.SizedBox), + ), + ); + + expect(box.width, 28); + expect(box.height, 28); + }); + + testWidgets('respects standard density preset (32px)', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaIconButton( + icon: material.Icon(material.Icons.refresh), + density: QueryaIconButtonDensity.standard, + ), + ), + ); + + final box = tester.widget( + find.ancestor( + of: find.byIcon(material.Icons.refresh), + matching: find.byType(material.SizedBox), + ), + ); + + expect(box.width, 32); + expect(box.height, 32); + }); + }); + + group('QueryaToolbarButton', () { + testWidgets('renders label, icon, shortcut hint, and handles tap', (tester) async { + var clicked = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaToolbarButton( + label: 'Filter', + icon: const material.Icon(material.Icons.filter_list), + shortcutHint: 'Ctrl+F', + onPressed: () => clicked = true, + ), + ), + ); + + expect(find.text('Filter'), findsOneWidget); + expect(find.byIcon(material.Icons.filter_list), findsOneWidget); + expect(find.text('Ctrl+F'), findsOneWidget); + + await tester.tap(find.text('Filter')); + expect(clicked, isTrue); + }); + }); +} diff --git a/test/shared/widgets/querya_modal_dialog_test.dart b/test/shared/widgets/querya_modal_dialog_test.dart new file mode 100644 index 00000000..9e1386a2 --- /dev/null +++ b/test/shared/widgets/querya_modal_dialog_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaModalDialog', () { + testWidgets('renders title, description, content, and actions', (tester) async { + var actionClicked = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaModalDialog( + title: const Text('Dialog Title'), + description: const Text('Dialog Subtitle'), + icon: const material.Icon(material.Icons.info_outline), + content: const Text('Dialog Content Body'), + actions: [ + OutlineButton( + onPressed: () => actionClicked = true, + child: const Text('Action Button'), + ), + ], + ), + ), + ); + + expect(find.text('Dialog Title'), findsOneWidget); + expect(find.text('Dialog Subtitle'), findsOneWidget); + expect(find.text('Dialog Content Body'), findsOneWidget); + expect(find.text('Action Button'), findsOneWidget); + + await tester.tap(find.text('Action Button')); + expect(actionClicked, isTrue); + }); + + testWidgets('renders close button when showCloseButton is true', (tester) async { + var closed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaModalDialog( + title: const Text('Title'), + showCloseButton: true, + onClose: () => closed = true, + ), + ), + ); + + final closeButton = find.byIcon(material.Icons.close_rounded); + expect(closeButton, findsOneWidget); + + await tester.tap(closeButton); + expect(closed, isTrue); + }); + }); + + group('QueryaConfirmDialog', () { + testWidgets('renders confirmation UI with standard buttons', (tester) async { + var confirmed = false; + var cancelled = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaConfirmDialog( + title: 'Delete Item?', + message: 'Are you sure you want to proceed?', + confirmLabel: 'Proceed', + cancelLabel: 'Abort', + onConfirm: () => confirmed = true, + onCancel: () => cancelled = true, + ), + ), + ); + + expect(find.text('Delete Item?'), findsOneWidget); + expect(find.text('Are you sure you want to proceed?'), findsOneWidget); + expect(find.text('Proceed'), findsOneWidget); + expect(find.text('Abort'), findsOneWidget); + + await tester.tap(find.text('Proceed')); + expect(confirmed, isTrue); + + await tester.tap(find.text('Abort')); + expect(cancelled, isTrue); + }); + + testWidgets('renders destructive confirm button when isDestructive is true', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaConfirmDialog( + title: 'Drop Table?', + message: 'This will destroy all data.', + isDestructive: true, + confirmLabel: 'Drop', + ), + ), + ); + + expect(find.byType(DestructiveButton), findsOneWidget); + expect(find.text('Drop'), findsOneWidget); + }); + }); +} diff --git a/test/shared/widgets/querya_search_field_test.dart b/test/shared/widgets/querya_search_field_test.dart new file mode 100644 index 00000000..c375f09e --- /dev/null +++ b/test/shared/widgets/querya_search_field_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaSearchField', () { + testWidgets('renders placeholder and search icon', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaSearchField( + placeholder: 'Filter collections...', + ), + ), + ); + + expect(find.text('Filter collections...'), findsOneWidget); + expect(find.byIcon(material.Icons.search_rounded), findsOneWidget); + }); + + testWidgets('shows clear button and invokes clear', (tester) async { + final controller = material.TextEditingController(text: 'users'); + String? changedValue; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaSearchField( + controller: controller, + debounceDuration: Duration.zero, + onChanged: (val) => changedValue = val, + ), + ), + ); + + final clearBtn = find.byIcon(material.Icons.close_rounded); + expect(clearBtn, findsOneWidget); + + await tester.tap(clearBtn); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(changedValue, isEmpty); + expect(find.byIcon(material.Icons.close_rounded), findsNothing); + }); + + testWidgets('invokes onChanged with debounce', (tester) async { + String? searchResult; + + await tester.pumpWidget( + queryaThemeTestShell( + child: QueryaSearchField( + debounceDuration: const Duration(milliseconds: 100), + onChanged: (val) => searchResult = val, + ), + ), + ); + + await tester.enterText(find.byType(material.TextField), 'query'); + expect(searchResult, isNull); + + await tester.pump(const Duration(milliseconds: 150)); + expect(searchResult, 'query'); + }); + }); +} diff --git a/test/shared/widgets/querya_spinner_test.dart b/test/shared/widgets/querya_spinner_test.dart new file mode 100644 index 00000000..32958e10 --- /dev/null +++ b/test/shared/widgets/querya_spinner_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('QueryaSpinner', () { + testWidgets('renders indicator with small preset (14px)', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaSpinner( + size: QueryaSpinnerSize.sm, + ), + ), + ); + + final indicator = tester.widget( + find.byType(material.CircularProgressIndicator), + ); + expect(indicator.strokeWidth, 2.0); + + final box = tester.widget( + find.ancestor( + of: find.byType(material.CircularProgressIndicator), + matching: find.byType(material.SizedBox), + ), + ); + expect(box.width, 14); + expect(box.height, 14); + }); + + testWidgets('renders indicator with large preset (32px)', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaSpinner( + size: QueryaSpinnerSize.lg, + ), + ), + ); + + final box = tester.widget( + find.ancestor( + of: find.byType(material.CircularProgressIndicator), + matching: find.byType(material.SizedBox), + ), + ); + expect(box.width, 32); + expect(box.height, 32); + }); + + testWidgets('renders label alongside spinner when provided', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const QueryaSpinner( + label: 'Executing query...', + ), + ), + ); + + expect(find.text('Executing query...'), findsOneWidget); + expect(find.byType(material.CircularProgressIndicator), findsOneWidget); + }); + }); +}