summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 17:57:13 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 17:57:13 +0300
commit26e8878cfeac4801b2cec44f338852898474ae71 (patch)
tree464c0f726e0d49689fba48ebe2b5371330b05185 /player-android
parent5582ebd43791c41e443c53c72db9e67cdb527d22 (diff)
Implement SearchFilterBar with debounce, type/favorites/sort filters for MediaGridScreen (wa)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
-rw-r--r--player-android/lib/models/media_filter.dart98
-rw-r--r--player-android/lib/models/models.dart1
-rw-r--r--player-android/lib/screens/media_grid_screen.dart67
-rw-r--r--player-android/lib/widgets/search_filter_bar.dart395
-rw-r--r--player-android/test/widgets/search_filter_bar_test.dart304
5 files changed, 858 insertions, 7 deletions
diff --git a/player-android/lib/models/media_filter.dart b/player-android/lib/models/media_filter.dart
new file mode 100644
index 0000000..0c4dc66
--- /dev/null
+++ b/player-android/lib/models/media_filter.dart
@@ -0,0 +1,98 @@
+/// Immutable value object that captures the current state of the
+/// [SearchFilterBar] and maps directly onto the query parameters accepted by
+/// `GET /api/v1/media` (see player-server/docs/api.md).
+///
+/// All fields are optional — a default-constructed [MediaFilter] represents
+/// "no filters applied" and produces the same result as calling
+/// `listMedia(setId: ...)` with no extra parameters.
+///
+/// Design notes:
+/// - Value object (all fields final, `==` / `hashCode` based on fields):
+/// callers can do cheap equality checks to detect real changes and avoid
+/// redundant API calls.
+/// - No dependencies on Flutter or Riverpod; a plain Dart class so it is
+/// trivially unit-testable without pumping widgets.
+/// - [copyWith] supports incremental updates from the filter bar without
+/// re-constructing the whole object (Immutable / Open-Closed).
+class MediaFilter {
+ /// Plain-text search term forwarded as the `search` query parameter.
+ ///
+ /// `null` or empty string ⟹ no search filter.
+ final String? query;
+
+ /// Media type filter: one of `'video'`, `'audio'`, `'image'`, or `null`
+ /// to return all types.
+ ///
+ /// Maps to the `type` query parameter.
+ final String? type;
+
+ /// When `true`, only favourite items are returned (`favorites=true`).
+ ///
+ /// `false` or `null` ⟹ no favourites filter.
+ final bool favoritesOnly;
+
+ /// Sort order forwarded as the `sort` query parameter.
+ ///
+ /// Valid values: `'name'`, `'date'`, `'duration'`, `'play_count'`,
+ /// `'random'`, or `null` for the server default.
+ final String? sortBy;
+
+ /// Creates a filter with all fields explicitly specified.
+ ///
+ /// All parameters have defaults corresponding to "no filter", so
+ /// `const MediaFilter()` is a valid zero-filter instance.
+ const MediaFilter({
+ this.query,
+ this.type,
+ this.favoritesOnly = false,
+ this.sortBy,
+ });
+
+ /// Returns a new [MediaFilter] with the supplied fields overridden.
+ ///
+ /// Fields not listed retain their current value, allowing callers to update
+ /// a single dimension without re-stating the rest (Open-Closed Principle).
+ MediaFilter copyWith({
+ // Sentinel object used to detect an explicit `null` override (i.e. the
+ // caller wants to clear a nullable field rather than leave it unchanged).
+ Object? query = _sentinel,
+ Object? type = _sentinel,
+ bool? favoritesOnly,
+ Object? sortBy = _sentinel,
+ }) {
+ return MediaFilter(
+ query: query == _sentinel ? this.query : query as String?,
+ type: type == _sentinel ? this.type : type as String?,
+ favoritesOnly: favoritesOnly ?? this.favoritesOnly,
+ sortBy: sortBy == _sentinel ? this.sortBy : sortBy as String?,
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Value semantics
+ // ---------------------------------------------------------------------------
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is MediaFilter &&
+ other.query == query &&
+ other.type == type &&
+ other.favoritesOnly == favoritesOnly &&
+ other.sortBy == sortBy;
+
+ @override
+ int get hashCode =>
+ Object.hash(query, type, favoritesOnly, sortBy);
+
+ @override
+ String toString() => 'MediaFilter('
+ 'query: $query, '
+ 'type: $type, '
+ 'favoritesOnly: $favoritesOnly, '
+ 'sortBy: $sortBy)';
+}
+
+// Private sentinel object used by [MediaFilter.copyWith] to distinguish
+// "omitted" from "explicitly set to null".
+const _sentinel = Object();
diff --git a/player-android/lib/models/models.dart b/player-android/lib/models/models.dart
index 2b11901..65de6ef 100644
--- a/player-android/lib/models/models.dart
+++ b/player-android/lib/models/models.dart
@@ -1,4 +1,5 @@
export 'media.dart';
+export 'media_filter.dart';
export 'media_set.dart';
export 'note.dart';
export 'playback_hint.dart';
diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart
index 6905243..5d95cbe 100644
--- a/player-android/lib/screens/media_grid_screen.dart
+++ b/player-android/lib/screens/media_grid_screen.dart
@@ -7,6 +7,7 @@ import '../app_routes.dart';
import '../models/models.dart';
import '../providers/api_client_provider.dart';
import '../utils/error_mappers.dart';
+import '../widgets/search_filter_bar.dart';
/// Displays the media items inside a single [MediaSet] as a scrollable grid.
///
@@ -24,6 +25,8 @@ import '../utils/error_mappers.dart';
/// app bar falls back to "Set $setId" when it is absent.
/// - Error handling is fully delegated to top-level helpers in
/// `error_mappers.dart` — no `dio` import in this file (DIP).
+/// - [SearchFilterBar] is shown below the AppBar; filter changes cancel
+/// any in-flight load and start a new one with the updated parameters.
class MediaGridScreen extends ConsumerStatefulWidget {
/// The numeric identifier of the set whose media items will be displayed.
final int setId;
@@ -50,6 +53,15 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
// True while the initial or refresh load is in flight.
bool _isLoading = false;
+ // Current filter state; starts with no filters applied.
+ MediaFilter _filter = const MediaFilter();
+
+ // Generation counter — incremented whenever a new load is started.
+ // The async callback checks this value before updating state so that a
+ // stale response from a cancelled logical request is silently discarded,
+ // preventing race conditions when the user changes filters rapidly.
+ int _loadGeneration = 0;
+
@override
void initState() {
super.initState();
@@ -62,12 +74,23 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
// Data loading
// ---------------------------------------------------------------------------
- /// Fetches media items for [widget.setId] and updates local state.
+ /// Fetches media items for [widget.setId] with the current [_filter] and
+ /// updates local state.
///
- /// Called on first mount and on pull-to-refresh. Errors are mapped by the
- /// top-level [mediaErrorMessage] helper so the widget stays free of Dio.
+ /// Called on first mount, on pull-to-refresh, and whenever [_filter]
+ /// changes. The [_loadGeneration] counter ensures that a response arriving
+ /// after a newer load has started is ignored, preventing stale data from
+ /// overwriting fresher results (cancellation-by-generation pattern).
+ ///
+ /// Errors are mapped by the top-level [mediaErrorMessage] helper so the
+ /// widget stays free of Dio.
Future<void> _load() async {
if (!mounted) return;
+
+ // Bump the generation before the async gap so that any pending callback
+ // from the previous load detects the change and drops its result.
+ final generation = ++_loadGeneration;
+
setState(() {
_isLoading = true;
_error = null;
@@ -75,14 +98,24 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
try {
final client = ref.read(apiClientProvider);
- final items = await client.listMedia(setId: widget.setId);
- if (!mounted) return;
+ final items = await client.listMedia(
+ setId: widget.setId,
+ search: _filter.query,
+ type: _filter.type,
+ favorites: _filter.favoritesOnly ? true : null,
+ sort: _filter.sortBy,
+ );
+
+ // Discard the result if a newer load was started while this one was
+ // in flight (filter changed, pull-to-refresh, etc.).
+ if (!mounted || generation != _loadGeneration) return;
+
setState(() {
_media = items;
_isLoading = false;
});
} catch (e) {
- if (!mounted) return;
+ if (!mounted || generation != _loadGeneration) return;
setState(() {
_error = mediaErrorMessage(e);
_isLoading = false;
@@ -90,6 +123,16 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
}
}
+ /// Called by [SearchFilterBar] when any filter dimension changes.
+ ///
+ /// Stores the new filter and immediately starts a new load. The generation
+ /// counter in [_load] ensures the previous in-flight request is logically
+ /// cancelled even though the underlying Future cannot be cancelled.
+ void _onFiltersChanged(MediaFilter filter) {
+ setState(() => _filter = filter);
+ _load();
+ }
+
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@@ -98,7 +141,17 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(),
- body: _buildBody(context),
+ body: Column(
+ children: [
+ // Search/filter bar sits directly below the AppBar.
+ SearchFilterBar(
+ initialFilter: _filter,
+ onFiltersChanged: _onFiltersChanged,
+ ),
+ // The remaining space is occupied by the data body.
+ Expanded(child: _buildBody(context)),
+ ],
+ ),
);
}
diff --git a/player-android/lib/widgets/search_filter_bar.dart b/player-android/lib/widgets/search_filter_bar.dart
new file mode 100644
index 0000000..02ad770
--- /dev/null
+++ b/player-android/lib/widgets/search_filter_bar.dart
@@ -0,0 +1,395 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import '../models/media_filter.dart';
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/// Delay between the last keystroke and the [onFiltersChanged] callback for
+/// the text-search field. 400 ms balances responsiveness against unnecessary
+/// in-flight API calls on every keypress.
+const _kDebounceDelay = Duration(milliseconds: 400);
+
+/// Internal sentinel used as the [PopupMenuButton] item value for the
+/// "Default" (no sort) option.
+///
+/// [PopupMenuButton.onSelected] is only invoked for non-null values, so we
+/// use a non-null sentinel string and convert back to `null` inside
+/// [_SortDropdown.build]. The sentinel is kept private to this file so it
+/// cannot leak into the public [MediaFilter] model.
+const _kSortDefault = '_sort_default_';
+
+/// Sort options shown in the dropdown, each mapped to its server-side value.
+///
+/// Keeping the list here (not inside the widget class) makes it easy to unit-
+/// test the labels independently and avoids rebuilding the list on every
+/// [build] call (stateless constant).
+const List<({String label, String? value})> _kSortOptions = [
+ (label: 'Default', value: null),
+ (label: 'Name', value: 'name'),
+ (label: 'Date', value: 'date'),
+ (label: 'Duration', value: 'duration'),
+ (label: 'Play count', value: 'play_count'),
+ (label: 'Random', value: 'random'),
+];
+
+/// Type-filter chips shown below the search field.
+///
+/// The `null` value represents "All" (no type filter).
+const List<({String label, String? value})> _kTypeOptions = [
+ (label: 'All', value: null),
+ (label: 'Video', value: 'video'),
+ (label: 'Audio', value: 'audio'),
+ (label: 'Image', value: 'image'),
+];
+
+// ---------------------------------------------------------------------------
+// SearchFilterBar
+// ---------------------------------------------------------------------------
+
+/// A composite filter bar that combines a debounced text search field,
+/// media-type filter chips, a favourites toggle, and a sort dropdown.
+///
+/// The widget is intentionally a pure `StatefulWidget` (not a
+/// `ConsumerWidget`) — it owns only local UI state (text controller, timer)
+/// and delegates all business logic to the [onFiltersChanged] callback. This
+/// satisfies the Single-Responsibility Principle: the bar manages its own UI
+/// state, and the caller (e.g. [MediaGridScreen]) owns the data-fetching logic.
+///
+/// Usage:
+/// ```dart
+/// SearchFilterBar(
+/// initialFilter: _filter,
+/// onFiltersChanged: (filter) => setState(() => _filter = filter),
+/// )
+/// ```
+///
+/// Key design choices:
+/// - Debounce is implemented with a [Timer] that is cancelled on every
+/// keystroke; this is the canonical Flutter pattern and avoids pulling in
+/// any third-party stream or debounce library.
+/// - [_debounceTimer] is always cancelled in [dispose] to prevent callbacks
+/// firing after the widget is unmounted.
+/// - Type-filter chips and the sort dropdown fire [onFiltersChanged]
+/// synchronously (no debounce) because the user's intent is unambiguous
+/// when they tap a chip or pick from a dropdown.
+class SearchFilterBar extends StatefulWidget {
+ /// The filter state to display when the bar is first built.
+ ///
+ /// Defaults to `const MediaFilter()` (no filters applied).
+ final MediaFilter initialFilter;
+
+ /// Called whenever any filter changes.
+ ///
+ /// The callee typically stores the new filter and re-fetches media.
+ final void Function(MediaFilter filter) onFiltersChanged;
+
+ const SearchFilterBar({
+ super.key,
+ this.initialFilter = const MediaFilter(),
+ required this.onFiltersChanged,
+ });
+
+ @override
+ State<SearchFilterBar> createState() => _SearchFilterBarState();
+}
+
+class _SearchFilterBarState extends State<SearchFilterBar> {
+ // Current composite filter state — mutated incrementally by each UI control.
+ late MediaFilter _filter;
+
+ // Controller for the search [TextField].
+ late final TextEditingController _searchController;
+
+ // Cancels the previous timer when the user types before the delay expires.
+ Timer? _debounceTimer;
+
+ @override
+ void initState() {
+ super.initState();
+ _filter = widget.initialFilter;
+ _searchController = TextEditingController(text: _filter.query ?? '');
+ }
+
+ @override
+ void dispose() {
+ // Always cancel the timer to prevent a stale callback firing after
+ // the widget is removed from the tree.
+ _debounceTimer?.cancel();
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal mutators — each updates _filter and calls onFiltersChanged.
+ // ---------------------------------------------------------------------------
+
+ /// Called on every keystroke in the search field.
+ ///
+ /// Cancels the previous debounce timer (if any) and schedules a new one.
+ /// Only the final keystroke within [_kDebounceDelay] propagates to the
+ /// caller, reducing unnecessary API calls while the user is still typing.
+ ///
+ /// The timer callback includes a [mounted] guard: if the widget is disposed
+ /// before the delay expires (e.g. the user navigates away), the callback
+ /// is a no-op and [setState] is never called on a dead widget.
+ void _onSearchChanged(String value) {
+ _debounceTimer?.cancel();
+ _debounceTimer = Timer(_kDebounceDelay, () {
+ if (!mounted) return;
+ // Trim once and reuse to avoid double computation.
+ final trimmed = value.trim();
+ final updated = _filter.copyWith(query: trimmed.isEmpty ? null : trimmed);
+ _applyFilter(updated);
+ });
+ }
+
+ /// Called when the user taps a type-filter chip.
+ ///
+ /// Fires synchronously — no debounce needed because the user's intent is
+ /// unambiguous when tapping a chip.
+ void _onTypeSelected(String? type) {
+ _applyFilter(_filter.copyWith(type: type));
+ }
+
+ /// Called when the user toggles the favourites button.
+ void _onFavoritesToggled() {
+ _applyFilter(_filter.copyWith(favoritesOnly: !_filter.favoritesOnly));
+ }
+
+ /// Called when the user picks a sort option.
+ void _onSortSelected(String? sortBy) {
+ _applyFilter(_filter.copyWith(sortBy: sortBy));
+ }
+
+ /// Stores the new filter in local state and notifies the parent widget.
+ void _applyFilter(MediaFilter updated) {
+ setState(() => _filter = updated);
+ widget.onFiltersChanged(updated);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Row 1: text search + favourites toggle + sort dropdown.
+ _SearchRow(
+ controller: _searchController,
+ favoritesOnly: _filter.favoritesOnly,
+ sortBy: _filter.sortBy,
+ onSearchChanged: _onSearchChanged,
+ onFavoritesToggled: _onFavoritesToggled,
+ onSortSelected: _onSortSelected,
+ ),
+ // Row 2: media-type filter chips (All / Video / Audio / Image).
+ _TypeFilterRow(
+ selectedType: _filter.type,
+ onTypeSelected: _onTypeSelected,
+ ),
+ ],
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _SearchRow
+// ---------------------------------------------------------------------------
+
+/// Top row of the filter bar: search field, favourites toggle, sort dropdown.
+///
+/// Extracted to keep [_SearchFilterBarState.build] concise and to allow
+/// independent widget tests for just the top row (Single Responsibility).
+class _SearchRow extends StatelessWidget {
+ const _SearchRow({
+ required this.controller,
+ required this.favoritesOnly,
+ required this.sortBy,
+ required this.onSearchChanged,
+ required this.onFavoritesToggled,
+ required this.onSortSelected,
+ });
+
+ final TextEditingController controller;
+ final bool favoritesOnly;
+ final String? sortBy;
+ final ValueChanged<String> onSearchChanged;
+ final VoidCallback onFavoritesToggled;
+ final ValueChanged<String?> onSortSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(12, 8, 8, 0),
+ child: Row(
+ children: [
+ // Expands to fill the available width, pushing the icon buttons right.
+ Expanded(
+ child: TextField(
+ key: const Key('search_input'),
+ controller: controller,
+ onChanged: onSearchChanged,
+ decoration: const InputDecoration(
+ hintText: 'Search media…',
+ prefixIcon: Icon(Icons.search),
+ border: OutlineInputBorder(),
+ isDense: true,
+ contentPadding: EdgeInsets.symmetric(
+ vertical: 8,
+ horizontal: 12,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 4),
+ // Favourites toggle — filled star when active.
+ IconButton(
+ key: const Key('favorites_toggle'),
+ tooltip: favoritesOnly ? 'All items' : 'Favourites only',
+ icon: Icon(
+ favoritesOnly ? Icons.star : Icons.star_border,
+ color: favoritesOnly
+ ? Theme.of(context).colorScheme.primary
+ : null,
+ ),
+ onPressed: onFavoritesToggled,
+ ),
+ // Sort dropdown — a small icon button that opens a pop-up menu.
+ _SortDropdown(
+ sortBy: sortBy,
+ onSortSelected: onSortSelected,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _SortDropdown
+// ---------------------------------------------------------------------------
+
+/// Pop-up sort menu triggered by a leading sort icon.
+///
+/// Separated from [_SearchRow] so it can be tested in isolation and because
+/// the pop-up-menu logic is non-trivial enough to deserve its own widget
+/// (Single Responsibility).
+///
+/// Implementation note: [PopupMenuButton] only calls [onSelected] for
+/// non-null return values, so this widget uses a non-nullable [String] type
+/// with [_kSortDefault] as the sentinel for "no sort". The sentinel is
+/// converted back to `null` before calling [onSortSelected].
+class _SortDropdown extends StatelessWidget {
+ const _SortDropdown({
+ required this.sortBy,
+ required this.onSortSelected,
+ });
+
+ final String? sortBy;
+ final ValueChanged<String?> onSortSelected;
+
+ /// Returns the label for the currently active sort option, or "Sort" as
+ /// a fallback when no sort is selected.
+ String get _currentLabel =>
+ _kSortOptions
+ .where((o) => o.value == sortBy)
+ .map((o) => o.label)
+ .firstOrNull ??
+ 'Sort';
+
+ /// Maps a [PopupMenuButton] selection (always non-null) back to the
+ /// nullable [sortBy] value expected by [MediaFilter].
+ void _handleSelected(String raw) {
+ onSortSelected(raw == _kSortDefault ? null : raw);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return PopupMenuButton<String>(
+ key: const Key('sort_dropdown'),
+ tooltip: 'Sort by',
+ onSelected: _handleSelected,
+ itemBuilder: (_) => _kSortOptions
+ .map(
+ (option) => PopupMenuItem<String>(
+ key: Key('sort_option_${option.value ?? 'default'}'),
+ // Use the sentinel for the null/"Default" option so that
+ // onSelected is always called (Flutter skips null returns).
+ value: option.value ?? _kSortDefault,
+ child: Text(option.label),
+ ),
+ )
+ .toList(),
+ // Show the current sort label next to the icon so the user always
+ // knows which order is active without opening the menu.
+ // child must be last per sort_child_properties_last lint rule.
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.sort),
+ const SizedBox(width: 4),
+ Text(
+ _currentLabel,
+ key: const Key('sort_label'),
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _TypeFilterRow
+// ---------------------------------------------------------------------------
+
+/// Horizontally scrollable row of [ChoiceChip]s for media-type filtering.
+///
+/// Using [ChoiceChip] (single-select) rather than [FilterChip]
+/// (multi-select) mirrors the API contract: `type` accepts exactly one value.
+/// A horizontal [SingleChildScrollView] handles narrow screens without
+/// truncating the chips.
+class _TypeFilterRow extends StatelessWidget {
+ const _TypeFilterRow({
+ required this.selectedType,
+ required this.onTypeSelected,
+ });
+
+ final String? selectedType;
+ final ValueChanged<String?> onTypeSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ key: const Key('type_filter_row'),
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ child: Row(
+ children: _kTypeOptions
+ .map(
+ (option) => Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: ChoiceChip(
+ key: Key('type_chip_${option.value ?? 'all'}'),
+ label: Text(option.label),
+ selected: selectedType == option.value,
+ onSelected: (_) => onTypeSelected(option.value),
+ ),
+ ),
+ )
+ .toList(),
+ ),
+ );
+ }
+}
diff --git a/player-android/test/widgets/search_filter_bar_test.dart b/player-android/test/widgets/search_filter_bar_test.dart
new file mode 100644
index 0000000..4378368
--- /dev/null
+++ b/player-android/test/widgets/search_filter_bar_test.dart
@@ -0,0 +1,304 @@
+// Widget tests for SearchFilterBar (widgets/search_filter_bar.dart).
+//
+// Tests cover:
+// 1. Text input triggers debounced callback after 400 ms.
+// 2. Text input does NOT fire immediately (debounce delay is respected).
+// 3. Typing quickly cancels the previous timer (only last value fires).
+// 4. Clearing the search text passes null as the query.
+// 5. Selecting a type chip updates the filter type.
+// 6. Tapping the "All" chip clears the type filter.
+// 7. Toggling the favourites button flips favoritesOnly.
+// 8. Picking a sort option updates sortBy in the callback.
+// 9. Initial filter state is reflected in the UI on first render.
+//
+// Run with: flutter test test/widgets/search_filter_bar_test.dart
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:player_android/models/media_filter.dart';
+import 'package:player_android/widgets/search_filter_bar.dart';
+
+// ---------------------------------------------------------------------------
+// Helper: pump SearchFilterBar inside a minimal MaterialApp.
+// ---------------------------------------------------------------------------
+
+/// Pumps a [SearchFilterBar] in isolation inside a [MaterialApp].
+///
+/// [onChanged] receives filter updates and can be used to assert on the
+/// emitted value. [initialFilter] seeds the bar's initial state.
+Future<void> _pumpBar(
+ WidgetTester tester, {
+ MediaFilter initialFilter = const MediaFilter(),
+ required void Function(MediaFilter) onChanged,
+}) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SearchFilterBar(
+ initialFilter: initialFilter,
+ onFiltersChanged: onChanged,
+ ),
+ ),
+ ),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+void main() {
+ // --------------------------------------------------------------------------
+ // Text search / debounce
+ // --------------------------------------------------------------------------
+
+ group('text search — debounce', () {
+ testWidgets('fires callback after 400 ms debounce', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.enterText(find.byKey(const Key('search_input')), 'hello');
+
+ // Before the debounce delay the callback should NOT have fired.
+ expect(emitted, isNull);
+
+ // Advance by the exact debounce delay.
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(emitted, isNotNull);
+ expect(emitted!.query, equals('hello'));
+ });
+
+ testWidgets('does not fire before 400 ms', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.enterText(find.byKey(const Key('search_input')), 'abc');
+
+ // Advance to just before the debounce window closes (399 ms < 400 ms).
+ await tester.pump(const Duration(milliseconds: 399));
+
+ // Callback must not have been called yet — the timer has not fired.
+ expect(emitted, isNull);
+
+ // Consume the remaining timer so the test does not leave pending async
+ // work that leaks into subsequent tests.
+ await tester.pumpAndSettle();
+ });
+
+ testWidgets('only last typed value fires when typing quickly',
+ (tester) async {
+ final emittedValues = <String?>[];
+ await _pumpBar(tester, onChanged: (f) => emittedValues.add(f.query));
+
+ // Type 'a', then immediately 'ab', then 'abc' — each within 200 ms.
+ await tester.enterText(find.byKey(const Key('search_input')), 'a');
+ await tester.pump(const Duration(milliseconds: 200));
+ await tester.enterText(find.byKey(const Key('search_input')), 'ab');
+ await tester.pump(const Duration(milliseconds: 200));
+ await tester.enterText(find.byKey(const Key('search_input')), 'abc');
+
+ // Wait for the final debounce to settle.
+ await tester.pump(const Duration(milliseconds: 400));
+
+ // Only the last value should have been emitted (the first two timers
+ // were cancelled before they fired).
+ expect(emittedValues, hasLength(1));
+ expect(emittedValues.first, equals('abc'));
+ });
+
+ testWidgets('clears query (passes null) when text is emptied',
+ (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(
+ tester,
+ initialFilter: const MediaFilter(query: 'hello'),
+ onChanged: (f) => emitted = f,
+ );
+
+ // Clear the field.
+ await tester.enterText(find.byKey(const Key('search_input')), '');
+ await tester.pump(const Duration(milliseconds: 400));
+
+ expect(emitted, isNotNull);
+ // An empty string is normalised to null by the bar.
+ expect(emitted!.query, isNull);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Type filter chips
+ // --------------------------------------------------------------------------
+
+ group('type filter chips', () {
+ testWidgets('tapping Video chip sets type to "video"', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.tap(find.byKey(const Key('type_chip_video')));
+ await tester.pumpAndSettle();
+
+ expect(emitted, isNotNull);
+ expect(emitted!.type, equals('video'));
+ });
+
+ testWidgets('tapping Audio chip sets type to "audio"', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.tap(find.byKey(const Key('type_chip_audio')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.type, equals('audio'));
+ });
+
+ testWidgets('tapping Image chip sets type to "image"', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.tap(find.byKey(const Key('type_chip_image')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.type, equals('image'));
+ });
+
+ testWidgets('tapping All chip clears the type filter', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(
+ tester,
+ initialFilter: const MediaFilter(type: 'video'),
+ onChanged: (f) => emitted = f,
+ );
+
+ await tester.tap(find.byKey(const Key('type_chip_all')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.type, isNull);
+ });
+
+ testWidgets('all four type chips are rendered', (tester) async {
+ await _pumpBar(tester, onChanged: (_) {});
+
+ expect(find.byKey(const Key('type_chip_all')), findsOneWidget);
+ expect(find.byKey(const Key('type_chip_video')), findsOneWidget);
+ expect(find.byKey(const Key('type_chip_audio')), findsOneWidget);
+ expect(find.byKey(const Key('type_chip_image')), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Favourites toggle
+ // --------------------------------------------------------------------------
+
+ group('favourites toggle', () {
+ testWidgets('toggling favourites sets favoritesOnly to true',
+ (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.tap(find.byKey(const Key('favorites_toggle')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.favoritesOnly, isTrue);
+ });
+
+ testWidgets('toggling favourites a second time sets favoritesOnly to false',
+ (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(
+ tester,
+ initialFilter: const MediaFilter(favoritesOnly: true),
+ onChanged: (f) => emitted = f,
+ );
+
+ await tester.tap(find.byKey(const Key('favorites_toggle')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.favoritesOnly, isFalse);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Sort dropdown
+ // --------------------------------------------------------------------------
+
+ group('sort dropdown', () {
+ testWidgets('selecting Name sort emits sortBy = "name"', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ // Open the popup menu.
+ await tester.tap(find.byKey(const Key('sort_dropdown')));
+ await tester.pumpAndSettle();
+
+ // Tap the Name option.
+ await tester.tap(find.byKey(const Key('sort_option_name')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.sortBy, equals('name'));
+ });
+
+ testWidgets('selecting Date sort emits sortBy = "date"', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.tap(find.byKey(const Key('sort_dropdown')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('sort_option_date')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.sortBy, equals('date'));
+ });
+
+ testWidgets('selecting Random sort emits sortBy = "random"',
+ (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(tester, onChanged: (f) => emitted = f);
+
+ await tester.tap(find.byKey(const Key('sort_dropdown')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('sort_option_random')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.sortBy, equals('random'));
+ });
+
+ testWidgets('selecting Default clears sortBy to null', (tester) async {
+ MediaFilter? emitted;
+ await _pumpBar(
+ tester,
+ initialFilter: const MediaFilter(sortBy: 'name'),
+ onChanged: (f) => emitted = f,
+ );
+
+ await tester.tap(find.byKey(const Key('sort_dropdown')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('sort_option_default')));
+ await tester.pumpAndSettle();
+
+ expect(emitted!.sortBy, isNull);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Initial filter reflected in UI
+ // --------------------------------------------------------------------------
+
+ group('initial filter state', () {
+ testWidgets('initial query is shown in the search field', (tester) async {
+ await _pumpBar(
+ tester,
+ initialFilter: const MediaFilter(query: 'test query'),
+ onChanged: (_) {},
+ );
+
+ expect(
+ tester
+ .widget<TextField>(find.byKey(const Key('search_input')))
+ .controller
+ ?.text,
+ equals('test query'),
+ );
+ });
+ });
+}