99462185b4
Final slice of the smooth-loading pass. Adds CachedQuarantineMine (schema 5, columnar so flag/unflag can do row-level mutation) and rewires MyQuarantineController to read from drift via watch() + SWR refresh; flag/unflag write drift first and roll back on REST failure. Public API (.flag / .unflag / .isHidden) unchanged so existing call sites (library_screen Hidden tab, TrackActionsSheet) keep working. Tests updated to match: bypassed-build-via-_StubController approach no longer makes sense now that state lives in drift, so the suite is rewritten against NativeDatabase.memory() with the same libsqlite3 skip the sync_controller suite uses on the CI runner. The Hidden tab now paints from disk on cold open, the list is queryable offline, and a flag from another device that arrived in this user's quarantine via SSE-triggered invalidate lands the same way as a local flag.
185 lines
7.0 KiB
Dart
185 lines
7.0 KiB
Dart
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 '../library/library_providers.dart' show dioProvider;
|
|
import '../models/quarantine_mine.dart';
|
|
import '../models/track.dart';
|
|
|
|
final quarantineApiProvider = FutureProvider<QuarantineApi>((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<List<QuarantineMineRow>> {
|
|
@override
|
|
Future<List<QuarantineMineRow>> 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<void> _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<void> 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 (e, st) {
|
|
// Rollback the optimistic insert; watch() re-emits the prior set.
|
|
await (db.delete(db.cachedQuarantineMine)
|
|
..where((t) => t.trackId.equals(track.id)))
|
|
.go();
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
|
|
/// Optimistic unflag: removes the drift row, calls server, restores
|
|
/// on error.
|
|
Future<void> 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 (e, st) {
|
|
// Restore the row we just deleted — toCompanion(true) preserves
|
|
// every field including the original fetchedAt timestamp.
|
|
await db
|
|
.into(db.cachedQuarantineMine)
|
|
.insertOnConflictUpdate(existing.toCompanion(true));
|
|
Error.throwWithStackTrace(e, st);
|
|
}
|
|
}
|
|
|
|
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, List<QuarantineMineRow>>(
|
|
MyQuarantineController.new,
|
|
);
|