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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user