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 '../models/album.dart';
import '../models/artist.dart';
import '../models/playlist.dart';
import '../models/system_playlists_status.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/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
import 'widgets/artist_card.dart';
import 'widgets/compact_track_card.dart';
import 'widgets/horizontal_scroll_row.dart';
import 'widgets/track_row.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@@ -23,6 +28,9 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
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(
backgroundColor: fs.obsidian,
appBar: AppBar(
@@ -32,64 +40,288 @@ class HomeScreen extends ConsumerWidget {
actions: const [MainAppBarActions(currentRoute: '/home')],
),
body: SafeArea(
child: ref.watch(homeProvider).when(
error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') {
return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider),
);
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const Center(child: CircularProgressIndicator()),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(children: [
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums),
_albumsRow(context, 'Rediscover', h.rediscoverAlbums),
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists),
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks),
_artistsRow(context, 'Last played artists', h.lastPlayedArtists),
const SizedBox(height: 96),
]),
child: home.when(
error: (e, _) {
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
if (code == 'connection_refused') {
return ConnectionErrorBanner(
onRetry: () => ref.refresh(homeProvider),
);
}
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
},
loading: () => const DelayedLoading(
isLoading: true,
whenReady: SizedBox.shrink(),
whileDelayed:
Center(child: CircularProgressIndicator()),
),
data: (h) => RefreshIndicator(
onRefresh: () async => ref.refresh(homeProvider.future),
child: ListView(children: [
_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(
title: title,
children: [
for (final a in albums)
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
],
);
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) =>
HorizontalScrollRow(
title: title,
children: [
for (final a in artists)
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')),
],
);
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) =>
HorizontalScrollRow(
title: title,
height: 64,
children: [
for (final t in tracks)
SizedBox(
width: 280,
child: TrackRow(
track: t,
onTap: () => ref.read(playerActionsProvider).playTracks([t]),
),
),
],
);
}
class _PlaylistsSection extends StatelessWidget {
const _PlaylistsSection({required this.playlists, required this.status});
final List<Playlist> playlists;
final SystemPlaylistsStatus status;
@override
Widget build(BuildContext context) {
final items = _buildPlaylistsRow(playlists, status);
return HorizontalScrollRow(
title: 'Playlists',
height: 220,
children: items.map((item) {
if (item is _RealPlaylist) {
return PlaylistCard(playlist: item.playlist);
}
final ph = item as _PlaceholderPlaylist;
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
}).toList(),
);
}
}
abstract class _PlaylistRowItem {}
class _RealPlaylist extends _PlaylistRowItem {
_RealPlaylist(this.playlist);
final Playlist playlist;
}
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;
}