feat(flutter/likes): LikeButton with optimistic toggle + rollback
LikedIdsController loads /api/likes/ids once and mutates the local set on toggle. Failed mutations roll the set back so the icon never lies. Wired into ArtistDetail header, AlbumDetail header, and per-track in the album track list.
This commit is contained in:
@@ -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<void> like(LikeKind kind, String id) async {
|
||||||
|
await _dio.post<void>('/api/likes/${kind.path}/$id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unlike(LikeKind kind, String id) async {
|
||||||
|
await _dio.delete<void>('/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<String> artists, Set<String> albums, Set<String> tracks})>
|
||||||
|
ids() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/likes/ids');
|
||||||
|
final body = r.data ?? const <String, dynamic>{};
|
||||||
|
Set<String> 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../likes/like_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/track_row.dart';
|
import 'widgets/track_row.dart';
|
||||||
@@ -43,11 +45,15 @@ class AlbumDetailScreen extends ConsumerWidget {
|
|||||||
onPressed: () { /* play wired in Task 18 */ },
|
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)
|
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),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../likes/like_button.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
@@ -50,7 +52,7 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
onPressed: () { /* play wired in Task 18 */ },
|
onPressed: () { /* play wired in Task 18 */ },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// LikeButton goes here in Task 16.
|
LikeButton(kind: LikeKind.artist, id: a.id, size: 28),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
|
|||||||
@@ -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<FabledSwordTheme>()!;
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../library/library_providers.dart';
|
||||||
|
|
||||||
|
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
|
return LikesApi(await ref.watch(dioProvider.future));
|
||||||
|
});
|
||||||
|
|
||||||
|
class LikedIds {
|
||||||
|
const LikedIds({
|
||||||
|
required this.artists,
|
||||||
|
required this.albums,
|
||||||
|
required this.tracks,
|
||||||
|
});
|
||||||
|
final Set<String> artists;
|
||||||
|
final Set<String> albums;
|
||||||
|
final Set<String> 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<LikedIds> {
|
||||||
|
@override
|
||||||
|
Future<LikedIds> 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<void> 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<String> swap(Set<String> 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, LikedIds>(LikedIdsController.new);
|
||||||
@@ -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<void> like(LikeKind kind, String id) async {
|
||||||
|
if (throwOnNext) throw StateError('boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> unlike(LikeKind kind, String id) async {
|
||||||
|
if (throwOnNext) throw StateError('boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({Set<String> artists, Set<String> albums, Set<String> tracks})>
|
||||||
|
ids() async =>
|
||||||
|
(artists: <String>{}, albums: <String>{}, tracks: <String>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user