From abb1432cb983ec8df3b784d7563c9cb1882e6f47 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:32:18 +0300 Subject: [PATCH 1/3] feat(settings): add ThemePickerButton for registry theme lists MenuAnchor popup with scrollable ListView.builder rows showing name, source badge, and dark/light label without parsing themes during build. --- .../settings/theme_picker_button.dart | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 lib/features/settings/theme_picker_button.dart diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart new file mode 100644 index 00000000..62860e71 --- /dev/null +++ b/lib/features/settings/theme_picker_button.dart @@ -0,0 +1,405 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/ui_scale.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/shared/widgets/querya_dropdown_tokens.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Dedicated theme picker for large registry lists (50+ themes). +class ThemePickerButton extends material.StatefulWidget { + const ThemePickerButton({ + super.key, + required this.themes, + required this.selectedThemeId, + required this.onSelected, + this.isLoading = false, + this.expandToParent = false, + this.width, + }); + + final List themes; + final String? selectedThemeId; + final material.ValueChanged onSelected; + final bool isLoading; + final bool expandToParent; + final double? width; + + static const double menuMaxHeight = 320; + + @override + material.State createState() => _ThemePickerButtonState(); +} + +class _ThemePickerButtonState extends material.State { + final material.MenuController _controller = material.MenuController(); + final material.ScrollController _scrollController = material.ScrollController(); + bool _triggerHovered = false; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + bool get _enabled => !widget.isLoading; + + String get _triggerLabel { + if (widget.isLoading) return 'Loading themes…'; + if (widget.selectedThemeId == null) return 'Select theme…'; + for (final theme in widget.themes) { + if (theme.id == widget.selectedThemeId) return theme.name; + } + return widget.selectedThemeId!; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final fieldWidth = widget.expandToParent + ? null + : (widget.width != null ? context.scaled(widget.width!) : null); + final menuWidth = fieldWidth ?? context.scaled(280); + final menuHeight = context.scaled(ThemePickerButton.menuMaxHeight); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + final anchor = material.MenuAnchor( + controller: _controller, + crossAxisUnconstrained: false, + alignmentOffset: material.Offset( + 0, + context.scaled(QueryaDropdownTokens.menuAlignmentOffset.dy), + ), + consumeOutsideTap: true, + style: material.MenuStyle( + backgroundColor: material.WidgetStatePropertyAll(cs.popover), + surfaceTintColor: material.WidgetStatePropertyAll(cs.popover), + elevation: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuElevation, + ), + shadowColor: const material.WidgetStatePropertyAll( + QueryaDropdownTokens.menuShadowColor, + ), + maximumSize: material.WidgetStatePropertyAll( + material.Size(menuWidth, menuHeight), + ), + minimumSize: material.WidgetStatePropertyAll( + material.Size(menuWidth, 0), + ), + padding: material.WidgetStatePropertyAll(material.EdgeInsets.zero), + shape: material.WidgetStatePropertyAll( + material.RoundedRectangleBorder( + borderRadius: material.BorderRadius.circular(radius), + side: material.BorderSide(color: cs.border), + ), + ), + ), + menuChildren: [ + material.SizedBox( + width: menuWidth, + height: menuHeight, + child: material.Scrollbar( + controller: _scrollController, + thumbVisibility: widget.themes.length > 8, + child: material.ListView.builder( + controller: _scrollController, + primary: false, + padding: QueryaDropdownTokens.menuPadding, + itemCount: widget.themes.length, + itemBuilder: (context, index) { + final theme = widget.themes[index]; + return _ThemePickerRow( + definition: theme, + selected: theme.id == widget.selectedThemeId, + colorScheme: cs, + onSelected: () { + widget.onSelected(theme.id); + _controller.close(); + }, + ); + }, + ), + ), + ), + ], + builder: (context, controller, child) { + final trigger = _buildTrigger( + context: context, + controller: controller, + cs: cs, + fieldWidth: fieldWidth, + ); + if (widget.expandToParent) { + return material.SizedBox(width: double.infinity, child: trigger); + } + if (fieldWidth != null) { + return material.SizedBox(width: fieldWidth, child: trigger); + } + return trigger; + }, + ); + + return anchor; + } + + material.Widget _buildTrigger({ + required material.BuildContext context, + required material.MenuController controller, + required ColorScheme cs, + required double? fieldWidth, + }) { + final borderColor = _enabled + ? (_triggerHovered ? cs.ring : cs.border) + : cs.border.withValues(alpha: 0.4); + final triggerHeight = QueryaDropdownTokens.scaledTriggerHeight(context); + final chevronGap = context.scaled(QueryaDropdownTokens.triggerChevronGap); + final chevronSize = context.scaled(QueryaDropdownTokens.triggerChevronSize); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + final triggerBody = material.MouseRegion( + cursor: _enabled + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: _enabled ? (_) => setState(() => _triggerHovered = true) : null, + onExit: _enabled ? (_) => setState(() => _triggerHovered = false) : null, + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + height: triggerHeight, + padding: QueryaDropdownTokens.scaledTriggerPadding(context), + decoration: material.BoxDecoration( + color: _triggerHovered + ? cs.muted.withValues(alpha: 0.28) + : cs.muted.withValues(alpha: 0.14), + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: borderColor), + ), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.spaceBetween, + mainAxisSize: (widget.expandToParent || fieldWidth != null) + ? material.MainAxisSize.max + : material.MainAxisSize.min, + children: [ + material.Expanded( + child: material.Text( + _triggerLabel, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.triggerTextStyle( + context, + _enabled ? cs.popoverForeground : cs.mutedForeground, + ), + ), + ), + material.SizedBox(width: chevronGap), + material.Icon( + material.Icons.keyboard_arrow_down_rounded, + size: chevronSize, + color: _enabled + ? cs.mutedForeground + : cs.mutedForeground.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + + return material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: _enabled + ? () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + } + : null, + borderRadius: material.BorderRadius.circular(radius), + child: triggerBody, + ), + ); + } +} + +class _ThemePickerRow extends material.StatefulWidget { + const _ThemePickerRow({ + required this.definition, + required this.selected, + required this.colorScheme, + required this.onSelected, + }); + + final ThemeDefinition definition; + final bool selected; + final ColorScheme colorScheme; + final material.VoidCallback onSelected; + + @override + material.State<_ThemePickerRow> createState() => _ThemePickerRowState(); +} + +class _ThemePickerRowState extends material.State<_ThemePickerRow> { + bool _hovered = false; + + @override + material.Widget build(material.BuildContext context) { + final cs = widget.colorScheme; + final bg = _hovered + ? cs.accent.withValues(alpha: 0.14) + : widget.selected + ? cs.muted.withValues(alpha: 0.32) + : material.Colors.transparent; + final itemHeight = QueryaDropdownTokens.scaledMenuItemHeight(context); + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + final slot = context.scaled(QueryaDropdownTokens.selectedCheckSlotWidth); + + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.Material( + type: material.MaterialType.transparency, + child: material.InkWell( + onTap: widget.onSelected, + borderRadius: material.BorderRadius.circular(radius), + child: material.AnimatedContainer( + duration: const Duration( + milliseconds: QueryaDropdownTokens.hoverAnimationMs, + ), + curve: material.Curves.easeOut, + constraints: material.BoxConstraints(minHeight: itemHeight), + padding: material.EdgeInsets.symmetric( + horizontal: + context.scaled(QueryaDropdownTokens.menuItemPadding.horizontal), + vertical: + context.scaled(QueryaDropdownTokens.menuItemPadding.vertical), + ), + decoration: material.BoxDecoration( + color: bg, + borderRadius: material.BorderRadius.circular(radius), + ), + child: material.Row( + children: [ + material.SizedBox( + width: slot, + child: widget.selected + ? material.Icon( + material.Icons.check_rounded, + size: context.scaled( + QueryaDropdownTokens.selectedCheckSize, + ), + color: cs.primary, + ) + : null, + ), + material.SizedBox(width: context.scaled(6)), + material.Expanded( + child: material.Text( + widget.definition.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: QueryaDropdownTokens.menuItemTextStyle( + context, + cs.popoverForeground, + selected: widget.selected, + ), + ), + ), + material.SizedBox(width: context.scaled(6)), + _SourceBadge( + label: _sourceBadgeLabel(widget.definition.source), + colorScheme: cs, + ), + material.SizedBox(width: context.scaled(6)), + _BrightnessLabel(isDark: widget.definition.isDark, colorScheme: cs), + ], + ), + ), + ), + ), + ); + } +} + +class _SourceBadge extends material.StatelessWidget { + const _SourceBadge({ + required this.label, + required this.colorScheme, + }); + + final String label; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + final cs = colorScheme; + return material.Container( + padding: material.EdgeInsets.symmetric( + horizontal: context.scaled(6), + vertical: context.scaled(2), + ), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.45), + borderRadius: material.BorderRadius.circular( + context.scaled(QueryaDropdownTokens.menuBorderRadius), + ), + border: material.Border.all(color: cs.border.withValues(alpha: 0.6)), + ), + child: material.Text( + label, + style: material.TextStyle( + fontSize: context.scaled(11), + height: 1.1, + color: cs.mutedForeground, + fontWeight: material.FontWeight.w500, + ), + ), + ); + } +} + +class _BrightnessLabel extends material.StatelessWidget { + const _BrightnessLabel({ + required this.isDark, + required this.colorScheme, + }); + + final bool isDark; + final ColorScheme colorScheme; + + @override + material.Widget build(material.BuildContext context) { + final cs = colorScheme; + return material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + isDark + ? material.Icons.dark_mode_outlined + : material.Icons.light_mode_outlined, + size: context.scaled(14), + color: cs.mutedForeground, + ), + material.SizedBox(width: context.scaled(4)), + material.Text( + isDark ? 'Dark' : 'Light', + style: material.TextStyle( + fontSize: context.scaled(11), + color: cs.mutedForeground, + ), + ), + ], + ); + } +} + +String _sourceBadgeLabel(ThemeSource source) { + return switch (source) { + ThemeSource.builtin => 'Built-in', + ThemeSource.imported => 'Imported', + ThemeSource.filesystem => 'File', + ThemeSource.legacyImported => 'Imported', + }; +} From 176464df8139252350830588dae23b2611cd897a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:32:18 +0300 Subject: [PATCH 2/3] test(settings): cover ThemePickerButton menu and selection Closes #113. --- .../settings/theme_picker_button_test.dart | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/features/settings/theme_picker_button_test.dart diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart new file mode 100644 index 00000000..7cfcab57 --- /dev/null +++ b/test/features/settings/theme_picker_button_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/theme_definition.dart'; +import 'package:querya_desktop/features/settings/theme_picker_button.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +List _fakeThemes(int count) { + return List.generate( + count, + (index) => ThemeDefinition( + id: 'theme-$index', + name: 'Theme ${index.toString().padLeft(2, '0')}', + source: ThemeSource.values[index % ThemeSource.values.length], + format: index.isEven ? ThemeFormat.queryaCustom : ThemeFormat.vscode, + isDark: index.isOdd, + ), + ); +} + +void main() { + group('ThemePickerButton', () { + testWidgets('builds MenuAnchor trigger for many themes', (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-5', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(material.MenuAnchor), findsOneWidget); + expect(find.text('Theme 05'), findsOneWidget); + expect(find.byType(material.ListView), findsNothing); + }); + + testWidgets('opens scrollable menu and renders visible rows', (tester) async { + final themes = _fakeThemes(60); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsOneWidget); + expect(find.byType(material.Scrollbar), findsWidgets); + expect(find.text('Theme 00'), findsWidgets); + expect(find.text('Theme 01'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('tap row triggers onSelected with theme id', (tester) async { + final themes = _fakeThemes(60); + String? picked; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'theme-0', + onSelected: (id) => picked = id, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Theme 01')); + await tester.pumpAndSettle(); + + expect(picked, 'theme-1'); + }); + + testWidgets('shows source badge and brightness label in open menu', + (tester) async { + final themes = const [ + ThemeDefinition( + id: 'builtin-dark', + name: 'Querya Dark', + source: ThemeSource.builtin, + format: ThemeFormat.queryaCustom, + isDark: true, + ), + ThemeDefinition( + id: 'file-light', + name: 'Sunrise', + source: ThemeSource.filesystem, + format: ThemeFormat.vscode, + isDark: false, + ), + ]; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: themes, + selectedThemeId: 'builtin-dark', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Querya Dark')); + await tester.pumpAndSettle(); + + expect(find.text('Built-in'), findsOneWidget); + expect(find.text('File'), findsOneWidget); + expect(find.text('Dark'), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + }); + + testWidgets('loading state disables menu open', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(3), + selectedThemeId: null, + onSelected: (_) {}, + isLoading: true, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Loading themes…'), findsOneWidget); + + await tester.tap(find.text('Loading themes…')); + await tester.pumpAndSettle(); + + expect(find.byType(material.ListView), findsNothing); + }); + }); +} From 6e135a166f151b37e90204b3f205ca5b863a56b5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:32:30 +0300 Subject: [PATCH 3/3] fix(settings): satisfy prefer_const analyzer hints in theme picker --- lib/features/settings/theme_picker_button.dart | 2 +- test/features/settings/theme_picker_button_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index 62860e71..bc6a80cc 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -84,7 +84,7 @@ class _ThemePickerButtonState extends material.State { minimumSize: material.WidgetStatePropertyAll( material.Size(menuWidth, 0), ), - padding: material.WidgetStatePropertyAll(material.EdgeInsets.zero), + padding: const material.WidgetStatePropertyAll(material.EdgeInsets.zero), shape: material.WidgetStatePropertyAll( material.RoundedRectangleBorder( borderRadius: material.BorderRadius.circular(radius), diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 7cfcab57..2734ccb1 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -95,7 +95,7 @@ void main() { testWidgets('shows source badge and brightness label in open menu', (tester) async { - final themes = const [ + const themes = [ ThemeDefinition( id: 'builtin-dark', name: 'Querya Dark',