335940cf23
User intent (likes, hides, playlist adds, Lidarr requests, cancels) now persists across network loss. Controllers write their optimistic local state to drift first, then try the REST call; on failure the call is enqueued in cached_mutations rather than rolled back. MutationReplayer drains the queue on connectivity transitions and a 1-minute periodic tick. **Infrastructure (schema 8):** * CachedMutations table — id / kind / payload (JSON) / createdAt / lastAttemptAt / attempts. Drop-after-5-attempts semantics: a permanently-failing mutation eventually drops, and next sync reconciles drift to the server's authoritative state. * MutationQueue.enqueue / pendingCount * MutationReplayer.start + .drain — start fires from app.dart's postFrameCallback alongside SyncController / Prefetcher / etc. * Kind registry: like.add / like.remove / quarantine.flag / quarantine.unflag / playlist.append / request.create / request.cancel — each with a Ref+payload handler that re-fires the corresponding REST call. **Wired surfaces:** * LikesController.toggle — optimistic drift like/unlike stays across REST failure; queues the call. Drops the old rollback. * MyQuarantineController.flag / .unflag — same pattern. Hide/unhide visibly persists offline; replays when back online. * addToPlaylistActionProvider — now does an optimistic cached_playlist_tracks write (position = max + 1) so the playlist detail screen shows the new track instantly. Queues appendTracks on REST failure. * DiscoverScreen._request — queues request.create on DioException. No drift state for the request itself (myRequestsProvider is still REST-only) so the row won't show on /requests until replay succeeds — acceptable for v1. * MyRequestsController.cancel — optimistic in-memory remove no longer restores on failure; queues request.cancel instead. **Test update:** quarantine_provider_test "flag rolls back on server failure" renamed and rewritten to assert the new offline behavior: optimistic drift row persists, mutation is enqueued for replay. **Out of scope (v2):** * Playlist create / rename / delete (no Flutter UI exposes these yet) * Lidarr request optimistic local row (would need a cached_requests drift table) * UI "syncing N pending changes" indicator (operator preference: silent unless we find a concrete need)
175 lines
5.6 KiB
Dart
175 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 '../cache/mutation_queue.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 (_) {
|
|
// 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<LikesController>((ref) => LikesController(ref));
|