Files
minstrel/flutter_client/lib/requests/requests_provider.dart
T
bvandeusen 335940cf23 feat(cache): outbound mutation queue for offline-resilient REST
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)
2026-05-14 18:27:05 -04:00

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);