import 'dart:async'; import 'package:drift/drift.dart' as drift; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/me.dart'; import '../api/endpoints/quarantine.dart'; import '../cache/audio_cache_manager.dart' show appDbProvider; import '../cache/connectivity_provider.dart'; import '../cache/db.dart'; import '../cache/mutation_queue.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/quarantine_mine.dart'; import '../models/track.dart'; final quarantineApiProvider = FutureProvider((ref) async { return QuarantineApi(await ref.watch(dioProvider.future)); }); /// Drift-first ("hidden") tab controller. Reads from cached_quarantine_ /// mine via a drift watch() subscription set up in build(), so any drift /// mutation (incoming sync, optimistic flag/unflag, SSE-triggered /// refresh) re-emits the list automatically. /// /// API surface unchanged from the prior FutureProvider-backed controller /// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()` /// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory /// AsyncNotifier state to drift so the Hidden tab paints from disk on /// cold open and the quarantine list is queryable offline. class MyQuarantineController extends AsyncNotifier> { @override Future> build() async { final db = ref.watch(appDbProvider); // Subscribe to drift first so any mutation (incoming sync, flag/ // unflag, etc.) propagates without needing a manual invalidate. // Disposed when the provider rebuilds or the listener detaches. final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) { state = AsyncData(rows.map(_rowToModel).toList()); }); ref.onDispose(sub.cancel); // SWR refresh on every build so the freshest server-side state // overtakes drift in the background. Don't await — UI gets the // cached snapshot first, freshness lands later via the watch(). unawaited(_refreshFromServer()); final initial = await db.select(db.cachedQuarantineMine).get(); return initial.map(_rowToModel).toList(); } /// Hits /api/quarantine/mine and replaces the local table with the /// authoritative server set. Best-effort: offline / 5xx / unresolved /// dependencies (e.g. uninitialised serverUrl in tests) all collapse /// to a no-op — drift stays on the prior cached state. Future _refreshFromServer() async { try { final online = await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true); if (!online) return; final dio = await ref.read(dioProvider.future); final fresh = await MeApi(dio).quarantineMine(); final db = ref.read(appDbProvider); await db.transaction(() async { // Full replace — quarantine list is small and we want server // unflags from other devices to take effect. Doing this in a // transaction means the watch() sees exactly one emission with // the merged state, not a delete-then-insert flicker. await db.delete(db.cachedQuarantineMine).go(); for (final r in fresh) { await db .into(db.cachedQuarantineMine) .insertOnConflictUpdate(_modelToCompanion(r)); } }); } catch (_) { // Swallow — cached state stays visible. } } /// True when this track is in the caller's quarantine list. bool isHidden(String trackId) => (state.value ?? const []).any((r) => r.trackId == trackId); /// Optimistic flag: inserts the synthetic row into drift (watch fires /// immediately so the UI updates), calls server, rolls back on error. Future flag(TrackRef track, String reason, String notes) async { final db = ref.read(appDbProvider); final existed = await (db.select(db.cachedQuarantineMine) ..where((t) => t.trackId.equals(track.id))) .getSingleOrNull(); if (existed != null) return; // already hidden final companion = CachedQuarantineMineCompanion.insert( trackId: track.id, reason: reason, notes: notes.isEmpty ? const drift.Value.absent() : drift.Value(notes), createdAt: DateTime.now().toUtc().toIso8601String(), trackTitle: track.title, trackDurationMs: drift.Value(track.durationSec * 1000), albumId: track.albumId, albumTitle: track.albumTitle, artistId: track.artistId, artistName: track.artistName, ); await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion); try { final api = await ref.read(quarantineApiProvider.future); await api.flag(track.id, reason, notes: notes); } catch (_) { // REST failed — don't roll back; queue for replay so the user's // "hide this track" intent persists offline. await ref.read(mutationQueueProvider).enqueue( MutationKinds.quarantineFlag, {'trackId': track.id, 'reason': reason, 'notes': notes}, ); } } /// Optimistic unflag: removes the drift row, calls server, queues /// the call on failure so the unhide intent persists offline. Future unflag(String trackId) async { final db = ref.read(appDbProvider); final existing = await (db.select(db.cachedQuarantineMine) ..where((t) => t.trackId.equals(trackId))) .getSingleOrNull(); if (existing == null) return; await (db.delete(db.cachedQuarantineMine) ..where((t) => t.trackId.equals(trackId))) .go(); try { final api = await ref.read(quarantineApiProvider.future); await api.unflag(trackId); } catch (_) { // Don't restore the drift row; queue the unflag for replay. await ref .read(mutationQueueProvider) .enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId}); } } static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) => QuarantineMineRow( trackId: r.trackId, reason: r.reason, notes: r.notes, createdAt: r.createdAt, trackTitle: r.trackTitle, trackDurationMs: r.trackDurationMs, albumId: r.albumId, albumTitle: r.albumTitle, albumCoverArtPath: r.albumCoverArtPath, artistId: r.artistId, artistName: r.artistName, ); static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) => CachedQuarantineMineCompanion.insert( trackId: r.trackId, reason: r.reason, notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes), createdAt: r.createdAt, trackTitle: r.trackTitle, trackDurationMs: drift.Value(r.trackDurationMs), albumId: r.albumId, albumTitle: r.albumTitle, albumCoverArtPath: r.albumCoverArtPath == null ? const drift.Value.absent() : drift.Value(r.albumCoverArtPath), artistId: r.artistId, artistName: r.artistName, ); } final myQuarantineProvider = AsyncNotifierProvider>( MyQuarantineController.new, );