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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -26,8 +20,7 @@ import com.composables.icons.lucide.Lucide
import androidx.compose.material3.Icon
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import com.fabledsword.minstrel.shared.widgets.CoverTile
/**
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
@@ -53,31 +46,20 @@ fun AlbumCard(
color = Color.Transparent,
) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Box(
modifier = Modifier
.size(144.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (album.id.isEmpty()) {
CoverTile(
url = album.displayCoverUrl,
contentDescription = album.title,
size = 144.dp,
background = MaterialTheme.colorScheme.surfaceVariant,
fallback = {
Icon(
imageVector = Lucide.Disc3,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
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))
Text(
text = album.title,
@@ -1,10 +1,8 @@
package com.fabledsword.minstrel.library.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -15,9 +13,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.text.style.TextAlign
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.fabledsword.minstrel.models.ArtistRef
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
@@ -54,25 +50,20 @@ fun ArtistCard(
modifier = Modifier.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(128.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = artist.displayCoverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
) {
CoverTile(
url = artist.displayCoverUrl,
contentDescription = artist.name,
size = 128.dp,
shape = CircleShape,
fallback = {
Icon(
imageVector = Lucide.User,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp),
)
}
}
},
)
Spacer(Modifier.height(8.dp))
Text(
text = artist.name,
@@ -1,10 +1,8 @@
package com.fabledsword.minstrel.playlists.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -17,7 +15,6 @@ import androidx.compose.material3.Text
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -25,7 +22,7 @@ import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.PlaylistRef
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
/**
@@ -54,33 +51,29 @@ fun PlaylistCard(
color = Color.Transparent,
) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Box(
modifier = Modifier
.size(144.dp)
.clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = playlist.coverUrl,
contentDescription = playlist.name,
modifier = Modifier.fillMaxSize(),
) {
CoverTile(
url = playlist.coverUrl,
contentDescription = playlist.name,
size = 144.dp,
fallback = {
Icon(
imageVector = Lucide.ListMusic,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp),
)
}
if (playlist.isSystem) {
VariantPill(
label = systemLabelFor(playlist.systemVariant),
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp),
)
}
}
},
overlay = {
if (playlist.isSystem) {
VariantPill(
label = systemLabelFor(playlist.systemVariant),
modifier = Modifier
.align(Alignment.TopStart)
.padding(6.dp),
)
}
},
)
Spacer(Modifier.height(8.dp))
Text(
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()
}
}