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)
66 lines
2.2 KiB
Dart
66 lines
2.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../api/endpoints/requests.dart';
|
|
import '../cache/mutation_queue.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
import '../models/admin_request.dart';
|
|
|
|
final requestsApiProvider = FutureProvider<RequestsApi>((ref) async {
|
|
return RequestsApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh
|
|
/// every 12s while any row is mid-ingest (status='approved'), stop when
|
|
/// all rows settle. Polls regardless of app foreground/background — a
|
|
/// future enhancement could pause via WidgetsBindingObserver, but for
|
|
/// v1 the small extra refresh is acceptable.
|
|
class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
|
static const Duration _pollInterval = Duration(seconds: 12);
|
|
|
|
Timer? _pollTimer;
|
|
|
|
@override
|
|
Future<List<AdminRequest>> build() async {
|
|
ref.onDispose(() {
|
|
_pollTimer?.cancel();
|
|
_pollTimer = null;
|
|
});
|
|
final api = await ref.watch(requestsApiProvider.future);
|
|
final rows = await api.listMine();
|
|
_maybeStartPolling(rows);
|
|
return rows;
|
|
}
|
|
|
|
void _maybeStartPolling(List<AdminRequest> rows) {
|
|
_pollTimer?.cancel();
|
|
if (rows.any((r) => r.status == 'approved')) {
|
|
_pollTimer = Timer.periodic(_pollInterval, (_) {
|
|
ref.invalidateSelf();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Optimistic remove + REST cancel. On failure the row stays
|
|
/// removed in-memory and the cancel is queued for replay — this
|
|
/// keeps the user's intent visible across network loss instead of
|
|
/// restoring a row they explicitly asked to remove.
|
|
Future<void> cancel(String id) async {
|
|
final api = await ref.read(requestsApiProvider.future);
|
|
final current = state.value ?? const <AdminRequest>[];
|
|
state = AsyncData(current.where((r) => r.id != id).toList());
|
|
try {
|
|
await api.cancel(id);
|
|
} catch (_) {
|
|
await ref
|
|
.read(mutationQueueProvider)
|
|
.enqueue(MutationKinds.requestCancel, {'id': id});
|
|
}
|
|
}
|
|
}
|
|
|
|
final myRequestsProvider =
|
|
AsyncNotifierProvider<MyRequestsController, List<AdminRequest>>(
|
|
MyRequestsController.new);
|