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 '../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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user