refactor(android): extract shared CoverTile from Album/Artist/Playlist cards

The three cards shared an identical Box + clip + background + ServerImage + fallback structure around their cover (only size, shape, fallback icon, and overlay differ). Extract a single CoverTile composable; each card now passes its own size, shape, background, fallback icon, and optional BoxScope overlay (used by PlaylistCard for the VariantPill). Pure DRY consolidation; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 02:31:32 -04:00
parent 37887107ee
commit ee6305f525
4 changed files with 92 additions and 70 deletions
@@ -1,23 +1,17 @@
package com.fabledsword.minstrel.library.widgets package com.fabledsword.minstrel.library.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -26,8 +20,7 @@ import com.composables.icons.lucide.Lucide
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.CoverTile
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/** /**
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp * One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
@@ -53,31 +46,20 @@ fun AlbumCard(
color = Color.Transparent, color = Color.Transparent,
) { ) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Box( CoverTile(
modifier = Modifier url = album.displayCoverUrl,
.size(144.dp) contentDescription = album.title,
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)) size = 144.dp,
.background(MaterialTheme.colorScheme.surfaceVariant), background = MaterialTheme.colorScheme.surfaceVariant,
contentAlignment = Alignment.Center, fallback = {
) {
if (album.id.isEmpty()) {
Icon( Icon(
imageVector = Lucide.Disc3, imageVector = Lucide.Disc3,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} else { },
// displayCoverUrl yields /api/albums/{id}/cover — )
// relative on fresh fetches, placeholder-absolute from
// cache. ServerImage resolves both to a loadable URL.
ServerImage(
url = album.displayCoverUrl,
contentDescription = album.title,
modifier = Modifier.fillMaxSize(),
)
}
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = album.title, text = album.title,
@@ -1,10 +1,8 @@
package com.fabledsword.minstrel.library.widgets package com.fabledsword.minstrel.library.widgets
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -15,9 +13,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -26,7 +22,7 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.CoverTile
/** /**
* One artist tile. Circular cover; name centered beneath. Tap fires * One artist tile. Circular cover; name centered beneath. Tap fires
@@ -54,25 +50,20 @@ fun ArtistCard(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( CoverTile(
modifier = Modifier url = artist.displayCoverUrl,
.size(128.dp) contentDescription = artist.name,
.clip(CircleShape), size = 128.dp,
contentAlignment = Alignment.Center, shape = CircleShape,
) { fallback = {
ServerImage(
url = artist.displayCoverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Lucide.User, imageVector = Lucide.User,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} },
} )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = artist.name, text = artist.name,
@@ -1,10 +1,8 @@
package com.fabledsword.minstrel.playlists.widgets package com.fabledsword.minstrel.playlists.widgets
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -17,7 +15,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -25,7 +22,7 @@ import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.CoverTile
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/** /**
@@ -54,33 +51,29 @@ fun PlaylistCard(
color = Color.Transparent, color = Color.Transparent,
) { ) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Box( CoverTile(
modifier = Modifier url = playlist.coverUrl,
.size(144.dp) contentDescription = playlist.name,
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)), size = 144.dp,
contentAlignment = Alignment.Center, fallback = {
) {
ServerImage(
url = playlist.coverUrl,
contentDescription = playlist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Lucide.ListMusic, imageVector = Lucide.ListMusic,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} },
if (playlist.isSystem) { overlay = {
VariantPill( if (playlist.isSystem) {
label = systemLabelFor(playlist.systemVariant), VariantPill(
modifier = Modifier label = systemLabelFor(playlist.systemVariant),
.align(Alignment.TopStart) modifier = Modifier
.padding(6.dp), .align(Alignment.TopStart)
) .padding(6.dp),
} )
} }
},
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = playlist.name, text = playlist.name,
@@ -0,0 +1,56 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
/**
* Shared cover artwork tile: clipped, optionally backgrounded `Box`
* with a [ServerImage] inside and a per-caller [fallback] icon. Used
* by `AlbumCard`, `ArtistCard`, `PlaylistCard` — they vary on size /
* shape / fallback icon / background, but the surrounding Box +
* ServerImage + fallback structure was identical at all three sites.
*
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
* (e.g. the `VariantPill` system-playlist label) — callers can use
* `Modifier.align(...)` inside it.
*/
@Composable
fun CoverTile(
url: String?,
contentDescription: String?,
size: Dp,
fallback: @Composable () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(FabledSwordFlatTokens.radiusSm),
background: Color = Color.Transparent,
overlay: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(background),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
fallback()
}
overlay()
}
}