diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 17:57:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 17:57:13 +0300 |
| commit | 26e8878cfeac4801b2cec44f338852898474ae71 (patch) | |
| tree | 464c0f726e0d49689fba48ebe2b5371330b05185 /player-android | |
| parent | 5582ebd43791c41e443c53c72db9e67cdb527d22 (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.dart | 98 | ||||
| -rw-r--r-- | player-android/lib/models/models.dart | 1 | ||||
| -rw-r--r-- | player-android/lib/screens/media_grid_screen.dart | 67 | ||||
| -rw-r--r-- | player-android/lib/widgets/search_filter_bar.dart | 395 | ||||
| -rw-r--r-- | player-android/test/widgets/search_filter_bar_test.dart | 304 |
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'), + ); + }); + }); +} |
