diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index d874bc97..7541cc4d 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -1,9 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +/// Debounce delay before requesting a hover preview load. +const Duration themePreviewDebounce = Duration(milliseconds: 120); + /// Dedicated theme picker for large registry lists (50+ themes). class ThemePickerButton extends material.StatefulWidget { const ThemePickerButton({ @@ -11,6 +18,7 @@ class ThemePickerButton extends material.StatefulWidget { required this.themes, required this.selectedThemeId, required this.onSelected, + this.onPreviewTheme, this.isLoading = false, this.expandToParent = false, this.width, @@ -19,6 +27,10 @@ class ThemePickerButton extends material.StatefulWidget { final List themes; final String? selectedThemeId; final material.ValueChanged onSelected; + + /// Loads preview data for [themeId] on hover. Must not apply the theme. + final Future Function(String themeId)? onPreviewTheme; + final bool isLoading; final bool expandToParent; final double? width; @@ -54,6 +66,13 @@ class _ThemePickerButtonState extends material.State { final material.TextEditingController _searchController = material.TextEditingController(); bool _triggerHovered = false; + String? _previewThemeId; + String? _previewThemeLabel; + QueryaTheme? _previewTheme; + String? _previewError; + bool _previewLoading = false; + Timer? _previewDebounce; + int _previewRequestSerial = 0; @override void initState() { @@ -63,12 +82,67 @@ class _ThemePickerButtonState extends material.State { @override void dispose() { + _previewDebounce?.cancel(); _searchController.removeListener(_onSearchChanged); _searchController.dispose(); _scrollController.dispose(); super.dispose(); } + void _resetPreviewState() { + _previewDebounce?.cancel(); + _previewRequestSerial++; + _previewThemeId = null; + _previewThemeLabel = null; + _previewTheme = null; + _previewError = null; + _previewLoading = false; + } + + void _schedulePreview(ThemeDefinition definition) { + if (widget.onPreviewTheme == null) return; + + _previewDebounce?.cancel(); + final targetId = definition.id; + setState(() { + _previewThemeId = targetId; + _previewThemeLabel = definition.name; + }); + + _previewDebounce = Timer(themePreviewDebounce, () { + if (!mounted || _previewThemeId != targetId) return; + setState(() { + _previewLoading = true; + _previewTheme = null; + _previewError = null; + }); + unawaited(_loadPreview(targetId)); + }); + } + + Future _loadPreview(String themeId) async { + final loader = widget.onPreviewTheme; + if (loader == null || !mounted || _previewThemeId != themeId) return; + + final requestId = ++_previewRequestSerial; + final result = await loader(themeId); + if (!mounted || requestId != _previewRequestSerial) return; + + setState(() { + _previewLoading = false; + switch (result) { + case ThemePreviewSuccess(:final theme): + _previewTheme = theme; + _previewError = null; + case ThemePreviewFailure(:final message): + _previewTheme = null; + _previewError = message; + case ThemePreviewLoading(): + _previewLoading = true; + } + }); + } + void _onSearchChanged() { setState(() {}); if (_scrollController.hasClients) { @@ -216,6 +290,21 @@ class _ThemePickerButtonState extends material.State { ), ), ), + if (widget.onPreviewTheme != null) + material.Padding( + padding: material.EdgeInsets.fromLTRB( + context.scaled(8), + context.scaled(4), + context.scaled(8), + context.scaled(4), + ), + child: ThemePreviewCard( + theme: _previewTheme, + errorMessage: _previewError, + isLoading: _previewLoading, + label: _previewThemeLabel, + ), + ), material.Expanded( child: filteredThemes.isEmpty ? material.Center( @@ -245,9 +334,13 @@ class _ThemePickerButtonState extends material.State { definition: theme, selected: theme.id == widget.selectedThemeId, colorScheme: cs, + onHover: widget.onPreviewTheme == null + ? null + : () => _schedulePreview(theme), onSelected: () { widget.onSelected(theme.id); _clearSearch(); + _resetPreviewState(); _controller.close(); }, ); @@ -332,6 +425,7 @@ class _ThemePickerButtonState extends material.State { controller.close(); } else { _clearSearch(); + _resetPreviewState(); controller.open(); } } @@ -349,12 +443,14 @@ class _ThemePickerRow extends material.StatefulWidget { required this.selected, required this.colorScheme, required this.onSelected, + this.onHover, }); final ThemeDefinition definition; final bool selected; final ColorScheme colorScheme; final material.VoidCallback onSelected; + final material.VoidCallback? onHover; @override material.State<_ThemePickerRow> createState() => _ThemePickerRowState(); @@ -377,7 +473,10 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> { return material.MouseRegion( cursor: material.SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), + onEnter: (_) { + setState(() => _hovered = true); + widget.onHover?.call(); + }, onExit: (_) => setState(() => _hovered = false), child: material.Material( type: material.MaterialType.transparency, diff --git a/lib/features/settings/theme_preview_card.dart b/lib/features/settings/theme_preview_card.dart new file mode 100644 index 00000000..c8d8afa7 --- /dev/null +++ b/lib/features/settings/theme_preview_card.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Result of an async theme preview load for [ThemePreviewCard]. +sealed class ThemePreviewResult { + const ThemePreviewResult(); + + const factory ThemePreviewResult.theme(QueryaTheme theme) = ThemePreviewSuccess; + const factory ThemePreviewResult.error(String message) = ThemePreviewFailure; + const factory ThemePreviewResult.loading() = ThemePreviewLoading; +} + +final class ThemePreviewSuccess extends ThemePreviewResult { + const ThemePreviewSuccess(this.theme); + final QueryaTheme theme; +} + +final class ThemePreviewFailure extends ThemePreviewResult { + const ThemePreviewFailure(this.message); + final String message; +} + +final class ThemePreviewLoading extends ThemePreviewResult { + const ThemePreviewLoading(); +} + +/// Compact visual preview for a [QueryaTheme] without applying it app-wide. +class ThemePreviewCard extends material.StatelessWidget { + const ThemePreviewCard({ + super.key, + this.theme, + this.errorMessage, + this.isLoading = false, + this.label, + }); + + final QueryaTheme? theme; + final String? errorMessage; + final bool isLoading; + final String? label; + + @override + material.Widget build(material.BuildContext context) { + final appScheme = Theme.of(context).colorScheme; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + if (isLoading) { + return _shell( + context: context, + radius: radius, + borderColor: appScheme.border, + child: material.Row( + children: [ + material.SizedBox( + width: context.scaled(14), + height: context.scaled(14), + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: appScheme.mutedForeground, + ), + ), + material.SizedBox(width: context.scaled(8)), + material.Text( + 'Loading preview…', + style: material.TextStyle( + fontSize: context.scaled(12), + color: appScheme.mutedForeground, + ), + ), + ], + ), + ); + } + + if (errorMessage != null) { + return _shell( + context: context, + radius: radius, + borderColor: appScheme.destructive.withValues(alpha: 0.45), + child: material.Text( + errorMessage!, + maxLines: 2, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(12), + color: appScheme.destructive, + ), + ), + ); + } + + final previewTheme = theme; + if (previewTheme == null) { + return _shell( + context: context, + radius: radius, + borderColor: appScheme.border, + child: material.Text( + 'Hover a theme to preview.', + style: material.TextStyle( + fontSize: context.scaled(12), + color: appScheme.mutedForeground, + ), + ), + ); + } + + final scheme = previewTheme.colorScheme; + final workbench = previewTheme.workbench; + final editor = previewTheme.editor; + + return _shell( + context: context, + radius: radius, + borderColor: appScheme.border, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (label != null && label!.isNotEmpty) + material.Padding( + padding: material.EdgeInsets.only(bottom: context.scaled(6)), + child: material.Text( + label!, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(11), + fontWeight: material.FontWeight.w600, + color: appScheme.popoverForeground, + ), + ), + ), + material.Row( + children: [ + _Swatch(color: scheme.background, label: 'Bg'), + material.SizedBox(width: context.scaled(6)), + _Swatch(color: workbench.surface, label: 'Surface'), + material.SizedBox(width: context.scaled(6)), + _Swatch(color: scheme.primary, label: 'Primary'), + material.SizedBox(width: context.scaled(6)), + _Swatch(color: workbench.accent, label: 'Accent'), + material.SizedBox(width: context.scaled(8)), + material.Expanded( + child: material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(8), + vertical: context.scaled(6), + ), + decoration: material.BoxDecoration( + color: workbench.surface, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: scheme.border.withValues(alpha: 0.7), + ), + ), + child: material.Text( + 'Sample text', + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: context.scaled(12), + color: scheme.foreground, + ), + ), + ), + ), + ], + ), + material.SizedBox(height: context.scaled(6)), + material.Container( + height: context.scaled(10), + width: double.infinity, + decoration: material.BoxDecoration( + color: editor.background, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: scheme.border.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ); + } + + material.Widget _shell({ + required material.BuildContext context, + required double radius, + required Color borderColor, + required material.Widget child, + }) { + return material.Container( + width: double.infinity, + padding: material.EdgeInsets.all(context.scaled(8)), + decoration: material.BoxDecoration( + color: Theme.of(context).colorScheme.muted.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: borderColor), + ), + child: child, + ); + } +} + +class _Swatch extends material.StatelessWidget { + const _Swatch({ + required this.color, + required this.label, + }); + + final Color color; + final String label; + + @override + material.Widget build(material.BuildContext context) { + final radius = context.scaled(4); + return material.Column( + children: [ + material.Container( + width: context.scaled(18), + height: context.scaled(18), + decoration: material.BoxDecoration( + color: color, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all( + color: material.Colors.black.withValues(alpha: 0.12), + ), + ), + ), + material.SizedBox(height: context.scaled(2)), + material.Text( + label, + style: material.TextStyle( + fontSize: context.scaled(9), + color: Theme.of(context).colorScheme.mutedForeground, + ), + ), + ], + ); + } +} diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 0ddb30fb..dcfe929f 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -1,7 +1,10 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/core/theme/theme_definition.dart'; import 'package:querya_desktop/features/settings/theme_picker_button.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -291,4 +294,126 @@ void main() { expect(selectionCount, 0); }); }); + + group('ThemePickerButton preview', () { + Future openMenu(WidgetTester tester) async { + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + } + + Future hoverRow(WidgetTester tester, String rowLabel) async { + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text(rowLabel))); + await tester.pump(); + } + + testWidgets('hover does not call onSelected', (tester) async { + var selectionCount = 0; + var previewCount = 0; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) => selectionCount++, + onPreviewTheme: (_) async { + previewCount++; + return const ThemePreviewResult.theme(QueryaTheme.darkDefault); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await hoverRow(tester, 'Theme 01'); + await tester.pump(themePreviewDebounce); + await tester.pump(); + + expect(selectionCount, 0); + expect(previewCount, 1); + expect(find.text('Sample text'), findsOneWidget); + }); + + testWidgets('preview future resolves and card updates after debounce', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + onPreviewTheme: (_) async { + await Future.delayed(const Duration(milliseconds: 20)); + return const ThemePreviewResult.theme(QueryaTheme.lightDefault); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await hoverRow(tester, 'Theme 02'); + await tester.pump(themePreviewDebounce); + expect(find.text('Loading preview…'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 30)); + expect(find.text('Theme 02'), findsWidgets); + expect(find.text('Sample text'), findsOneWidget); + expect(find.text('Loading preview…'), findsNothing); + }); + + testWidgets('broken preview shows fallback error in card', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + onPreviewTheme: (_) async { + return const ThemePreviewResult.error('Could not parse theme'); + }, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await hoverRow(tester, 'Theme 03'); + await tester.pump(themePreviewDebounce); + await tester.pump(); + + expect(find.text('Could not parse theme'), findsOneWidget); + expect(find.text('Sample text'), findsNothing); + }); + + testWidgets('shows preview card only when onPreviewTheme is provided', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(5), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + expect(find.byType(ThemePreviewCard), findsNothing); + }); + }); } diff --git a/test/features/settings/theme_preview_card_test.dart b/test/features/settings/theme_preview_card_test.dart new file mode 100644 index 00000000..9cf26532 --- /dev/null +++ b/test/features/settings/theme_preview_card_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/features/settings/theme_preview_card.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('ThemePreviewCard', () { + testWidgets('shows placeholder when no theme is provided', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard(), + ), + ), + ); + await tester.pump(); + + expect(find.text('Hover a theme to preview.'), findsOneWidget); + }); + + testWidgets('renders preview swatches and sample text from QueryaTheme', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard( + theme: QueryaTheme.darkDefault, + label: 'Querya Dark', + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Querya Dark'), findsOneWidget); + expect(find.text('Sample text'), findsOneWidget); + expect(find.text('Bg'), findsOneWidget); + expect(find.text('Surface'), findsOneWidget); + expect(find.text('Primary'), findsOneWidget); + expect(find.text('Accent'), findsOneWidget); + }); + + testWidgets('shows loading state', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard(isLoading: true), + ), + ), + ); + await tester.pump(); + + expect(find.text('Loading preview…'), findsOneWidget); + expect(find.byType(material.CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('shows non-blocking error message', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold( + body: ThemePreviewCard(errorMessage: 'Theme file is invalid'), + ), + ), + ); + await tester.pump(); + + expect(find.text('Theme file is invalid'), findsOneWidget); + }); + }); +}