feat(flutter/quarantine): MyQuarantineController + provider
AsyncNotifier wrapping /api/quarantine/mine with optimistic flag / unflag mutations and an isHidden(trackId) convenience for menu state. Mirrors the LikedIdsController pattern: optimistic update, rollback on server error. Used by the next slice's TrackActionsSheet to power Hide / Unhide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<QuarantineApi>((ref) async {
|
||||
return QuarantineApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
|
||||
@override
|
||||
Future<List<QuarantineMineRow>> 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<void> flag(TrackRef track, String reason, String notes) async {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
final current = state.value ?? const <QuarantineMineRow>[];
|
||||
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<void> unflag(String trackId) async {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
final current = state.value ?? const <QuarantineMineRow>[];
|
||||
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, List<QuarantineMineRow>>(
|
||||
MyQuarantineController.new,
|
||||
);
|
||||
@@ -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<void> flag(String trackId, String reason, {String notes = ''}) async {
|
||||
flagCalls++;
|
||||
if (shouldThrow) throw Exception('simulated server failure');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unflag(String trackId) async {
|
||||
unflagCalls++;
|
||||
if (shouldThrow) throw Exception('simulated server failure');
|
||||
}
|
||||
}
|
||||
|
||||
class _StubController extends MyQuarantineController {
|
||||
_StubController(this._initial);
|
||||
final List<QuarantineMineRow> _initial;
|
||||
@override
|
||||
Future<List<QuarantineMineRow>> 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user