fix(flutter): unhide button + cover + timestamp on Library Hidden tab

Three small parity gaps in _HiddenTab vs the web /library/hidden page:

- No unhide affordance — once a track was flagged via the kebab, the only
  way to reverse it was to find the track in another surface and toggle
  via the kebab again. Added an Icons.restore IconButton on each tile that
  calls myQuarantineProvider.notifier.unflag(trackId) (the optimistic
  remove-with-rollback already lived on the notifier).
- No album cover art — added a 56px ServerImage thumb matching the web
  page's 14×14 thumb, with the same fs.slate fallback compact_track_card
  uses for missing covers.
- No relative timestamp — appended _relativeTime(row.createdAt) next to
  the reason pill so the user can tell "I hid this 3d ago" at a glance.

Also collapsed the duplicate provider: _HiddenTab was watching a local
FutureProvider that didn't see flag/unflag mutations, while the kebab's
HideTrackSheet flow goes through the canonical myQuarantineProvider
(AsyncNotifier). Switched _HiddenTab to watch myQuarantineProvider so
flag-from-anywhere and unhide-from-the-tab stay in sync. The local
_quarantineProvider was deleted; one source of truth now.

Caught during the #375 DRY audit cross-check against the #356 inventory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:32:04 -04:00
parent 9acad5461e
commit b466b6494a
+56 -14
View File
@@ -16,7 +16,9 @@ import '../models/page.dart' as wire;
import '../models/quarantine_mine.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
import '../quarantine/quarantine_provider.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
@@ -138,9 +140,9 @@ final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async
return (await ref.watch(_likesApiProvider.future)).listArtists();
});
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
return (await ref.watch(_meApiProvider.future)).quarantineMine();
});
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
// optimistic flag/unflag) so flagging from any kebab and unflagging from
// the Hidden tab keep one source of truth.
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@@ -459,7 +461,7 @@ class _HiddenTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ref.watch(_quarantineProvider).when(
return ref.watch(myQuarantineProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
data: (rows) => rows.isEmpty
@@ -471,7 +473,7 @@ class _HiddenTab extends ConsumerWidget {
),
)
: RefreshIndicator(
onRefresh: () async => ref.refresh(_quarantineProvider.future),
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
child: ListView.separated(
itemCount: rows.length,
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
@@ -482,12 +484,12 @@ class _HiddenTab extends ConsumerWidget {
}
}
class _QuarantineTile extends StatelessWidget {
class _QuarantineTile extends ConsumerWidget {
const _QuarantineTile({required this.row});
final QuarantineMineRow row;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final reasonLabel = switch (row.reason) {
'bad_rip' => 'Bad rip',
@@ -496,9 +498,25 @@ class _QuarantineTile extends StatelessWidget {
'duplicate' => 'Duplicate',
_ => 'Other',
};
final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 56,
height: 56,
child: coverUrl.isEmpty
? Container(color: fs.slate)
: ServerImage(
url: coverUrl,
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
@@ -515,17 +533,41 @@ class _QuarantineTile extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
),
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
),
if (row.createdAt.isNotEmpty) ...[
const SizedBox(width: 8),
Text(_relativeTime(row.createdAt),
style: TextStyle(color: fs.ash, fontSize: 11)),
],
]),
),
]),
),
IconButton(
tooltip: 'Unhide',
icon: Icon(Icons.restore, color: fs.ash, size: 20),
onPressed: () async {
try {
await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
} catch (_) {
// Optimistic rollback handled by the notifier; surface
// the failure only if the user kept the tab open.
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not unhide; try again.')),
);
}
}
},
),
]),
);
}