summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
Diffstat (limited to 'player-android')
-rw-r--r--player-android/lib/api/dio_player_api_client.dart23
-rw-r--r--player-android/lib/screens/media_grid_screen.dart184
-rw-r--r--player-android/lib/widgets/search_filter_bar.dart29
-rw-r--r--player-android/test/screens/media_detail_screen_test.dart122
-rw-r--r--player-android/test/screens/media_grid_screen_test.dart302
-rw-r--r--player-android/test/widgets/search_filter_bar_test.dart135
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(''),
+ );
+ });
+ });
}