feat(offline): #427 S4a — Shuffle all + offline system-playlist gate

Headline of S4. "Shuffle all" is always present (home app-bar
shuffle icon); the pool degrades with reachability:
- online  → GET /api/library/shuffle?limit=N (new): N random
  library tracks server-side, per-user quarantine filtered
  (ListRandomTracksForUser, ORDER BY random()).
- offline → ShuffleSource shuffles the whole local cache index
  (audio_cache_index ∩ cached_tracks, names from cached_artists/
  albums) — a UNION over liked AND recently-played, since the
  two-bucket split is storage-only and never filters playback.

Offline gating: refreshable (singleton) system playlists need the
live build/shuffle endpoints, so their tile play is disabled when
offlineProvider is true — Shuffle all is the offline path. User
playlists still play from cache.

playlist_card now reads offlineProvider in build → wrapped the
direct-render widget test in ProviderScope (offlineProvider is
smoke-safe from S1: tracked timers, no connectivity mount).

S4b (offline Recently-played / Liked browsable surfaces over the
cache index) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 21:37:29 -04:00
parent a4f293b7cf
commit a5e2abb8c4
9 changed files with 248 additions and 10 deletions
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/models/playlist.dart';
@@ -35,44 +36,48 @@ const _forYou = Playlist(
void main() {
testWidgets('renders name and no badge for user playlist', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _userPlaylist),
),
));
)));
expect(find.text('Road trip'), findsOneWidget);
expect(find.text('for you'), findsNothing);
});
testWidgets('renders system badge for system playlist', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _forYou),
),
));
)));
expect(find.text('For You'), findsOneWidget);
expect(find.text('for you'), findsOneWidget);
});
testWidgets('shows refresh kebab on system playlists', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _forYou),
),
));
)));
expect(find.byIcon(Icons.more_vert), findsOneWidget);
});
testWidgets('no refresh kebab on user playlists', (tester) async {
await tester.pumpWidget(MaterialApp(
await tester.pumpWidget(ProviderScope(
child: MaterialApp(
theme: buildThemeData(),
home: const Scaffold(
body: PlaylistCard(playlist: _userPlaylist),
),
));
)));
expect(find.byIcon(Icons.more_vert), findsNothing);
});
}