mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user