fix(flutter): card taps via Material+InkWell, not GestureDetector

User reports only the first card in 'Recently added' navigates and the
HitTestBehavior.opaque fix in d703fc2 didn't fully resolve it.

Switch all three card widgets (album, artist, playlist) from
GestureDetector to Material(transparent) + InkWell. This is the more
idiomatic Flutter pattern for tap-to-navigate cards:

- InkWell registers via the Material ripple system instead of the
  raw gesture arena, sidestepping any subtle deferToChild edge cases
  inside horizontal ListViews.
- Visible ripple feedback on tap means a missed tap is now diagnosable
  visually — if the ripple shows but nothing navigates, the issue is
  on the route side; if no ripple, the tap never landed.

Material color is transparent so the existing dark page background
shows through unchanged.
This commit is contained in:
2026-05-11 07:57:10 -04:00
parent a05a279508
commit 3162fedd3b
3 changed files with 78 additions and 70 deletions
@@ -13,14 +13,15 @@ class AlbumCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
return SizedBox(
width: 140,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
@@ -46,6 +47,7 @@ class AlbumCard extends StatelessWidget {
style: TextStyle(color: fs.ash, fontSize: 12),
),
]),
),
),
),
);
@@ -13,32 +13,35 @@ class ArtistCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 140,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipOval(
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
return SizedBox(
width: 140,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipOval(
child: Container(
width: 124,
height: 124,
color: fs.slate,
child: artist.coverUrl.isEmpty
? SvgPicture.asset('assets/svg/album-fallback.svg',
fit: BoxFit.cover)
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
),
),
),
const SizedBox(height: 8),
Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
]),
const SizedBox(height: 8),
Text(
artist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
]),
),
),
),
);
@@ -18,46 +18,49 @@ class PlaylistCard extends StatelessWidget {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return SizedBox(
width: 176,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context.push('/playlists/${playlist.id}'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 144,
height: 144,
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash, size: 56)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
),
),
const SizedBox(height: 8),
Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (playlist.isSystem)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => context.push('/playlists/${playlist.id}'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(
playlist.systemVariant!.replaceAll('_', ' '),
style: TextStyle(color: fs.accent, fontSize: 11),
),
width: 144,
height: 144,
color: fs.slate,
child: playlist.coverUrl.isEmpty
? Icon(Icons.queue_music, color: fs.ash, size: 56)
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
),
),
]),
const SizedBox(height: 8),
Text(
playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14),
),
if (playlist.isSystem)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: fs.iron,
borderRadius: BorderRadius.circular(4),
),
child: Text(
playlist.systemVariant!.replaceAll('_', ' '),
style: TextStyle(color: fs.accent, fontSize: 11),
),
),
),
]),
),
),
),
);