fix(flutter): album/artist covers in artist detail + tighter grid
Drift cache intentionally drops cover_url (server-derived). The adapters comment says "REST cold-cache fallback briefly shows the real values before drift takes over" — but once drift takes over, covers are empty forever. Fix per source: - Album cover: server constructs the URL deterministically as /api/albums/<id>/cover (internal/api/convert.go:69). Mirror that in CachedAlbumAdapter.toRef so AlbumRef.coverUrl is non-empty whether the row came from a fresh fetch or a drift hit. Restores cover art on the artist detail album grid (and any other surface reading albums from drift). - Artist cover: server picks a representative album and reuses its cover (convert.go:98). Drift doesn't store the pointer, so derive client-side via the artist's first loaded album. New _ArtistAvatar prefers a non-empty server-emitted coverUrl and falls back to /api/albums/<firstAlbumId>/cover, then slate while the album list is still loading. Album grid spacing was off because childAspectRatio: 0.8 inflated each cell taller than the AlbumCard's actual ~160dp footprint, leaving a visible gap below every card. Switch to mainAxisExtent: 168 with explicit 8dp main/cross spacing — cells now match the card and sit on a clean grid.
This commit is contained in:
+6
@@ -34,12 +34,18 @@ extension ArtistRefDriftWrite on ArtistRef {
|
|||||||
|
|
||||||
extension CachedAlbumAdapter on CachedAlbum {
|
extension CachedAlbumAdapter on CachedAlbum {
|
||||||
/// `artistName` is supplied by the joined CachedArtists row at query time.
|
/// `artistName` is supplied by the joined CachedArtists row at query time.
|
||||||
|
/// `coverUrl` is reconstructed deterministically from the album id —
|
||||||
|
/// the server emits the same shape (see internal/api/convert.go:69).
|
||||||
|
/// We don't need to persist it, so AlbumRef.coverUrl is non-empty
|
||||||
|
/// even when the row was populated from a sync that didn't carry the
|
||||||
|
/// derived URL.
|
||||||
AlbumRef toRef({String artistName = ''}) => AlbumRef(
|
AlbumRef toRef({String artistName = ''}) => AlbumRef(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
sortTitle: sortTitle,
|
sortTitle: sortTitle,
|
||||||
artistId: artistId,
|
artistId: artistId,
|
||||||
artistName: artistName,
|
artistName: artistName,
|
||||||
|
coverUrl: '/api/albums/$id/cover',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 96,
|
width: 96,
|
||||||
height: 96,
|
height: 96,
|
||||||
child: a.coverUrl.isEmpty
|
// Server derives artist cover from a representative
|
||||||
? Container(color: fs.slate)
|
// album. Drift cache doesn't persist that pointer, so
|
||||||
: ServerImage(
|
// mirror the trick client-side: reuse the first album
|
||||||
url: a.coverUrl,
|
// returned by artistAlbumsProvider. Falls back to
|
||||||
fit: BoxFit.cover,
|
// slate while albums are loading or empty.
|
||||||
fallback: Container(color: fs.slate),
|
child: _ArtistAvatar(
|
||||||
),
|
serverCoverUrl: a.coverUrl,
|
||||||
|
albums: albums,
|
||||||
|
fs: fs,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -82,9 +85,16 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
|
// mainAxisExtent locks each cell's height to the actual
|
||||||
|
// AlbumCard footprint (124 cover + 8 + 14 title + 12
|
||||||
|
// artist + ~6 padding), instead of letting
|
||||||
|
// childAspectRatio inflate it. Removes the visible vertical
|
||||||
|
// gap below each card.
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
childAspectRatio: 0.8,
|
mainAxisExtent: 168,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemCount: list.length,
|
itemCount: list.length,
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
@@ -98,3 +108,37 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||||
|
/// non-empty; otherwise we mirror the server's "use the first album's
|
||||||
|
/// cover" rule client-side via the loaded album list.
|
||||||
|
class _ArtistAvatar extends StatelessWidget {
|
||||||
|
const _ArtistAvatar({
|
||||||
|
required this.serverCoverUrl,
|
||||||
|
required this.albums,
|
||||||
|
required this.fs,
|
||||||
|
});
|
||||||
|
final String serverCoverUrl;
|
||||||
|
final AsyncValue<List<AlbumRef>> albums;
|
||||||
|
final FabledSwordTheme fs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (serverCoverUrl.isNotEmpty) {
|
||||||
|
return ServerImage(
|
||||||
|
url: serverCoverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
|
||||||
|
if (firstId == null) {
|
||||||
|
return Container(color: fs.slate);
|
||||||
|
}
|
||||||
|
return ServerImage(
|
||||||
|
url: '/api/albums/$firstId/cover',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user