diff --git a/flutter_client/lib/likes/likes_provider.dart b/flutter_client/lib/likes/likes_provider.dart index eed10762..947824ac 100644 --- a/flutter_client/lib/likes/likes_provider.dart +++ b/flutter_client/lib/likes/likes_provider.dart @@ -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((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 { - @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); - } +/// 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((ref) { + final db = ref.watch(appDbProvider); + return cacheFirst( + 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 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 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), - ), - }; - } + String _entityType(LikeKind kind) => switch (kind) { + LikeKind.artist => 'artist', + LikeKind.album => 'album', + LikeKind.track => 'track', + }; } -final likedIdsProvider = - AsyncNotifierProvider(LikedIdsController.new); +final likesControllerProvider = + Provider((ref) => LikesController(ref));