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:
@@ -1,6 +1,12 @@
|
|||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.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';
|
import '../library/library_providers.dart';
|
||||||
|
|
||||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
@@ -22,67 +28,154 @@ class LikedIds {
|
|||||||
LikeKind.album => albums.contains(id),
|
LikeKind.album => albums.contains(id),
|
||||||
LikeKind.track => tracks.contains(id),
|
LikeKind.track => tracks.contains(id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
|
||||||
}
|
}
|
||||||
|
|
||||||
class LikedIdsController extends AsyncNotifier<LikedIds> {
|
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
|
||||||
@override
|
/// SyncController). Reactive — sync writes propagate via watch().
|
||||||
Future<LikedIds> build() async {
|
/// Empty + online triggers REST cold-cache fallback that re-populates.
|
||||||
final api = await ref.watch(likesApiProvider.future);
|
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
|
||||||
final r = await api.ids();
|
final db = ref.watch(appDbProvider);
|
||||||
return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks);
|
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 {
|
Future<void> toggle(LikeKind kind, String id) async {
|
||||||
final api = await ref.read(likesApiProvider.future);
|
final user = _ref.read(authControllerProvider).value;
|
||||||
final current = state.value ??
|
if (user == null) return;
|
||||||
const LikedIds(artists: {}, albums: {}, tracks: {});
|
final db = _ref.read(appDbProvider);
|
||||||
|
final entityType = _entityType(kind);
|
||||||
|
|
||||||
final isLiked = current.has(kind, id);
|
final existing = await (db.select(db.cachedLikes)
|
||||||
final optimistic = _flip(current, kind, id);
|
..where((t) =>
|
||||||
state = AsyncData(optimistic);
|
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 {
|
try {
|
||||||
if (isLiked) {
|
final api = await _ref.read(likesApiProvider.future);
|
||||||
|
if (wasLiked) {
|
||||||
await api.unlike(kind, id);
|
await api.unlike(kind, id);
|
||||||
} else {
|
} else {
|
||||||
await api.like(kind, id);
|
await api.like(kind, id);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} 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);
|
Error.throwWithStackTrace(e, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LikedIds _flip(LikedIds before, LikeKind kind, String id) {
|
String _entityType(LikeKind kind) => switch (kind) {
|
||||||
Set<String> swap(Set<String> s) {
|
LikeKind.artist => 'artist',
|
||||||
final next = {...s};
|
LikeKind.album => 'album',
|
||||||
if (s.contains(id)) {
|
LikeKind.track => 'track',
|
||||||
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 =
|
final likesControllerProvider =
|
||||||
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
|
Provider<LikesController>((ref) => LikesController(ref));
|
||||||
|
|||||||
Reference in New Issue
Block a user