8a6c926ea7
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>
182 lines
5.6 KiB
Dart
182 lines
5.6 KiB
Dart
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 {
|
|
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),
|
|
};
|
|
|
|
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<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 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 (e, st) {
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
String _entityType(LikeKind kind) => switch (kind) {
|
|
LikeKind.artist => 'artist',
|
|
LikeKind.album => 'album',
|
|
LikeKind.track => 'track',
|
|
};
|
|
}
|
|
|
|
final likesControllerProvider =
|
|
Provider<LikesController>((ref) => LikesController(ref));
|