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:
2026-05-02 17:34:59 -04:00
parent 6ec7cea362
commit 44e2623b3c
6 changed files with 238 additions and 3 deletions
+36
View File
@@ -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);