diff --git a/flutter_client/lib/quarantine/quarantine_provider.dart b/flutter_client/lib/quarantine/quarantine_provider.dart new file mode 100644 index 00000000..3bd1a902 --- /dev/null +++ b/flutter_client/lib/quarantine/quarantine_provider.dart @@ -0,0 +1,70 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/me.dart'; +import '../api/endpoints/quarantine.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)); +}); + +class MyQuarantineController extends AsyncNotifier> { + @override + Future> build() async { + final dio = await ref.watch(dioProvider.future); + return MeApi(dio).quarantineMine(); + } + + /// 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: prepends a synthetic row, calls server, + /// rolls back on error. + Future flag(TrackRef track, String reason, String notes) async { + final api = await ref.read(quarantineApiProvider.future); + final current = state.value ?? const []; + if (current.any((r) => r.trackId == track.id)) return; // already hidden + final synthetic = QuarantineMineRow( + trackId: track.id, + reason: reason, + notes: notes.isEmpty ? null : notes, + createdAt: DateTime.now().toUtc().toIso8601String(), + trackTitle: track.title, + trackDurationMs: track.durationSec * 1000, + albumId: track.albumId, + albumTitle: track.albumTitle, + artistId: track.artistId, + artistName: track.artistName, + ); + state = AsyncData([synthetic, ...current]); + try { + await api.flag(track.id, reason, notes: notes); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } + + /// Optimistic unflag: removes the row, calls server, rolls back on error. + Future unflag(String trackId) async { + final api = await ref.read(quarantineApiProvider.future); + final current = state.value ?? const []; + final removed = current.where((r) => r.trackId == trackId).toList(); + if (removed.isEmpty) return; + state = AsyncData(current.where((r) => r.trackId != trackId).toList()); + try { + await api.unflag(trackId); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } +} + +final myQuarantineProvider = + AsyncNotifierProvider>( + MyQuarantineController.new, +); diff --git a/flutter_client/test/quarantine/quarantine_provider_test.dart b/flutter_client/test/quarantine/quarantine_provider_test.dart new file mode 100644 index 00000000..b146102f --- /dev/null +++ b/flutter_client/test/quarantine/quarantine_provider_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/api/endpoints/quarantine.dart'; +import 'package:minstrel/models/quarantine_mine.dart'; +import 'package:minstrel/models/track.dart'; +import 'package:minstrel/quarantine/quarantine_provider.dart'; + +class _StubQuarantineApi implements QuarantineApi { + _StubQuarantineApi({this.shouldThrow = false}); + bool shouldThrow; + int flagCalls = 0; + int unflagCalls = 0; + + @override + Future flag(String trackId, String reason, {String notes = ''}) async { + flagCalls++; + if (shouldThrow) throw Exception('simulated server failure'); + } + + @override + Future unflag(String trackId) async { + unflagCalls++; + if (shouldThrow) throw Exception('simulated server failure'); + } +} + +class _StubController extends MyQuarantineController { + _StubController(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +const _track = TrackRef( + id: 't1', + title: 'Roygbiv', + albumId: 'a1', + albumTitle: 'Geogaddi', + artistId: 'ar1', + artistName: 'Boards of Canada', + durationSec: 137, + trackNumber: 4, + streamUrl: '', +); + +const _existing = QuarantineMineRow( + trackId: 't1', + reason: 'bad_rip', + notes: null, + createdAt: '2026-05-01T00:00:00Z', + trackTitle: 'Roygbiv', + trackDurationMs: 137000, + albumId: 'a1', + albumTitle: 'Geogaddi', + artistId: 'ar1', + artistName: 'Boards of Canada', +); + +void main() { + test('flag prepends synthetic row and calls server', () async { + final api = _StubQuarantineApi(); + final container = ProviderContainer(overrides: [ + quarantineApiProvider.overrideWith((ref) async => api), + myQuarantineProvider.overrideWith(() => _StubController(const [])), + ]); + addTearDown(container.dispose); + + await container.read(myQuarantineProvider.future); + await container.read(myQuarantineProvider.notifier).flag(_track, 'bad_rip', ''); + + expect(api.flagCalls, 1); + final list = container.read(myQuarantineProvider).value!; + expect(list, hasLength(1)); + expect(list.first.trackId, 't1'); + expect(list.first.reason, 'bad_rip'); + }); + + test('flag rolls back on server failure', () async { + final api = _StubQuarantineApi(shouldThrow: true); + final container = ProviderContainer(overrides: [ + quarantineApiProvider.overrideWith((ref) async => api), + myQuarantineProvider.overrideWith(() => _StubController(const [])), + ]); + addTearDown(container.dispose); + + await container.read(myQuarantineProvider.future); + await expectLater( + () => container + .read(myQuarantineProvider.notifier) + .flag(_track, 'bad_rip', ''), + throwsException, + ); + expect(container.read(myQuarantineProvider).value, isEmpty); + }); + + test('unflag removes the row and calls server', () async { + final api = _StubQuarantineApi(); + final container = ProviderContainer(overrides: [ + quarantineApiProvider.overrideWith((ref) async => api), + myQuarantineProvider.overrideWith(() => _StubController(const [_existing])), + ]); + addTearDown(container.dispose); + + await container.read(myQuarantineProvider.future); + await container.read(myQuarantineProvider.notifier).unflag('t1'); + + expect(api.unflagCalls, 1); + expect(container.read(myQuarantineProvider).value, isEmpty); + }); + + test('isHidden reflects current state', () async { + final container = ProviderContainer(overrides: [ + myQuarantineProvider.overrideWith(() => _StubController(const [_existing])), + ]); + addTearDown(container.dispose); + + await container.read(myQuarantineProvider.future); + final ctrl = container.read(myQuarantineProvider.notifier); + expect(ctrl.isHidden('t1'), isTrue); + expect(ctrl.isHidden('t2'), isFalse); + }); +}