feat(flutter/home): mirror web home structurally

Rewrites HomeScreen body to match the web home layout:
- Playlists row: For-You + 3 Songs-like + user playlists, with
  PlaylistPlaceholderCard for empty slots driven by
  systemPlaylistsStatusProvider
- Recently added: 50 albums in 2 rows of 25 sharing a scroll controller
- Rediscover: separate scrollers for albums (square) and artists
  (circular) — same condition as web's two-scroller branch
- Most played: 75 tracks in 3 rows of 25 using new CompactTrackCard
- Last played: existing single row layout preserved

Empty states use design-system understated-mythic copy mirrored
verbatim from web. Loading state uses DelayedLoading so brief network
blips don't flash a spinner.

Closes the visible parity gap the operator hit when first opening the
Flutter app on a real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:40:12 -04:00
parent 8fb82d473d
commit a2f6bdb44f
+290 -58
View File
@@ -6,16 +6,21 @@ import 'package:go_router/go_router.dart';
import '../api/errors.dart'; import '../api/errors.dart';
import '../models/album.dart'; import '../models/album.dart';
import '../models/artist.dart'; import '../models/artist.dart';
import '../models/playlist.dart';
import '../models/system_playlists_status.dart';
import '../models/track.dart'; import '../models/track.dart';
import '../player/player_provider.dart'; import '../playlists/playlists_provider.dart';
import '../playlists/widgets/playlist_card.dart';
import '../playlists/widgets/playlist_placeholder_card.dart';
import '../shared/delayed_loading.dart';
import '../shared/widgets/connection_error_banner.dart'; import '../shared/widgets/connection_error_banner.dart';
import '../shared/widgets/main_app_bar_actions.dart'; import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart'; import '../theme/theme_extension.dart';
import 'library_providers.dart'; import 'library_providers.dart';
import 'widgets/album_card.dart'; import 'widgets/album_card.dart';
import 'widgets/artist_card.dart'; import 'widgets/artist_card.dart';
import 'widgets/compact_track_card.dart';
import 'widgets/horizontal_scroll_row.dart'; import 'widgets/horizontal_scroll_row.dart';
import 'widgets/track_row.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -23,6 +28,9 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!; final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final home = ref.watch(homeProvider);
final allPlaylists = ref.watch(playlistsListProvider('all'));
final status = ref.watch(systemPlaylistsStatusProvider);
return Scaffold( return Scaffold(
backgroundColor: fs.obsidian, backgroundColor: fs.obsidian,
appBar: AppBar( appBar: AppBar(
@@ -32,64 +40,288 @@ class HomeScreen extends ConsumerWidget {
actions: const [MainAppBarActions(currentRoute: '/home')], actions: const [MainAppBarActions(currentRoute: '/home')],
), ),
body: SafeArea( body: SafeArea(
child: ref.watch(homeProvider).when( child: home.when(
error: (e, _) { error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') { if (code == 'connection_refused') {
return ConnectionErrorBanner( return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider), onRetry: () => ref.refresh(homeProvider),
); );
} }
return Center(child: Text('$e', style: TextStyle(color: fs.error))); return Center(child: Text('$e', style: TextStyle(color: fs.error)));
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const DelayedLoading(
data: (h) => RefreshIndicator( isLoading: true,
onRefresh: () async => ref.refresh(homeProvider.future), whenReady: SizedBox.shrink(),
child: ListView(children: [ whileDelayed:
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums), Center(child: CircularProgressIndicator()),
_albumsRow(context, 'Rediscover', h.rediscoverAlbums), ),
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists), data: (h) => RefreshIndicator(
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks), onRefresh: () async => ref.refresh(homeProvider.future),
_artistsRow(context, 'Last played artists', h.lastPlayedArtists), child: ListView(children: [
const SizedBox(height: 96), _PlaylistsSection(
]), playlists: allPlaylists.value?.owned ?? const [],
status: status.value ?? SystemPlaylistsStatus.empty(),
), ),
), _RecentlyAddedSection(albums: h.recentlyAddedAlbums),
_RediscoverSection(
albums: h.rediscoverAlbums,
artists: h.rediscoverArtists,
),
_MostPlayedSection(tracks: h.mostPlayedTracks),
_LastPlayedSection(artists: h.lastPlayedArtists),
const SizedBox(height: 96),
]),
),
),
), ),
); );
} }
}
Widget _albumsRow(BuildContext ctx, String title, List<AlbumRef> albums) =>
HorizontalScrollRow( class _PlaylistsSection extends StatelessWidget {
title: title, const _PlaylistsSection({required this.playlists, required this.status});
children: [ final List<Playlist> playlists;
for (final a in albums) final SystemPlaylistsStatus status;
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
], @override
); Widget build(BuildContext context) {
final items = _buildPlaylistsRow(playlists, status);
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) => return HorizontalScrollRow(
HorizontalScrollRow( title: 'Playlists',
title: title, height: 220,
children: [ children: items.map((item) {
for (final a in artists) if (item is _RealPlaylist) {
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')), return PlaylistCard(playlist: item.playlist);
], }
); final ph = item as _PlaceholderPlaylist;
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) => }).toList(),
HorizontalScrollRow( );
title: title, }
height: 64, }
children: [
for (final t in tracks) abstract class _PlaylistRowItem {}
SizedBox( class _RealPlaylist extends _PlaylistRowItem {
width: 280, _RealPlaylist(this.playlist);
child: TrackRow( final Playlist playlist;
track: t, }
onTap: () => ref.read(playerActionsProvider).playTracks([t]), class _PlaceholderPlaylist extends _PlaylistRowItem {
), _PlaceholderPlaylist(this.label, this.variant);
), final String label;
], final String variant;
); }
List<_PlaylistRowItem> _buildPlaylistsRow(
List<Playlist> ownedAll,
SystemPlaylistsStatus status,
) {
final out = <_PlaylistRowItem>[];
Playlist? findFirst(bool Function(Playlist) test) {
for (final p in ownedAll) {
if (test(p)) return p;
}
return null;
}
// Slot 1: For-You.
final forYou = findFirst((p) => p.systemVariant == 'for_you');
out.add(forYou != null
? _RealPlaylist(forYou)
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
// Slots 2-4: Songs-like (real first, padded to 3).
final songsLike = ownedAll
.where((p) => p.systemVariant == 'songs_like_artist')
.take(3)
.toList();
for (var i = 0; i < 3; i++) {
out.add(i < songsLike.length
? _RealPlaylist(songsLike[i])
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
}
// User-created trail (server returns most-recently-updated first).
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
out.add(_RealPlaylist(p));
}
return out;
}
String _variantFor(String slot, SystemPlaylistsStatus s) {
if (s.inFlight) return 'building';
if (s.lastError != null) return 'failed';
if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed';
return 'pending';
}
class _RecentlyAddedSection extends StatelessWidget {
const _RecentlyAddedSection({required this.albums});
final List<AlbumRef> albums;
@override
Widget build(BuildContext context) {
if (albums.isEmpty) {
return const _EmptySection(
title: 'Recently added',
message: "Nothing added yet. Scan a folder via the server's config.",
);
}
final controller = ScrollController();
final rows = _chunk(albums, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
title: i == 0 ? 'Recently added' : '',
controller: controller,
children: rows[i]
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
))
.toList(),
),
]);
}
}
class _RediscoverSection extends StatelessWidget {
const _RediscoverSection({required this.albums, required this.artists});
final List<AlbumRef> albums;
final List<ArtistRef> artists;
@override
Widget build(BuildContext context) {
if (albums.isEmpty && artists.isEmpty) {
return const _EmptySection(
title: 'Rediscover',
message:
'No forgotten favourites yet. Like some albums or artists to fill this in.',
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (albums.isNotEmpty)
HorizontalScrollRow(
title: 'Rediscover',
children: albums
.map((a) => AlbumCard(
album: a,
onTap: () =>
_push(context, '/albums/${a.id}'),
))
.toList(),
),
if (artists.isNotEmpty)
HorizontalScrollRow(
title: albums.isEmpty ? 'Rediscover' : '',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () =>
_push(context, '/artists/${ar.id}'),
))
.toList(),
),
]);
}
}
class _MostPlayedSection extends StatelessWidget {
const _MostPlayedSection({required this.tracks});
final List<TrackRef> tracks;
@override
Widget build(BuildContext context) {
if (tracks.isEmpty) {
return const _EmptySection(
title: 'Most played',
message: 'No plays to draw from. Listen to something.',
);
}
final controller = ScrollController();
final rows = _chunk(tracks, 25);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
for (var i = 0; i < rows.length; i++)
HorizontalScrollRow(
title: i == 0 ? 'Most played' : '',
height: 64,
controller: controller,
children: [
for (var j = 0; j < rows[i].length; j++)
CompactTrackCard(
track: rows[i][j],
sectionTracks: tracks,
index: i * 25 + j,
),
],
),
]);
}
}
class _LastPlayedSection extends StatelessWidget {
const _LastPlayedSection({required this.artists});
final List<ArtistRef> artists;
@override
Widget build(BuildContext context) {
if (artists.isEmpty) {
return const _EmptySection(
title: 'Last played',
message: 'No recent plays.',
);
}
return HorizontalScrollRow(
title: 'Last played',
height: 168,
children: artists
.map((ar) => ArtistCard(
artist: ar,
onTap: () => _push(context, '/artists/${ar.id}'),
))
.toList(),
);
}
}
class _EmptySection extends StatelessWidget {
const _EmptySection({required this.title, required this.message});
final String title;
final String message;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
title,
style: TextStyle(
fontFamily: fs.display.fontFamily,
fontSize: 18,
color: fs.parchment,
),
),
const SizedBox(height: 8),
Text(message, style: TextStyle(color: fs.ash)),
]),
);
}
}
void _push(BuildContext context, String path) {
context.push(path);
}
List<List<T>> _chunk<T>(List<T> items, int size) {
final out = <List<T>>[];
for (var i = 0; i < items.length; i += size) {
out.add(items.sublist(i, i + size > items.length ? items.length : i + size));
}
return out;
} }