diff options
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 23 | ||||
| -rw-r--r-- | player-android/lib/screens/media_grid_screen.dart | 184 | ||||
| -rw-r--r-- | player-android/lib/widgets/search_filter_bar.dart | 29 | ||||
| -rw-r--r-- | player-android/test/screens/media_detail_screen_test.dart | 122 | ||||
| -rw-r--r-- | player-android/test/screens/media_grid_screen_test.dart | 302 | ||||
| -rw-r--r-- | player-android/test/widgets/search_filter_bar_test.dart | 135 |
6 files changed, 792 insertions, 3 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index 4443ba3..a1ed037 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -332,6 +332,29 @@ class DioPlayerApiClient extends PlayerApiClient { } // --------------------------------------------------------------------------- + // Favourites + // --------------------------------------------------------------------------- + + /// Toggles the authenticated user's favourite status for [mediaId]. + /// + /// POST /api/v1/media/{id}/favorite + /// + /// The server flips the current favourite state and responds with: + /// `{ "favorite": true | false }` + /// + /// Returns the **new** favourite state so the caller can reconcile the UI + /// without performing a second GET request. + @override + Future<bool> toggleFavorite(int mediaId) async { + final response = await rawDio.post<Map<String, dynamic>>( + '$_kApiV1/media/$mediaId/favorite', + ); + // The envelope always contains a boolean "favorite" field per the API spec. + final data = response.data ?? {}; + return (data['favorite'] as bool?) ?? false; + } + + // --------------------------------------------------------------------------- // Shares // --------------------------------------------------------------------------- diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart index 5d95cbe..9e58b15 100644 --- a/player-android/lib/screens/media_grid_screen.dart +++ b/player-android/lib/screens/media_grid_screen.dart @@ -9,6 +9,22 @@ import '../providers/api_client_provider.dart'; import '../utils/error_mappers.dart'; import '../widgets/search_filter_bar.dart'; +// --------------------------------------------------------------------------- +// _buildMediaWithFavorite (file-private helper) +// --------------------------------------------------------------------------- + +/// Returns a copy of [media] with the [favorite] flag replaced. +/// +/// [Media] is immutable, so we rebuild via [Media.fromJson] / [Media.toJson] +/// to avoid coupling the grid screen to any `copyWith` generated method. +/// Extracted as a file-private function so both [_MediaGridScreenState] and +/// the card overlay can share it without adding a public model API +/// (Dependency Inversion, DRY). +Media _buildMediaWithFavorite(Media media, bool favorite) { + final json = media.toJson()..['favorite'] = favorite; + return Media.fromJson(json); +} + /// Displays the media items inside a single [MediaSet] as a scrollable grid. /// /// Each card shows the item's thumbnail, title (file name), media-type icon @@ -134,6 +150,67 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> { } // --------------------------------------------------------------------------- + // Favourite toggle + // --------------------------------------------------------------------------- + + /// Optimistically flips the favourite flag on the item at [index], calls + /// [toggleFavorite] on the server, then reconciles with the confirmed state. + /// + /// On error the optimistic update is reverted and a SnackBar is shown. + /// + /// Guard: if [_media] is null or [index] is out of range the call is a no-op. + Future<void> _toggleFavoriteAt(int index) async { + final items = _media; + if (items == null || index < 0 || index >= items.length) return; + + final original = items[index]; + final optimistic = _buildMediaWithFavorite(original, !original.favorite); + + // Apply optimistic update immediately so the icon flips without lag. + setState(() { + _media = List<Media>.from(items)..[index] = optimistic; + }); + + try { + final client = ref.read(apiClientProvider); + final confirmed = await client.toggleFavorite(original.id); + if (!mounted) return; + // Reconcile with the value the server actually stored. + setState(() { + final current = _media; + if (current != null && index < current.length) { + _media = List<Media>.from(current) + ..[index] = _buildMediaWithFavorite(current[index], confirmed); + } + }); + } catch (_) { + if (!mounted) return; + // Revert the optimistic update on failure. + setState(() { + final current = _media; + if (current != null && index < current.length) { + _media = List<Media>.from(current)..[index] = original; + } + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not update favourite. Try again.'), + ), + ); + } + } + + /// Toggles the [MediaFilter.favoritesOnly] flag and reloads. + /// + /// Called by the app-bar heart icon button as a fast shortcut so the user + /// can show/hide favourites without opening [SearchFilterBar]. The filter + /// state is kept in sync with [SearchFilterBar] via [_filter] so both + /// controls always reflect the same state. + void _toggleFavoritesFilter() { + _onFiltersChanged(_filter.copyWith(favoritesOnly: !_filter.favoritesOnly)); + } + + // --------------------------------------------------------------------------- // Build // --------------------------------------------------------------------------- @@ -156,9 +233,26 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> { } /// Builds the app bar, showing [widget.setName] when available. + /// + /// Includes a heart icon button as a quick toggle for [MediaFilter.favoritesOnly]. + /// The icon is filled and highlighted when the filter is active so the user + /// always knows at a glance whether the favourites-only view is on. AppBar _buildAppBar() { return AppBar( title: Text(widget.setName ?? 'Set ${widget.setId}'), + actions: [ + IconButton( + key: const Key('media_grid_favorites_filter'), + tooltip: _filter.favoritesOnly ? 'Show all items' : 'Show favourites only', + icon: Icon( + _filter.favoritesOnly ? Icons.favorite : Icons.favorite_border, + color: _filter.favoritesOnly + ? Theme.of(context).colorScheme.error + : null, + ), + onPressed: _toggleFavoritesFilter, + ), + ], ); } @@ -190,6 +284,7 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> { : _MediaGrid( media: _media!, thumbnailUrlBuilder: _thumbnailUrl, + onFavoriteToggle: _toggleFavoriteAt, ), ); } @@ -214,6 +309,7 @@ class _MediaGrid extends StatelessWidget { const _MediaGrid({ required this.media, required this.thumbnailUrlBuilder, + required this.onFavoriteToggle, }); final List<Media> media; @@ -224,6 +320,13 @@ class _MediaGrid extends StatelessWidget { /// base-URL or API path structure (Dependency Inversion). final String Function(int mediaId) thumbnailUrlBuilder; + /// Called when the user taps the heart icon on a card. + /// + /// The argument is the [index] of the item within [media]. Using an index + /// (rather than the item itself) lets the state class update the correct + /// position in its list without a linear search. + final void Function(int index) onFavoriteToggle; + @override Widget build(BuildContext context) { return GridView.builder( @@ -241,6 +344,7 @@ class _MediaGrid extends StatelessWidget { itemBuilder: (context, index) => _MediaCard( item: media[index], thumbnailUrl: thumbnailUrlBuilder(media[index].id), + onFavoriteToggle: () => onFavoriteToggle(index), ), ); } @@ -252,14 +356,26 @@ class _MediaGrid extends StatelessWidget { /// - Thumbnail image with placeholder and error fallback. /// - Semi-transparent overlay at the bottom with title, type icon, and /// duration. +/// - A heart icon in the bottom-right corner of the thumbnail that reflects +/// the favourite state and fires [onFavoriteToggle] when tapped. /// -/// Tapping navigates to [AppRoutes.mediaDetailPath] for the item. +/// Tapping the card body navigates to [AppRoutes.mediaDetailPath] for the item. +/// Tapping the heart icon triggers the favourite toggle without navigating. class _MediaCard extends StatelessWidget { - const _MediaCard({required this.item, required this.thumbnailUrl}); + const _MediaCard({ + required this.item, + required this.thumbnailUrl, + required this.onFavoriteToggle, + }); final Media item; final String thumbnailUrl; + /// Called when the heart icon is tapped. The parent state performs the + /// optimistic update and API call; this widget is purely presentational + /// (Single Responsibility, Dependency Inversion). + final VoidCallback onFavoriteToggle; + @override Widget build(BuildContext context) { return Card( @@ -279,6 +395,19 @@ class _MediaCard extends StatelessWidget { bottom: 0, child: _InfoOverlay(item: item), ), + // Heart icon anchored to the bottom-right of the card. + // Positioned inside the info overlay's gradient area so it blends + // visually. [GestureDetector] is used so taps on the icon do NOT + // propagate to the [InkWell] above (which would navigate). + Positioned( + right: 4, + bottom: 4, + child: _FavoriteIconButton( + isFavorite: item.favorite, + mediaId: item.id, + onTap: onFavoriteToggle, + ), + ), ], ), ), @@ -286,6 +415,57 @@ class _MediaCard extends StatelessWidget { } } +// --------------------------------------------------------------------------- +// _FavoriteIconButton +// --------------------------------------------------------------------------- + +/// Small heart icon rendered on top of a media card thumbnail. +/// +/// Uses a [GestureDetector] with [HitTestBehavior.opaque] to consume the tap +/// before it reaches the parent [InkWell], preventing card-navigation from +/// firing when the user taps the heart. +/// +/// Design notes: +/// - Extracted as a separate widget so it is independently testable and +/// keeps [_MediaCard.build] under 30 lines (Single Responsibility). +/// - The icon is styled with a dark shadow so it remains legible over both +/// light and dark thumbnails. +class _FavoriteIconButton extends StatelessWidget { + const _FavoriteIconButton({ + required this.isFavorite, + required this.mediaId, + required this.onTap, + }); + + final bool isFavorite; + + /// Used only for the widget key so tests can find the button by media ID. + final int mediaId; + + /// Called when the user taps the heart; no navigation occurs. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: Key('media_card_favorite_$mediaId'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Icon( + isFavorite ? Icons.favorite : Icons.favorite_border, + size: 20, + color: isFavorite ? Colors.redAccent : Colors.white70, + shadows: const [ + Shadow( + color: Colors.black54, + blurRadius: 4, + ), + ], + ), + ); + } +} + /// Thumbnail image for a media card, loaded via [CachedNetworkImage]. /// /// Provides a grey placeholder while loading or when [thumbnailUrl] is empty, diff --git a/player-android/lib/widgets/search_filter_bar.dart b/player-android/lib/widgets/search_filter_bar.dart index 02ad770..54ad86f 100644 --- a/player-android/lib/widgets/search_filter_bar.dart +++ b/player-android/lib/widgets/search_filter_bar.dart @@ -114,6 +114,35 @@ class _SearchFilterBarState extends State<SearchFilterBar> { _searchController = TextEditingController(text: _filter.query ?? ''); } + /// Syncs internal filter state when the parent supplies a new [initialFilter]. + /// + /// This handles the case where an external control (e.g. the app-bar favourites + /// shortcut in [MediaGridScreen]) mutates the filter outside [SearchFilterBar] + /// and rebuilds the widget with a different [initialFilter]. Without this + /// override the bar would ignore the new value and show stale chips/icons. + /// + /// Only fields that actually changed are updated to avoid clearing the search + /// text while the user is still typing (the debounce timer owns the pending + /// query, so we leave [_searchController] alone unless the query changed). + @override + void didUpdateWidget(SearchFilterBar oldWidget) { + super.didUpdateWidget(oldWidget); + final newFilter = widget.initialFilter; + if (newFilter == _filter) return; // nothing changed + + // Sync the internal filter state without calling onFiltersChanged — + // the parent already holds the updated filter; we only need to reflect it. + setState(() { + _filter = newFilter; + }); + + // Sync the text field only when the query actually changed (avoid + // overwriting text the user is currently editing via the debounce path). + if (oldWidget.initialFilter.query != newFilter.query) { + _searchController.text = newFilter.query ?? ''; + } + } + @override void dispose() { // Always cancel the timer to prevent a stale callback firing after diff --git a/player-android/test/screens/media_detail_screen_test.dart b/player-android/test/screens/media_detail_screen_test.dart index 87ece3c..be8f373 100644 --- a/player-android/test/screens/media_detail_screen_test.dart +++ b/player-android/test/screens/media_detail_screen_test.dart @@ -10,6 +10,8 @@ // 6. Shows an error view when getMedia throws a DioException. // 7. Retry button triggers a fresh getMedia call. // 8. 404 error is mapped to the "not found" message. +// 9. Optimistic update: icon flips immediately before toggleFavorite returns. +// 10. Revert on error: icon reverts and SnackBar shown when toggleFavorite fails. // // Riverpod providers are overridden with fakes so tests run without a real // server or OS keychain. @@ -112,6 +114,36 @@ class _DelayedFakeApiClient extends PlayerApiClient { String thumbnailUrl(int mediaId) => ''; } +/// [PlayerApiClient] stub where [toggleFavorite] is delayed until +/// [completeToggle] is called. +/// +/// Allows tests to inspect the UI state between the tap and the API response +/// (the optimistic update window). +class _DelayedToggleFakeApiClient extends PlayerApiClient { + _DelayedToggleFakeApiClient({required this.mediaResult}) + : super(dio: Dio()); + + /// The media item returned synchronously by [getMedia]. + final Media mediaResult; + + final _toggleCompleter = Completer<bool>(); + + /// Resolves [toggleFavorite] with [result]. + void completeToggle(bool result) => _toggleCompleter.complete(result); + + /// Fails [toggleFavorite] with [error]. + void failToggle(Object error) => _toggleCompleter.completeError(error); + + @override + Future<Media> getMedia(int mediaId) async => mediaResult; + + @override + Future<bool> toggleFavorite(int mediaId) => _toggleCompleter.future; + + @override + String thumbnailUrl(int mediaId) => ''; +} + // --------------------------------------------------------------------------- // Sample data // --------------------------------------------------------------------------- @@ -441,6 +473,96 @@ void main() { findsOneWidget, ); }); + + testWidgets( + 'optimistic update: icon flips before toggleFavorite returns', + (tester) async { + // Use the delayed client so we can observe the UI before the API responds. + final fakeClient = _DelayedToggleFakeApiClient( + mediaResult: _kVideo, // favorite: false + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Starts as outlined (not favourite). + expect( + find.descendant( + of: find.byKey(const Key('media_detail_favorite')), + matching: find.byIcon(Icons.favorite_border), + ), + findsOneWidget, + ); + + // Tap — the toggle future is still pending. + await tester.tap(find.byKey(const Key('media_detail_favorite'))); + await tester.pump(); // one frame: setState for optimistic update + + // Icon must already show filled (optimistic) before the API responds. + expect( + find.descendant( + of: find.byKey(const Key('media_detail_favorite')), + matching: find.byIcon(Icons.favorite), + ), + findsOneWidget, + ); + + // Resolve the API call so the test doesn't leave pending async work. + fakeClient.completeToggle(true); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'revert on error: icon reverts and SnackBar shown when toggleFavorite fails', + (tester) async { + final fakeClient = _DelayedToggleFakeApiClient( + mediaResult: _kVideo, // favorite: false + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Starts as outlined. + expect( + find.descendant( + of: find.byKey(const Key('media_detail_favorite')), + matching: find.byIcon(Icons.favorite_border), + ), + findsOneWidget, + ); + + // Tap — the toggle future is still pending. + await tester.tap(find.byKey(const Key('media_detail_favorite'))); + await tester.pump(); // optimistic flip applied + + // Optimistic: icon is now filled. + expect( + find.descendant( + of: find.byKey(const Key('media_detail_favorite')), + matching: find.byIcon(Icons.favorite), + ), + findsOneWidget, + ); + + // Fail the API call. + fakeClient.failToggle(Exception('network error')); + await tester.pumpAndSettle(); + + // Icon must revert to outlined. + expect( + find.descendant( + of: find.byKey(const Key('media_detail_favorite')), + matching: find.byIcon(Icons.favorite_border), + ), + findsOneWidget, + ); + + // SnackBar must be visible. + expect( + find.text('Could not update favourite. Try again.'), + findsOneWidget, + ); + }); }); // -------------------------------------------------------------------------- diff --git a/player-android/test/screens/media_grid_screen_test.dart b/player-android/test/screens/media_grid_screen_test.dart index 986b901..9524e2f 100644 --- a/player-android/test/screens/media_grid_screen_test.dart +++ b/player-android/test/screens/media_grid_screen_test.dart @@ -8,6 +8,10 @@ // 5. Shows an empty-state widget when listMedia returns []. // 6. Shows an error view when listMedia throws a DioException. // 7. Pull-to-refresh calls listMedia again. +// 8. Heart overlay is shown on each card, filled for favourites. +// 9. Tapping heart overlay toggles favourite state (optimistic update). +// 10. Revert on error: icon reverts when toggleFavorite fails. +// 11. Favorites filter shortcut in app bar toggles favoritesOnly filter. // // Riverpod providers are overridden with fakes so tests run without a real // server or OS keychain. @@ -127,6 +131,71 @@ class _DelayedFakeApiClient extends PlayerApiClient { String thumbnailUrl(int mediaId) => ''; } +/// [PlayerApiClient] stub that supports both [listMedia] and [toggleFavorite]. +/// +/// [toggleFavorite] is delayed until [completeToggle] or [failToggle] is called +/// so tests can inspect the optimistic-update and revert paths. +class _FakeApiClientWithToggle extends PlayerApiClient { + _FakeApiClientWithToggle({required List<Media> initialMedia}) + : super(dio: Dio()) { + mediaResult = initialMedia; + } + + /// Mutable media list; [listMedia] always returns this. + late List<Media> mediaResult; + + /// When non-null, all [listMedia] calls throw this error. + Object? mediaError; + + /// Records the [MediaFilter.favoritesOnly] flag from the last [listMedia] call. + bool? lastFavoritesOnlyFlag; + + /// Completer for the current in-flight [toggleFavorite]; replaced per call. + Completer<bool>? _toggleCompleter; + + /// Resolve the current pending [toggleFavorite] with [result]. + void completeToggle(bool result) { + _toggleCompleter?.complete(result); + } + + /// Fail the current pending [toggleFavorite] with [error]. + void failToggle(Object error) { + _toggleCompleter?.completeError(error); + } + + @override + Future<List<Media>> listMedia({ + String? search, + int? setId, + List<int>? setIds, + String? type, + bool? favorites, + List<String>? tags, + double? minDuration, + double? maxDuration, + int? fileSizeMin, + int? fileSizeMax, + String? sort, + int? limit, + int? offset, + String? folder, + String? parent, + }) async { + lastFavoritesOnlyFlag = favorites; + if (mediaError != null) throw mediaError!; + return mediaResult; + } + + @override + Future<bool> toggleFavorite(int mediaId) { + _toggleCompleter = Completer<bool>(); + return _toggleCompleter!.future; + } + + @override + String thumbnailUrl(int mediaId) => ''; +} + // --------------------------------------------------------------------------- // Sample data // --------------------------------------------------------------------------- @@ -169,6 +238,26 @@ const _kAudio = Media( playCount: 12, ); +/// A sample media item that starts as a favourite. +const _kFavorite = Media( + id: 3, + setId: 10, + relPath: 'music/fav.mp3', + fileName: 'fav.mp3', + absPath: '/media/music/fav.mp3', + type: 'audio', + duration: 180.0, + codec: 'mp3', + resolution: '', + bitrate: 256, + fileSizeBytes: 4194304, + width: 0, + height: 0, + thumbnailPath: '', + playCount: 5, + favorite: true, // already a favourite +); + // --------------------------------------------------------------------------- // Helper: pump MediaGridScreen inside a minimal ProviderScope. // --------------------------------------------------------------------------- @@ -442,4 +531,217 @@ void main() { expect(fakeClient.listMediaCallCount, equals(2)); }); }); + + // -------------------------------------------------------------------------- + // Heart overlay + // -------------------------------------------------------------------------- + + group('heart overlay on media card', () { + testWidgets('shows outlined heart on non-favourite item', (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kVideo], // favorite: false + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The heart button for the non-favourite video must have the outlined icon. + final heartFinder = find.byKey(const Key('media_card_favorite_1')); + expect(heartFinder, findsOneWidget); + expect( + find.descendant( + of: heartFinder, + matching: find.byIcon(Icons.favorite_border), + ), + findsOneWidget, + ); + }); + + testWidgets('shows filled heart on a favourite item', (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kFavorite], // favorite: true + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + final heartFinder = find.byKey(const Key('media_card_favorite_3')); + expect(heartFinder, findsOneWidget); + expect( + find.descendant( + of: heartFinder, + matching: find.byIcon(Icons.favorite), + ), + findsOneWidget, + ); + }); + + testWidgets('tapping heart flips icon optimistically before API responds', + (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kVideo], // favorite: false + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Scroll the heart icon into view (it may be near the card bottom). + await tester + .ensureVisible(find.byKey(const Key('media_card_favorite_1'))); + await tester.pumpAndSettle(); + + // Starts as outlined. + expect( + find.descendant( + of: find.byKey(const Key('media_card_favorite_1')), + matching: find.byIcon(Icons.favorite_border), + ), + findsOneWidget, + ); + + // Tap heart — toggleFavorite is now in flight (pending). + await tester.tap(find.byKey(const Key('media_card_favorite_1'))); + await tester.pump(); // one frame for the optimistic setState + + // Icon must already show filled (optimistic update) before API responds. + expect( + find.descendant( + of: find.byKey(const Key('media_card_favorite_1')), + matching: find.byIcon(Icons.favorite), + ), + findsOneWidget, + ); + + // Resolve the API call to clean up pending async work. + fakeClient.completeToggle(true); + await tester.pumpAndSettle(); + }); + + testWidgets('revert on error: icon reverts and SnackBar shown on failure', + (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kVideo], // favorite: false + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Scroll the heart icon into view. + await tester + .ensureVisible(find.byKey(const Key('media_card_favorite_1'))); + await tester.pumpAndSettle(); + + // Tap the heart. + await tester.tap(find.byKey(const Key('media_card_favorite_1'))); + await tester.pump(); // optimistic flip + + // Optimistic update: icon is filled. + expect( + find.descendant( + of: find.byKey(const Key('media_card_favorite_1')), + matching: find.byIcon(Icons.favorite), + ), + findsOneWidget, + ); + + // Fail the API call. + fakeClient.failToggle(Exception('server error')); + await tester.pumpAndSettle(); + + // Icon must revert to outlined. + expect( + find.descendant( + of: find.byKey(const Key('media_card_favorite_1')), + matching: find.byIcon(Icons.favorite_border), + ), + findsOneWidget, + ); + + // SnackBar error message is visible. + expect( + find.text('Could not update favourite. Try again.'), + findsOneWidget, + ); + }); + + testWidgets('tapping card body does not trigger favourite toggle', + (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kVideo], + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Tap the card body (centre of the card, not the heart icon). + await tester.tap(find.byKey(const Key('media_card_1'))); + await tester.pumpAndSettle(); + + // Navigation happened — no pending toggle completer means toggle was not called. + // (If it had been called the completer would be non-null and the test would + // crash on dispose with an unhandled Completer future.) + expect(find.byKey(_kDestinationKey), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Favourites filter shortcut in app bar + // -------------------------------------------------------------------------- + + group('favourites filter shortcut', () { + testWidgets('app bar shows a heart icon button', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = [_kVideo]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('media_grid_favorites_filter')), + findsOneWidget, + ); + }); + + testWidgets( + 'tapping favourites filter shortcut passes favorites=true to listMedia', + (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kVideo], + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Initially no favourites filter. + expect(fakeClient.lastFavoritesOnlyFlag, isNull); + + // Tap the heart in the app bar. + await tester.tap(find.byKey(const Key('media_grid_favorites_filter'))); + await tester.pumpAndSettle(); + + // listMedia must have been called with favorites = true. + expect(fakeClient.lastFavoritesOnlyFlag, isTrue); + }); + + testWidgets('tapping shortcut twice reverts filter to off', (tester) async { + final fakeClient = _FakeApiClientWithToggle( + initialMedia: [_kVideo], + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + final shortcut = find.byKey(const Key('media_grid_favorites_filter')); + + // First tap — enable. + await tester.tap(shortcut); + await tester.pumpAndSettle(); + expect(fakeClient.lastFavoritesOnlyFlag, isTrue); + + // Second tap — disable. + await tester.tap(shortcut); + await tester.pumpAndSettle(); + // favorites=false means the flag is passed as null (no filter applied). + expect(fakeClient.lastFavoritesOnlyFlag, isNull); + }); + }); } diff --git a/player-android/test/widgets/search_filter_bar_test.dart b/player-android/test/widgets/search_filter_bar_test.dart index 4378368..bbcd5ce 100644 --- a/player-android/test/widgets/search_filter_bar_test.dart +++ b/player-android/test/widgets/search_filter_bar_test.dart @@ -10,6 +10,7 @@ // 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. +// 10. didUpdateWidget: external filter change updates the bar's UI. // // Run with: flutter test test/widgets/search_filter_bar_test.dart @@ -19,7 +20,7 @@ import 'package:player_android/models/media_filter.dart'; import 'package:player_android/widgets/search_filter_bar.dart'; // --------------------------------------------------------------------------- -// Helper: pump SearchFilterBar inside a minimal MaterialApp. +// Helpers // --------------------------------------------------------------------------- /// Pumps a [SearchFilterBar] in isolation inside a [MaterialApp]. @@ -43,6 +44,69 @@ Future<void> _pumpBar( ); } +// Key used to locate the [_ControlledFilterBar] state in rebuild tests. +final _controlledBarKey = GlobalKey<_ControlledFilterBarState>(); + +/// Stateful wrapper that lets tests push a new [MediaFilter] into +/// [SearchFilterBar] from outside (simulating an external control like the +/// MediaGridScreen app-bar shortcut). +class _ControlledFilterBar extends StatefulWidget { + const _ControlledFilterBar({ + super.key, + required this.initialFilter, + required this.onChanged, + }); + + final MediaFilter initialFilter; + final void Function(MediaFilter) onChanged; + + @override + _ControlledFilterBarState createState() => _ControlledFilterBarState(); +} + +class _ControlledFilterBarState extends State<_ControlledFilterBar> { + late MediaFilter _filter; + + @override + void initState() { + super.initState(); + _filter = widget.initialFilter; + } + + /// Simulates an external filter update (e.g. app-bar shortcut). + void pushFilter(MediaFilter filter) { + setState(() => _filter = filter); + } + + @override + Widget build(BuildContext context) { + return SearchFilterBar( + initialFilter: _filter, + onFiltersChanged: widget.onChanged, + ); + } +} + +/// Pumps [_ControlledFilterBar] and returns the wrapper key. +Future<GlobalKey<_ControlledFilterBarState>> _pumpControlledBar( + WidgetTester tester, { + MediaFilter initialFilter = const MediaFilter(), + required void Function(MediaFilter) onChanged, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _ControlledFilterBar( + key: _controlledBarKey, + initialFilter: initialFilter, + onChanged: onChanged, + ), + ), + ), + ); + return _controlledBarKey; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -301,4 +365,73 @@ void main() { ); }); }); + + // -------------------------------------------------------------------------- + // External filter update (didUpdateWidget) + // -------------------------------------------------------------------------- + + group('external filter update', () { + testWidgets( + 'favourites star updates when initialFilter is changed externally', + (tester) async { + final key = await _pumpControlledBar( + tester, + initialFilter: const MediaFilter(favoritesOnly: false), + onChanged: (_) {}, + ); + await tester.pumpAndSettle(); + + // Star should initially be the outlined (inactive) variant. + expect( + find.descendant( + of: find.byKey(const Key('favorites_toggle')), + matching: find.byIcon(Icons.star_border), + ), + findsOneWidget, + ); + + // Push a new filter with favoritesOnly = true from outside. + key.currentState!.pushFilter(const MediaFilter(favoritesOnly: true)); + await tester.pumpAndSettle(); + + // Star should now be filled (active). + expect( + find.descendant( + of: find.byKey(const Key('favorites_toggle')), + matching: find.byIcon(Icons.star), + ), + findsOneWidget, + ); + }); + + testWidgets('search text updates when query is changed externally', + (tester) async { + final key = await _pumpControlledBar( + tester, + initialFilter: const MediaFilter(query: 'original'), + onChanged: (_) {}, + ); + await tester.pumpAndSettle(); + + expect( + tester + .widget<TextField>(find.byKey(const Key('search_input'))) + .controller + ?.text, + equals('original'), + ); + + // External clear: push a filter with no query. + key.currentState!.pushFilter(const MediaFilter()); + await tester.pumpAndSettle(); + + expect( + tester + .widget<TextField>(find.byKey(const Key('search_input'))) + .controller + ?.text, + equals(''), + ); + }); + }); } |
