diff --git a/flutter_client/lib/api/endpoints/likes.dart b/flutter_client/lib/api/endpoints/likes.dart new file mode 100644 index 00000000..03771e47 --- /dev/null +++ b/flutter_client/lib/api/endpoints/likes.dart @@ -0,0 +1,40 @@ +import 'package:dio/dio.dart'; + +enum LikeKind { artist, album, track } + +extension LikeKindPath on LikeKind { + String get path => switch (this) { + LikeKind.artist => 'artists', + LikeKind.album => 'albums', + LikeKind.track => 'tracks', + }; +} + +class LikesApi { + LikesApi(this._dio); + final Dio _dio; + + Future like(LikeKind kind, String id) async { + await _dio.post('/api/likes/${kind.path}/$id'); + } + + Future unlike(LikeKind kind, String id) async { + await _dio.delete('/api/likes/${kind.path}/$id'); + } + + /// Returns sets of {artists, albums, tracks} the user has liked. + /// Server response keys (verified in internal/api/likes.go + /// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`. + Future<({Set artists, Set albums, Set tracks})> + ids() async { + final r = await _dio.get>('/api/likes/ids'); + final body = r.data ?? const {}; + Set set(String key) => + ((body[key] as List?) ?? const []).map((e) => e.toString()).toSet(); + return ( + artists: set('artist_ids'), + albums: set('album_ids'), + tracks: set('track_ids'), + ); + } +} diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart index 6aa29c0c..20b4c82a 100644 --- a/flutter_client/lib/library/album_detail_screen.dart +++ b/flutter_client/lib/library/album_detail_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../api/endpoints/likes.dart'; +import '../likes/like_button.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/track_row.dart'; @@ -43,11 +45,15 @@ class AlbumDetailScreen extends ConsumerWidget { onPressed: () { /* play wired in Task 18 */ }, ), ), - // LikeButton goes here in Task 16. + LikeButton(kind: LikeKind.album, id: r.album.id, size: 28), ]), ), for (final t in r.tracks) - TrackRow(track: t, onTap: () { /* play wired in Task 18 */ }), + TrackRow( + track: t, + onTap: () { /* play wired in Task 18 */ }, + trailing: LikeButton(kind: LikeKind.track, id: t.id), + ), ]), ), ); diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart index 0cb7e291..f3185170 100644 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ b/flutter_client/lib/library/artist_detail_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../api/endpoints/likes.dart'; +import '../likes/like_button.dart'; import '../models/album.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; @@ -50,7 +52,7 @@ class ArtistDetailScreen extends ConsumerWidget { onPressed: () { /* play wired in Task 18 */ }, ), ), - // LikeButton goes here in Task 16. + LikeButton(kind: LikeKind.artist, id: a.id, size: 28), ]), ), const Padding( diff --git a/flutter_client/lib/likes/like_button.dart b/flutter_client/lib/likes/like_button.dart new file mode 100644 index 00000000..b33f9ab8 --- /dev/null +++ b/flutter_client/lib/likes/like_button.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/likes.dart'; +import '../theme/theme_extension.dart'; +import 'likes_provider.dart'; + +class LikeButton extends ConsumerWidget { + const LikeButton({ + required this.kind, + required this.id, + this.size = 22, + super.key, + }); + final LikeKind kind; + final String id; + final double size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final state = ref.watch(likedIdsProvider); + final liked = state.maybeWhen( + data: (s) => s.has(kind, id), + orElse: () => false, + ); + return IconButton( + icon: Icon( + liked ? Icons.favorite : Icons.favorite_border, + color: liked ? fs.accent : fs.ash, + size: size, + ), + onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id), + ); + } +} diff --git a/flutter_client/lib/likes/likes_provider.dart b/flutter_client/lib/likes/likes_provider.dart new file mode 100644 index 00000000..eed10762 --- /dev/null +++ b/flutter_client/lib/likes/likes_provider.dart @@ -0,0 +1,88 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/likes.dart'; +import '../library/library_providers.dart'; + +final likesApiProvider = FutureProvider((ref) async { + return LikesApi(await ref.watch(dioProvider.future)); +}); + +class LikedIds { + const LikedIds({ + required this.artists, + required this.albums, + required this.tracks, + }); + final Set artists; + final Set albums; + final Set tracks; + + bool has(LikeKind kind, String id) => switch (kind) { + LikeKind.artist => artists.contains(id), + LikeKind.album => albums.contains(id), + LikeKind.track => tracks.contains(id), + }; +} + +class LikedIdsController extends AsyncNotifier { + @override + Future build() async { + final api = await ref.watch(likesApiProvider.future); + final r = await api.ids(); + return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks); + } + + Future toggle(LikeKind kind, String id) async { + final api = await ref.read(likesApiProvider.future); + final current = state.value ?? + const LikedIds(artists: {}, albums: {}, tracks: {}); + + final isLiked = current.has(kind, id); + final optimistic = _flip(current, kind, id); + state = AsyncData(optimistic); + + try { + if (isLiked) { + await api.unlike(kind, id); + } else { + await api.like(kind, id); + } + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } + + LikedIds _flip(LikedIds before, LikeKind kind, String id) { + Set swap(Set s) { + final next = {...s}; + if (s.contains(id)) { + next.remove(id); + } else { + next.add(id); + } + return next; + } + + return switch (kind) { + LikeKind.artist => LikedIds( + artists: swap(before.artists), + albums: before.albums, + tracks: before.tracks, + ), + LikeKind.album => LikedIds( + artists: before.artists, + albums: swap(before.albums), + tracks: before.tracks, + ), + LikeKind.track => LikedIds( + artists: before.artists, + albums: before.albums, + tracks: swap(before.tracks), + ), + }; + } +} + +final likedIdsProvider = + AsyncNotifierProvider(LikedIdsController.new); diff --git a/flutter_client/test/likes/like_button_test.dart b/flutter_client/test/likes/like_button_test.dart new file mode 100644 index 00000000..e499626a --- /dev/null +++ b/flutter_client/test/likes/like_button_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/endpoints/likes.dart'; +import 'package:minstrel/likes/like_button.dart'; +import 'package:minstrel/likes/likes_provider.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _ThrowingLikesApi implements LikesApi { + bool throwOnNext = false; + + @override + Future like(LikeKind kind, String id) async { + if (throwOnNext) throw StateError('boom'); + } + + @override + Future unlike(LikeKind kind, String id) async { + if (throwOnNext) throw StateError('boom'); + } + + @override + Future<({Set artists, Set albums, Set tracks})> + ids() async => + (artists: {}, albums: {}, tracks: {}); +} + +void main() { + testWidgets('tap toggles icon optimistically; rollback on error', + (tester) async { + final api = _ThrowingLikesApi(); + final container = ProviderContainer(overrides: [ + likesApiProvider.overrideWith((ref) async => api), + ]); + addTearDown(container.dispose); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: buildThemeData(), + home: const Scaffold( + body: LikeButton(kind: LikeKind.album, id: 'al-1'), + ), + ), + )); + await tester.pumpAndSettle(); + + // First tap = like; succeeds → icon flips to favorite. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + expect(find.byIcon(Icons.favorite), findsOneWidget); + + // Force the next mutation to fail; rollback restores prior state. + api.throwOnNext = true; + final notifier = container.read(likedIdsProvider.notifier); + try { + await notifier.toggle(LikeKind.album, 'al-1'); + } catch (_) {/* expected */} + await tester.pump(); + expect(find.byIcon(Icons.favorite), findsOneWidget); // rolled back + }); +}