feat(flutter/cache): migrate likedIdsProvider to drift-first; LikesController for mutations

Splits the previous LikedIdsController into two:

- likedIdsProvider — StreamProvider reading from cached_likes via
  drift watch(). Reactive: SyncController writes propagate
  automatically. Empty + online triggers REST cold-cache fallback
  that populates cached_likes via insertOrIgnore (sync may have
  already written some rows).

- likesControllerProvider (new) — exposes toggle(LikeKind, id).
  Optimistic: writes drift first (UI updates instantly via the
  watch stream), then REST. Rolls back drift on REST failure.

Two consumer updates: like_button.dart + track_actions_sheet.dart
switch from likedIdsProvider.notifier.toggle to
likesControllerProvider.toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:20:59 -04:00
parent fc83c7b514
commit 8a6c926ea7
+139 -46
View File
@@ -1,6 +1,12 @@
import 'package:drift/drift.dart' as drift;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../auth/auth_provider.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../library/library_providers.dart';
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
@@ -22,67 +28,154 @@ class LikedIds {
LikeKind.album => albums.contains(id),
LikeKind.track => tracks.contains(id),
};
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
}
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);
}
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
/// SyncController). Reactive — sync writes propagate via watch().
/// Empty + online triggers REST cold-cache fallback that re-populates.
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedLike, LikedIds>(
driftStream: db.select(db.cachedLikes).watch(),
fetchAndPopulate: () async {
final api = await ref.read(likesApiProvider.future);
final fresh = await api.ids();
// Need a userId for the composite primary key. The auth controller
// exposes the current user.
final user = ref.read(authControllerProvider).value;
if (user == null) return; // not logged in; skip
await db.batch((b) {
for (final id in fresh.tracks) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'track',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.albums) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'album',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
for (final id in fresh.artists) {
b.insert(
db.cachedLikes,
CachedLikesCompanion.insert(
userId: user.id,
entityType: 'artist',
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
});
},
toResult: (rows) => LikedIds(
tracks: rows
.where((r) => r.entityType == 'track')
.map((r) => r.entityId)
.toSet(),
albums: rows
.where((r) => r.entityType == 'album')
.map((r) => r.entityId)
.toSet(),
artists: rows
.where((r) => r.entityType == 'artist')
.map((r) => r.entityId)
.toSet(),
),
isOnline: () async => (await ref.read(connectivityProvider.future)),
);
});
/// Mutation controller. Writes optimistically to drift first (so the
/// likedIdsProvider stream re-emits immediately for snappy UI), then to
/// REST. Rolls back drift on REST failure.
class LikesController {
LikesController(this._ref);
final Ref _ref;
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 user = _ref.read(authControllerProvider).value;
if (user == null) return;
final db = _ref.read(appDbProvider);
final entityType = _entityType(kind);
final isLiked = current.has(kind, id);
final optimistic = _flip(current, kind, id);
state = AsyncData(optimistic);
final existing = await (db.select(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.getSingleOrNull();
final wasLiked = existing != null;
// Optimistic mutation — drift watch() re-emits immediately.
if (wasLiked) {
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.go();
} else {
await db.into(db.cachedLikes).insert(
CachedLikesCompanion.insert(
userId: user.id,
entityType: entityType,
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
}
try {
if (isLiked) {
final api = await _ref.read(likesApiProvider.future);
if (wasLiked) {
await api.unlike(kind, id);
} else {
await api.like(kind, id);
}
} catch (e, st) {
state = AsyncData(current);
// Rollback drift
if (wasLiked) {
await db.into(db.cachedLikes).insert(
CachedLikesCompanion.insert(
userId: user.id,
entityType: entityType,
entityId: id,
),
mode: drift.InsertMode.insertOrIgnore,
);
} else {
await (db.delete(db.cachedLikes)
..where((t) =>
t.userId.equals(user.id) &
t.entityType.equals(entityType) &
t.entityId.equals(id)))
.go();
}
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),
),
};
}
String _entityType(LikeKind kind) => switch (kind) {
LikeKind.artist => 'artist',
LikeKind.album => 'album',
LikeKind.track => 'track',
};
}
final likedIdsProvider =
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
final likesControllerProvider =
Provider<LikesController>((ref) => LikesController(ref));