fix(flutter): keep artist avatars round in the Library grid

ArtistCard hardcoded its avatar at Container(width: 124, height:
124) inside a ClipOval. In the Library Artists 3-column grid the
cell is narrower than the card's nominal 140dp width — on a typical
phone the cell is ~109dp, the padded inner content area ~93dp. The
parent constrained the Container's width to ~93dp but the explicit
height stayed 124dp, so ClipOval clipped a 93×124 rectangle and
the avatar rendered as a vertical ellipse.

Fix mirrors AlbumCard's pattern: ArtistCard takes an optional
`width` parameter (default 140 for horizontal carousels) and
derives coverSize = width - 16, so the Container is always square.
ArtistsTab now uses LayoutBuilder to compute cell width and passes
it through, same as AlbumsTab. Avatar stays a true circle at any
cell width.

mainAxisExtent on the grid replaces the previous fixed
childAspectRatio so cell height tracks cellW + name line, with
slack matching AlbumsTab's overflow guard.
This commit is contained in:
2026-05-14 08:39:37 -04:00
parent 6efb3159d5
commit bf0ef5e0c3
2 changed files with 69 additions and 36 deletions
+45 -29
View File
@@ -398,36 +398,52 @@ class _ArtistsTab extends ConsumerWidget {
// Listen for scroll-near-bottom and ask the notifier // Listen for scroll-near-bottom and ask the notifier
// to fetch the next page. 800px lookahead so the next // to fetch the next page. 800px lookahead so the next
// page lands before the user reaches the visible end. // page lands before the user reaches the visible end.
child: NotificationListener<ScrollNotification>( // LayoutBuilder + cell-aware ArtistCard width mirrors
onNotification: (n) { // the AlbumsTab pattern so the circular avatar stays
if (n.metrics.pixels >= // a true circle on narrow grid cells.
n.metrics.maxScrollExtent - 800) { child: LayoutBuilder(builder: (ctx, constraints) {
ref const cols = 3;
.read(_libraryArtistsProvider.notifier) const sidePad = 8.0;
.loadMore(); const gap = 8.0;
} final cellW = (constraints.maxWidth -
return false; sidePad * 2 -
}, gap * (cols - 1)) /
child: GridView.builder( cols;
padding: const EdgeInsets.all(8), // avatar (cellW - 16) + gap (8) + 1 line name (~18)
gridDelegate: // + slack matched to AlbumsTab's overflow guard.
const SliverGridDelegateWithFixedCrossAxisCount( final cellH = (cellW - 16) + 8 + 18 + 8;
crossAxisCount: 3, return NotificationListener<ScrollNotification>(
mainAxisSpacing: 8, onNotification: (n) {
crossAxisSpacing: 8, if (n.metrics.pixels >=
childAspectRatio: 0.78, n.metrics.maxScrollExtent - 800) {
), ref
itemCount: page.items.length, .read(_libraryArtistsProvider.notifier)
itemBuilder: (ctx, i) { .loadMore();
final artist = page.items[i]; }
return ArtistCard( return false;
artist: artist,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
}, },
), child: GridView.builder(
), padding: const EdgeInsets.all(sidePad),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final artist = page.items[i];
return ArtistCard(
artist: artist,
width: cellW,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
},
),
);
}),
); );
}, },
); );
@@ -12,15 +12,27 @@ import '../library_providers.dart';
import 'play_circle_button.dart'; import 'play_circle_button.dart';
class ArtistCard extends ConsumerWidget { class ArtistCard extends ConsumerWidget {
const ArtistCard({required this.artist, required this.onTap, super.key}); const ArtistCard({
required this.artist,
required this.onTap,
this.width = 140,
super.key,
});
final ArtistRef artist; final ArtistRef artist;
final VoidCallback onTap; final VoidCallback onTap;
/// Outer width of the card. Avatar is a circle of width-16 (8dp
/// horizontal padding either side). Default suits horizontal lists;
/// grids should pass the cell width so the avatar shrinks
/// proportionally and stays a true circle.
final double width;
@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 coverSize = width - 16;
return SizedBox( return SizedBox(
width: 140, width: width,
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
@@ -29,15 +41,20 @@ class ArtistCard extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// Stack: circular avatar + overlaid play button at bottom-right. // Stack: circular avatar + overlaid play button at bottom-right.
// The avatar is a circle (ClipOval) — bottom-right of its // Avatar size derives from the [width] parameter so the
// bounding box still places the button inside the visible area // Library Artists 3-column grid can pass its cell width
// because the button is small relative to the radius. // (~109dp on typical phones) and the avatar stays a
// perfect circle. Previous hardcoded 124×124 got squeezed
// horizontally in the grid (cell narrower than 124+16
// padding) but kept its 124dp height — ClipOval produced
// a visible vertical ellipse. Mirrors AlbumCard's width-
// parameter convention.
Stack( Stack(
children: [ children: [
ClipOval( ClipOval(
child: Container( child: Container(
width: 124, width: coverSize,
height: 124, height: coverSize,
color: fs.slate, color: fs.slate,
child: artist.coverUrl.isEmpty child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', ? SvgPicture.asset('assets/svg/album-fallback.svg',