a2f6bdb44f
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>
328 lines
9.9 KiB
Dart
328 lines
9.9 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
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 '../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';
|
|
|
|
class HomeScreen extends ConsumerWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@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(
|
|
backgroundColor: fs.obsidian,
|
|
elevation: 0,
|
|
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
|
actions: const [MainAppBarActions(currentRoute: '/home')],
|
|
),
|
|
body: SafeArea(
|
|
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),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|