Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 142 additions & 22 deletions lib/features/settings/theme_picker_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,61 @@ class ThemePickerButton extends material.StatefulWidget {
material.State<ThemePickerButton> createState() => _ThemePickerButtonState();
}

/// Filters [themes] by lowercase [query] against name, id, and source labels.
List<ThemeDefinition> filterThemeDefinitions(
List<ThemeDefinition> 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<ThemePickerButton> {
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<ThemeDefinition> get _filteredThemes =>
filterThemeDefinitions(widget.themes, _searchController.text);

bool get _enabled => !widget.isLoading;

String get _triggerLabel {
Expand Down Expand Up @@ -96,28 +140,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
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) {
Expand All @@ -140,6 +163,102 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
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,
Expand Down Expand Up @@ -212,6 +331,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
if (controller.isOpen) {
controller.close();
} else {
_clearSearch();
controller.open();
}
}
Expand Down
134 changes: 134 additions & 0 deletions test/features/settings/theme_picker_button_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,31 @@ List<ThemeDefinition> _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);
Expand Down Expand Up @@ -157,4 +182,113 @@ void main() {
expect(find.byType(material.ListView), findsNothing);
});
});

group('ThemePickerButton search', () {
Future<void> 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);
});
});
}
Loading