From 7ed7869dbf877b2a588f1488de3259e8bb995aa7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:41:27 +0300 Subject: [PATCH 1/2] feat(settings): add search filter to ThemePickerButton popup Filter registry rows by name, id, and source with a local search field and empty-state message without applying themes while typing. --- .../settings/theme_picker_button.dart | 164 +++++++++++++++--- 1 file changed, 142 insertions(+), 22 deletions(-) diff --git a/lib/features/settings/theme_picker_button.dart b/lib/features/settings/theme_picker_button.dart index bc6a80cc..d874bc97 100644 --- a/lib/features/settings/theme_picker_button.dart +++ b/lib/features/settings/theme_picker_button.dart @@ -29,17 +29,61 @@ class ThemePickerButton extends material.StatefulWidget { material.State createState() => _ThemePickerButtonState(); } +/// Filters [themes] by lowercase [query] against name, id, and source labels. +List filterThemeDefinitions( + List themes, + String query, +) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) return themes; + + return themes + .where( + (theme) => + theme.name.toLowerCase().contains(normalized) || + theme.id.toLowerCase().contains(normalized) || + theme.source.name.toLowerCase().contains(normalized) || + _sourceBadgeLabel(theme.source).toLowerCase().contains(normalized), + ) + .toList(growable: false); +} + class _ThemePickerButtonState extends material.State { final material.MenuController _controller = material.MenuController(); final material.ScrollController _scrollController = material.ScrollController(); + final material.TextEditingController _searchController = + material.TextEditingController(); bool _triggerHovered = false; + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + } + @override void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); _scrollController.dispose(); super.dispose(); } + void _onSearchChanged() { + setState(() {}); + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + + void _clearSearch() { + if (_searchController.text.isEmpty) return; + _searchController.clear(); + } + + List get _filteredThemes => + filterThemeDefinitions(widget.themes, _searchController.text); + bool get _enabled => !widget.isLoading; String get _triggerLabel { @@ -96,28 +140,7 @@ class _ThemePickerButtonState extends material.State { 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(); - }, - ); - }, - ), - ), + child: _buildMenuPanel(context, cs), ), ], builder: (context, controller, child) { @@ -140,6 +163,102 @@ class _ThemePickerButtonState extends material.State { return anchor; } + material.Widget _buildMenuPanel(material.BuildContext context, ColorScheme cs) { + final filteredThemes = _filteredThemes; + final radius = context.scaled(QueryaDropdownTokens.menuBorderRadius); + + return material.Column( + children: [ + material.Padding( + padding: material.EdgeInsets.fromLTRB( + context.scaled(8), + context.scaled(8), + context.scaled(8), + context.scaled(4), + ), + child: material.TextField( + controller: _searchController, + style: material.TextStyle( + fontSize: context.scaled(QueryaDropdownTokens.fontSize), + color: cs.popoverForeground, + ), + decoration: material.InputDecoration( + isDense: true, + hintText: 'Search themes…', + hintStyle: material.TextStyle(color: cs.mutedForeground), + prefixIcon: material.Icon( + material.Icons.search, + size: context.scaled(18), + color: cs.mutedForeground, + ), + prefixIconConstraints: material.BoxConstraints( + minWidth: context.scaled(36), + minHeight: context.scaled(32), + ), + contentPadding: material.EdgeInsets.symmetric( + horizontal: context.scaled(8), + vertical: context.scaled(8), + ), + filled: true, + fillColor: cs.muted.withValues(alpha: 0.18), + border: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(radius), + borderSide: material.BorderSide(color: cs.border), + ), + enabledBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(radius), + borderSide: material.BorderSide(color: cs.border), + ), + focusedBorder: material.OutlineInputBorder( + borderRadius: material.BorderRadius.circular(radius), + borderSide: material.BorderSide(color: cs.ring), + ), + ), + ), + ), + material.Expanded( + child: filteredThemes.isEmpty + ? material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(context.scaled(12)), + child: material.Text( + 'No themes match your search.', + textAlign: material.TextAlign.center, + style: material.TextStyle( + fontSize: context.scaled(12), + color: cs.mutedForeground, + ), + ), + ), + ) + : material.Scrollbar( + controller: _scrollController, + thumbVisibility: filteredThemes.length > 8, + child: material.ListView.builder( + controller: _scrollController, + primary: false, + padding: QueryaDropdownTokens.menuPadding, + itemCount: filteredThemes.length, + itemBuilder: (context, index) { + final theme = filteredThemes[index]; + return _ThemePickerRow( + definition: theme, + selected: theme.id == widget.selectedThemeId, + colorScheme: cs, + onSelected: () { + widget.onSelected(theme.id); + _clearSearch(); + _controller.close(); + }, + ); + }, + ), + ), + ), + ], + ); + } + material.Widget _buildTrigger({ required material.BuildContext context, required material.MenuController controller, @@ -212,6 +331,7 @@ class _ThemePickerButtonState extends material.State { if (controller.isOpen) { controller.close(); } else { + _clearSearch(); controller.open(); } } From b7309a2763092a2dd0e3ab5c9a61cc96e56e2395 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 14 Jun 2026 12:41:27 +0300 Subject: [PATCH 2/2] test(settings): cover ThemePickerButton search filtering Closes #114. --- .../settings/theme_picker_button_test.dart | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/test/features/settings/theme_picker_button_test.dart b/test/features/settings/theme_picker_button_test.dart index 2734ccb1..0ddb30fb 100644 --- a/test/features/settings/theme_picker_button_test.dart +++ b/test/features/settings/theme_picker_button_test.dart @@ -19,6 +19,31 @@ List _fakeThemes(int count) { } void main() { + group('filterThemeDefinitions', () { + final themes = _fakeThemes(10); + + test('filters by theme name', () { + final filtered = filterThemeDefinitions(themes, 'theme 03'); + expect(filtered, hasLength(1)); + expect(filtered.single.id, 'theme-3'); + }); + + test('filters by theme id', () { + final filtered = filterThemeDefinitions(themes, 'theme-7'); + expect(filtered, hasLength(1)); + expect(filtered.single.name, 'Theme 07'); + }); + + test('filters by source label', () { + final filtered = filterThemeDefinitions(themes, 'file'); + expect(filtered, isNotEmpty); + expect( + filtered.every((theme) => theme.source == ThemeSource.filesystem), + isTrue, + ); + }); + }); + group('ThemePickerButton', () { testWidgets('builds MenuAnchor trigger for many themes', (tester) async { final themes = _fakeThemes(60); @@ -157,4 +182,113 @@ void main() { expect(find.byType(material.ListView), findsNothing); }); }); + + group('ThemePickerButton search', () { + Future openMenu(WidgetTester tester) async { + await tester.tap(find.text('Theme 00')); + await tester.pumpAndSettle(); + } + + testWidgets('filters visible rows by theme name', (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 openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 05'); + await tester.pump(); + + expect(find.text('Theme 01'), findsNothing); + expect(find.text('Theme 59'), findsNothing); + expect( + find.descendant( + of: find.byType(material.ListView), + matching: find.text('Theme 05'), + ), + findsOneWidget, + ); + }); + + testWidgets('shows empty message when filter has no results', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'zzzz-no-match'); + await tester.pump(); + + expect(find.text('No themes match your search.'), findsOneWidget); + expect(find.byType(material.ListView), findsNothing); + }); + + testWidgets('clearing search restores full list', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) {}, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 05'); + await tester.pump(); + expect(find.text('Theme 01'), findsNothing); + + await tester.enterText(find.byType(material.TextField), ''); + await tester.pump(); + + expect(find.text('Theme 01'), findsOneWidget); + expect(find.text('No themes match your search.'), findsNothing); + }); + + testWidgets('typing in search does not call onSelected', (tester) async { + var selectionCount = 0; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: ThemePickerButton( + themes: _fakeThemes(20), + selectedThemeId: 'theme-0', + onSelected: (_) => selectionCount++, + ), + ), + ), + ); + await tester.pump(); + await openMenu(tester); + + await tester.enterText(find.byType(material.TextField), 'Theme 03'); + await tester.pumpAndSettle(); + + expect(selectionCount, 0); + }); + }); }