ui: add the artist bio layout states

Default the second row to a greyed "No artist bio available" placeholder.
Keep the collapsed two-line layout with a bold More and the expanded flow
that wraps around the action buttons for the next PR, which will supply the
server biography.
This commit is contained in:
austinried
2026-10-19 08:26:43 +00:00
parent 5758609c25
commit 63d140e67e
@@ -73,11 +73,14 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -116,6 +119,7 @@ import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import kotlin.math.ceil
private val ART_HEIGHT = 420.dp private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp private val TITLE_INSET = 16.dp
@@ -126,6 +130,10 @@ private val SHUFFLE_ICON = 30.dp
private val SHUFFLE_END = 22.dp private val SHUFFLE_END = 22.dp
private val SHUFFLE_RESERVE = 86.dp private val SHUFFLE_RESERVE = 86.dp
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2 private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
private val BIO_ACTION_RESERVE = 104.dp
private val BIO_ACTION_HEIGHT = 48.dp
private val BIO_TOP_GAP = 8.dp
private const val ARTIST_BIO_EMPTY = "No artist bio available"
internal fun estimateScrollPx( internal fun estimateScrollPx(
index: Int, index: Int,
@@ -259,6 +267,11 @@ fun ArtistDetailScreen(
) )
val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount) val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else "" val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else ""
// No server biography is mirrored yet, so the page ships with the empty state. The collapsed
// and expanded paths below are ready for it: collapsed keeps two lines and ends with a bold
// More, expanded shows the full flow and leaves extra room before the first section.
val bioCollapsed = true
val bioText = ARTIST_BIO_EMPTY
var titleHeightPx by remember { mutableFloatStateOf(0f) } var titleHeightPx by remember { mutableFloatStateOf(0f) }
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
val titleMaxLines = val titleMaxLines =
@@ -396,13 +409,24 @@ fun ArtistDetailScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE), modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE),
) )
Row( ArtistBio(
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp), text = bioText,
horizontalArrangement = Arrangement.End, color =
verticalAlignment = Alignment.CenterVertically, if (bioText == ARTIST_BIO_EMPTY) {
) { MaterialTheme.colorScheme.outline
ArtistActions(artist, starred, onToggleStar, onMore) } else {
} MaterialTheme.colorScheme.onSurfaceVariant
},
collapsed = bioCollapsed,
artist = artist,
starred = starred,
onToggleStar = onToggleStar,
onMore = onMore,
modifier =
Modifier.padding(
bottom = if (!bioCollapsed && bioText != ARTIST_BIO_EMPTY) 8.dp else 0.dp,
),
)
} }
} }
} }
@@ -484,6 +508,148 @@ fun ArtistDetailScreen(
} }
} }
// The expanded layout narrows only the lines that overlap the action buttons; later lines use the
// full width. Collapsed keeps two lines and appends a bold More, not yet wired to expand.
@Composable
private fun ArtistBio(
text: String,
color: Color,
collapsed: Boolean,
artist: Artist?,
starred: Boolean,
onToggleStar: (() -> Unit)?,
onMore: () -> Unit,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = MaterialTheme.typography.bodyMedium
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
val lineHeightPx =
remember(style) {
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
}.coerceAtLeast(1)
val reserveLines = ceil(with(density) { BIO_ACTION_HEIGHT.toPx() } / lineHeightPx).toInt().coerceAtLeast(1)
val lines =
remember(text, fullWidth, reserve, reserveLines, collapsed) {
if (collapsed) {
splitBioCollapsed(measurer, text, style, fullWidth, reserve)
} else {
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
AnnotatedString(it.ifEmpty { " " })
}
}
}
Box {
Column(Modifier.fillMaxWidth()) {
lines.forEach { line ->
Text(
text = line,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(Modifier.align(Alignment.TopEnd).offset(y = (-8).dp)) {
ArtistActions(artist, starred, onToggleStar, onMore)
}
}
}
}
/** Splits text into lines, narrowing the lines that would overlap the actions. */
internal fun splitBio(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
): List<String> {
if (text.isBlank()) return listOf("")
val lines = mutableListOf<String>()
text.split("\n\n").forEachIndexed { paragraphIndex, paragraph ->
if (paragraphIndex > 0) lines += ""
val words = paragraph.replace('\n', ' ').split(' ').filter { it.isNotEmpty() }
var current = ""
var index = 0
while (index < words.size) {
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
val available = if (lines.size < reserveLines) fullWidth - reserve else fullWidth
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
current = candidate
index++
} else if (current.isEmpty()) {
lines += candidate
index++
} else {
lines += current
current = ""
}
}
if (current.isNotEmpty()) lines += current
}
return lines
}
/** Collapses text to two lines around the actions, ending with a bold More. */
internal fun splitBioCollapsed(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
): List<AnnotatedString> {
val words =
text
.replace("\n\n", " ")
.replace('\n', ' ')
.split(' ')
.filter { it.isNotEmpty() }
if (words.isEmpty()) return listOf(AnnotatedString(""))
val available = (fullWidth - reserve).coerceAtLeast(1)
var first = ""
var index = 0
while (index < words.size) {
val candidate = if (first.isEmpty()) words[index] else "$first ${words[index]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
first = candidate
index++
} else {
break
}
}
if (index >= words.size) return listOf(AnnotatedString(first))
val more =
buildAnnotatedString {
append("… ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("More") }
}
val moreWidth = measurer.measure(more, style, maxLines = 1).size.width
val secondAvailable = (available - moreWidth).coerceAtLeast(1)
var second = ""
while (index < words.size) {
val candidate = if (second.isEmpty()) words[index] else "$second ${words[index]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= secondAvailable) {
second = candidate
index++
} else {
break
}
}
val secondLine =
buildAnnotatedString {
if (second.isNotEmpty()) append("$second ")
append("… ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("More") }
}
return listOf(AnnotatedString(first), secondLine)
}
@Composable @Composable
private fun ArtistSectionHeader( private fun ArtistSectionHeader(
text: String, text: String,
@@ -491,7 +657,7 @@ private fun ArtistSectionHeader(
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 4.dp),
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,