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
+24 -8
View File
@@ -398,7 +398,21 @@ 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
// the AlbumsTab pattern so the circular avatar stays
// a true circle on narrow grid cells.
child: LayoutBuilder(builder: (ctx, constraints) {
const cols = 3;
const sidePad = 8.0;
const gap = 8.0;
final cellW = (constraints.maxWidth -
sidePad * 2 -
gap * (cols - 1)) /
cols;
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
// + slack matched to AlbumsTab's overflow guard.
final cellH = (cellW - 16) + 8 + 18 + 8;
return NotificationListener<ScrollNotification>(
onNotification: (n) { onNotification: (n) {
if (n.metrics.pixels >= if (n.metrics.pixels >=
n.metrics.maxScrollExtent - 800) { n.metrics.maxScrollExtent - 800) {
@@ -409,25 +423,27 @@ class _ArtistsTab extends ConsumerWidget {
return false; return false;
}, },
child: GridView.builder( child: GridView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(sidePad),
gridDelegate: gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount( SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: cols,
mainAxisSpacing: 8, mainAxisExtent: cellH,
crossAxisSpacing: 8, mainAxisSpacing: gap,
childAspectRatio: 0.78, crossAxisSpacing: gap,
), ),
itemCount: page.items.length, itemCount: page.items.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final artist = page.items[i]; final artist = page.items[i];
return ArtistCard( return ArtistCard(
artist: artist, artist: artist,
width: cellW,
onTap: () => ctx.push('/artists/${artist.id}', onTap: () => ctx.push('/artists/${artist.id}',
extra: artist), 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',