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 '../cache/mutation_queue.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), }; static const empty = LikedIds(artists: {}, albums: {}, 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 user = _ref.read(authControllerProvider).value; if (user == null) return; final db = _ref.read(appDbProvider); final entityType = _entityType(kind); 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 { final api = await _ref.read(likesApiProvider.future); if (wasLiked) { await api.unlike(kind, id); } else { await api.like(kind, id); } } catch (_) { // REST failed (network or HTTP). Don't roll back drift — the // user's intent is to like/unlike, and we want that visible to // them even when offline. Queue the call for replay; the // MutationReplayer will retry on next connectivity transition. // If retries exhaust (5 attempts), the row drops and the next // SyncController.sync brings drift back in line with the // server's authoritative state. await _ref.read(mutationQueueProvider).enqueue( wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd, {'kind': entityType, 'id': id}, ); } } String _entityType(LikeKind kind) => switch (kind) { LikeKind.artist => 'artist', LikeKind.album => 'album', LikeKind.track => 'track', }; } final likesControllerProvider = Provider((ref) => LikesController(ref));