Skip to content

Commit 93f745d

Browse files
Merge pull request #146 from QueryaHub/issue/115-theme-preview-card
feat(settings): ThemePreviewCard with debounced hover preview (TP-20)
2 parents 3280d13 + cf6e231 commit 93f745d

4 files changed

Lines changed: 541 additions & 1 deletion

File tree

lib/features/settings/theme_picker_button.dart

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart' as material;
24
import 'package:querya_desktop/core/layout/ui_scale.dart';
5+
import 'package:querya_desktop/core/theme/querya_theme.dart';
36
import 'package:querya_desktop/core/theme/theme_definition.dart';
7+
import 'package:querya_desktop/features/settings/theme_preview_card.dart';
48
import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart';
59
import 'package:shadcn_flutter/shadcn_flutter.dart';
610

11+
/// Debounce delay before requesting a hover preview load.
12+
const Duration themePreviewDebounce = Duration(milliseconds: 120);
13+
714
/// Dedicated theme picker for large registry lists (50+ themes).
815
class ThemePickerButton extends material.StatefulWidget {
916
const ThemePickerButton({
1017
super.key,
1118
required this.themes,
1219
required this.selectedThemeId,
1320
required this.onSelected,
21+
this.onPreviewTheme,
1422
this.isLoading = false,
1523
this.expandToParent = false,
1624
this.width,
@@ -19,6 +27,10 @@ class ThemePickerButton extends material.StatefulWidget {
1927
final List<ThemeDefinition> themes;
2028
final String? selectedThemeId;
2129
final material.ValueChanged<String> onSelected;
30+
31+
/// Loads preview data for [themeId] on hover. Must not apply the theme.
32+
final Future<ThemePreviewResult> Function(String themeId)? onPreviewTheme;
33+
2234
final bool isLoading;
2335
final bool expandToParent;
2436
final double? width;
@@ -54,6 +66,13 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
5466
final material.TextEditingController _searchController =
5567
material.TextEditingController();
5668
bool _triggerHovered = false;
69+
String? _previewThemeId;
70+
String? _previewThemeLabel;
71+
QueryaTheme? _previewTheme;
72+
String? _previewError;
73+
bool _previewLoading = false;
74+
Timer? _previewDebounce;
75+
int _previewRequestSerial = 0;
5776

5877
@override
5978
void initState() {
@@ -63,12 +82,67 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
6382

6483
@override
6584
void dispose() {
85+
_previewDebounce?.cancel();
6686
_searchController.removeListener(_onSearchChanged);
6787
_searchController.dispose();
6888
_scrollController.dispose();
6989
super.dispose();
7090
}
7191

92+
void _resetPreviewState() {
93+
_previewDebounce?.cancel();
94+
_previewRequestSerial++;
95+
_previewThemeId = null;
96+
_previewThemeLabel = null;
97+
_previewTheme = null;
98+
_previewError = null;
99+
_previewLoading = false;
100+
}
101+
102+
void _schedulePreview(ThemeDefinition definition) {
103+
if (widget.onPreviewTheme == null) return;
104+
105+
_previewDebounce?.cancel();
106+
final targetId = definition.id;
107+
setState(() {
108+
_previewThemeId = targetId;
109+
_previewThemeLabel = definition.name;
110+
});
111+
112+
_previewDebounce = Timer(themePreviewDebounce, () {
113+
if (!mounted || _previewThemeId != targetId) return;
114+
setState(() {
115+
_previewLoading = true;
116+
_previewTheme = null;
117+
_previewError = null;
118+
});
119+
unawaited(_loadPreview(targetId));
120+
});
121+
}
122+
123+
Future<void> _loadPreview(String themeId) async {
124+
final loader = widget.onPreviewTheme;
125+
if (loader == null || !mounted || _previewThemeId != themeId) return;
126+
127+
final requestId = ++_previewRequestSerial;
128+
final result = await loader(themeId);
129+
if (!mounted || requestId != _previewRequestSerial) return;
130+
131+
setState(() {
132+
_previewLoading = false;
133+
switch (result) {
134+
case ThemePreviewSuccess(:final theme):
135+
_previewTheme = theme;
136+
_previewError = null;
137+
case ThemePreviewFailure(:final message):
138+
_previewTheme = null;
139+
_previewError = message;
140+
case ThemePreviewLoading():
141+
_previewLoading = true;
142+
}
143+
});
144+
}
145+
72146
void _onSearchChanged() {
73147
setState(() {});
74148
if (_scrollController.hasClients) {
@@ -216,6 +290,21 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
216290
),
217291
),
218292
),
293+
if (widget.onPreviewTheme != null)
294+
material.Padding(
295+
padding: material.EdgeInsets.fromLTRB(
296+
context.scaled(8),
297+
context.scaled(4),
298+
context.scaled(8),
299+
context.scaled(4),
300+
),
301+
child: ThemePreviewCard(
302+
theme: _previewTheme,
303+
errorMessage: _previewError,
304+
isLoading: _previewLoading,
305+
label: _previewThemeLabel,
306+
),
307+
),
219308
material.Expanded(
220309
child: filteredThemes.isEmpty
221310
? material.Center(
@@ -245,9 +334,13 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
245334
definition: theme,
246335
selected: theme.id == widget.selectedThemeId,
247336
colorScheme: cs,
337+
onHover: widget.onPreviewTheme == null
338+
? null
339+
: () => _schedulePreview(theme),
248340
onSelected: () {
249341
widget.onSelected(theme.id);
250342
_clearSearch();
343+
_resetPreviewState();
251344
_controller.close();
252345
},
253346
);
@@ -332,6 +425,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
332425
controller.close();
333426
} else {
334427
_clearSearch();
428+
_resetPreviewState();
335429
controller.open();
336430
}
337431
}
@@ -349,12 +443,14 @@ class _ThemePickerRow extends material.StatefulWidget {
349443
required this.selected,
350444
required this.colorScheme,
351445
required this.onSelected,
446+
this.onHover,
352447
});
353448

354449
final ThemeDefinition definition;
355450
final bool selected;
356451
final ColorScheme colorScheme;
357452
final material.VoidCallback onSelected;
453+
final material.VoidCallback? onHover;
358454

359455
@override
360456
material.State<_ThemePickerRow> createState() => _ThemePickerRowState();
@@ -377,7 +473,10 @@ class _ThemePickerRowState extends material.State<_ThemePickerRow> {
377473

378474
return material.MouseRegion(
379475
cursor: material.SystemMouseCursors.click,
380-
onEnter: (_) => setState(() => _hovered = true),
476+
onEnter: (_) {
477+
setState(() => _hovered = true);
478+
widget.onHover?.call();
479+
},
381480
onExit: (_) => setState(() => _hovered = false),
382481
child: material.Material(
383482
type: material.MaterialType.transparency,

0 commit comments

Comments
 (0)