ui: add a Less control and preserve bio paragraphs

Keep source line breaks, treat the triple-space paragraph separator some servers return as a blank line, justify wrapped bio lines, and append a bold Less control to the expanded bio.
This commit is contained in:
austinried
2026-10-19 13:36:06 +00:00
parent 98398ab2fe
commit cac1f6af24
6 changed files with 217 additions and 30 deletions
@@ -556,6 +556,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
const val SEARCH_RESULT_LIMIT = 50
internal fun normalizeBiography(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() }
internal fun normalizeBiography(value: String?): String? =
value
?.replace("\r\n", "\n")
?.replace('\r', '\n')
?.replace(Regex(" {3,}"), "\n\n")
?.trim()
?.takeIf { it.isNotEmpty() }
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
@@ -155,6 +155,7 @@ private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
internal const val ARTIST_BIO_LESS_TAG = "artistBioLess"
private data class BioSnapshot(
val known: Boolean,
@@ -240,6 +241,7 @@ fun ArtistDetailRoute(
starred = artist?.starred != null,
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
onExpandBio = viewModel::expandBio,
onCollapseBio = viewModel::collapseBio,
onShuffle = viewModel::shuffle,
)
@@ -278,6 +280,7 @@ fun ArtistDetailScreen(
starred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
onExpandBio: () -> Unit = {},
onCollapseBio: () -> Unit = {},
onShuffle: () -> Unit = {},
) {
val listState = rememberLazyGridState()
@@ -455,11 +458,13 @@ fun ArtistDetailScreen(
MaterialTheme.colorScheme.outline
},
collapsed = bioCollapsed,
expanded = hasBio && bioExpanded,
artist = artist,
starred = starred,
onToggleStar = onToggleStar,
onMore = onMore,
onExpand = onExpandBio,
onCollapse = onCollapseBio,
modifier =
Modifier.padding(
bottom = if (hasBio && !bioCollapsed) 8.dp else 0.dp,
@@ -553,17 +558,20 @@ private fun ArtistBio(
text: String,
color: Color,
collapsed: Boolean,
expanded: Boolean,
artist: Artist?,
starred: Boolean,
onToggleStar: (() -> Unit)?,
onMore: () -> Unit,
onExpand: () -> Unit,
onCollapse: () -> Unit,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = MaterialTheme.typography.bodyMedium
val moreText = stringResource(R.string.artist_bio_more)
val lessText = stringResource(R.string.artist_bio_less)
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
@@ -573,33 +581,49 @@ private fun ArtistBio(
}.coerceAtLeast(1)
val reserveLines = ceil(with(density) { BIO_ACTION_HEIGHT.toPx() } / lineHeightPx).toInt().coerceAtLeast(1)
val bioLines =
remember(text, fullWidth, reserve, reserveLines, collapsed, moreText) {
if (collapsed) {
splitBioCollapsed(measurer, text, style, fullWidth, reserve, moreText)
} else {
BioLines(
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
AnnotatedString(it.ifEmpty { " " })
},
more = false,
)
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) {
when {
collapsed -> {
splitBioCollapsed(measurer, text, style, fullWidth, reserve, moreText)
}
expanded -> {
splitBioExpanded(measurer, text, style, fullWidth, reserve, reserveLines, lessText)
}
else -> {
BioLines(
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
AnnotatedString(it.ifEmpty { " " })
},
more = false,
)
}
}
}
Box {
Column(Modifier.fillMaxWidth()) {
bioLines.lines.forEachIndexed { index, line ->
val more = bioLines.more && index == bioLines.lines.lastIndex
val isControl = index == bioLines.lines.lastIndex && (bioLines.more || bioLines.less)
val nextBlank = index == bioLines.lines.lastIndex || bioLines.lines[index + 1].text.isBlank()
val available = bioLineWidth(index, fullWidth, reserve, reserveLines)
val displayLine =
if (!nextBlank && line.text.isNotBlank() && line.spanStyles.isEmpty()) {
justifyBioLine(line, measurer, style, available)
} else {
line
}
Text(
text = line,
text = displayLine,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
if (more) {
Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
} else {
Modifier
when {
isControl && bioLines.more -> Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
isControl && bioLines.less -> Modifier.testTag(ARTIST_BIO_LESS_TAG).clickable(onClick = onCollapse)
else -> Modifier
},
)
}
@@ -611,7 +635,14 @@ private fun ArtistBio(
}
}
/** Splits text into lines, narrowing the lines that would overlap the actions. */
private fun bioLineWidth(
index: Int,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
): Int = if (index < reserveLines) fullWidth - reserve else fullWidth
/** Splits text at every source line break, then wraps it to the width left by the actions. */
internal fun splitBio(
measurer: TextMeasurer,
text: String,
@@ -620,35 +651,125 @@ internal fun splitBio(
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() }
text.split('\n').forEach { sourceLine ->
val words = sourceLine.trim().split(' ').filter { it.isNotEmpty() }
if (words.isEmpty()) {
lines += ""
return@forEach
}
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
for (word in words) {
val candidate = if (current.isEmpty()) word else "$current $word"
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
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 = ""
current = word
}
}
if (current.isNotEmpty()) lines += current
}
return lines
while (lines.isNotEmpty() && lines.last().isBlank()) lines.removeAt(lines.lastIndex)
return lines.ifEmpty { listOf("") }
}
internal fun splitBioExpanded(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
lessText: String,
): BioLines {
val lines = splitBio(measurer, text, style, fullWidth, reserve, reserveLines).mapTo(mutableListOf()) { AnnotatedString(it) }
appendBioControl(lines, lessText, measurer, style, fullWidth, reserve, reserveLines)
return BioLines(lines, more = false, less = true)
}
private fun appendBioControl(
lines: MutableList<AnnotatedString>,
label: String,
measurer: TextMeasurer,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
) {
while (lines.isNotEmpty() && lines.last().text.isBlank()) lines.removeAt(lines.lastIndex)
val control =
buildAnnotatedString {
append(" ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(label) }
}
if (lines.isEmpty()) {
lines += control
return
}
val lastIndex = lines.lastIndex
val available = bioLineWidth(lastIndex, fullWidth, reserve, reserveLines)
val combined =
buildAnnotatedString {
append(lines[lastIndex])
append(control)
}
if (measurer.measure(combined, style, maxLines = 1).size.width <= available) {
lines[lastIndex] = combined
return
}
val words = lines[lastIndex].text.split(' ').filter { it.isNotEmpty() }
if (words.size > 1) {
val tailLine =
buildAnnotatedString {
append(words.last())
append(control)
}
if (measurer.measure(tailLine, style, maxLines = 1).size.width <= available) {
lines[lastIndex] = AnnotatedString(words.dropLast(1).joinToString(" "))
lines += tailLine
return
}
}
lines += control
}
internal fun justifyBioLine(
line: AnnotatedString,
measurer: TextMeasurer,
style: TextStyle,
available: Int,
): AnnotatedString {
if (line.spanStyles.isNotEmpty()) return line
val parts = line.text.split(' ')
if (parts.size < 2) return line
val spaceWidth = measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
if (spaceWidth <= 0) return line
val extra = available - measurer.measure(line, style, maxLines = 1).size.width
val spaces = extra / spaceWidth
if (spaces <= 0) return line
val gaps = parts.size - 1
val perGap = spaces / gaps
val remainder = spaces % gaps
return AnnotatedString(
buildString {
parts.forEachIndexed { index, part ->
append(part)
if (index < gaps) {
repeat(1 + perGap + if (index < remainder) 1 else 0) { append(' ') }
}
}
},
)
}
internal data class BioLines(
val lines: List<AnnotatedString>,
val more: Boolean,
val less: Boolean = false,
)
/** Collapses text to two lines, appending a bold More only when it does not fit. */
@@ -110,6 +110,10 @@ class ArtistDetailViewModel(
_bioExpanded.value = true
}
fun collapseBio() {
_bioExpanded.value = false
}
suspend fun downloadedBytes(
list: DownloadList,
refId: String,
+1
View File
@@ -41,6 +41,7 @@
<string name="artist_bio_loading">Loading artist bio…</string>
<string name="artist_bio_unavailable">Artist bio unavailable.</string>
<string name="artist_bio_more">More</string>
<string name="artist_bio_less">Less</string>
<string name="artist_meta">%1$s · %2$s</string>
<string name="resources_filter_genre">Genre</string>
<string name="resources_filter_starred">Starred</string>
@@ -329,6 +329,15 @@ class LibraryRepositoryTest {
}
}
@Test
fun biographiesKeepLineAndParagraphBreaks() {
assertEquals("Line one\nLine two", normalizeBiography("Line one\r\nLine two"))
assertEquals("Line one\nLine two", normalizeBiography("Line one\nLine two"))
assertEquals("One\n\nTwo", normalizeBiography("One Two"))
assertEquals("One\n\nTwo", normalizeBiography("One\n\nTwo"))
assertNull(normalizeBiography(" "))
}
@Test
fun aFailedRefreshKeepsTheStoredBio() =
runBlocking {
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
@@ -25,6 +26,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -65,6 +67,7 @@ class ArtistDetailScreenTest {
onBack = {},
onAlbumClick = {},
onExpandBio = { expanded = true },
onCollapseBio = { expanded = false },
)
}
}
@@ -219,6 +222,12 @@ class ArtistDetailScreenTest {
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).performClick()
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).assertDoesNotExist()
composeRule.onNodeWithTag(ARTIST_BIO_LESS_TAG).performScrollTo().assertExists()
composeRule.onNodeWithTag(ARTIST_BIO_LESS_TAG).performClick()
composeRule.onNodeWithTag(ARTIST_BIO_LESS_TAG).assertDoesNotExist()
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).performScrollTo().assertExists()
}
@Test
@@ -275,6 +284,43 @@ class ArtistDetailScreenTest {
)
}
@Test
fun sourceLineAndParagraphBreaksArePreserved() {
var lineBreak: List<String>? = null
var paragraphBreak: List<String>? = null
composeRule.setContent {
val measurer = rememberTextMeasurer()
val style = TextStyle(fontSize = 14.sp)
lineBreak = splitBio(measurer, "First line.\nSecond line.", style, 400, 0, 2)
paragraphBreak = splitBio(measurer, "First paragraph.\n\nSecond paragraph.", style, 400, 0, 2)
}
composeRule.waitForIdle()
assertEquals(listOf("First line.", "Second line."), lineBreak)
assertEquals(listOf("First paragraph.", "", "Second paragraph."), paragraphBreak)
}
@Test
fun aJustifiedLineFillsTheAvailableWidth() {
val style = TextStyle(fontSize = 14.sp)
var plainWidth = 0
var justifiedWidth = 0
var justified = ""
composeRule.setContent {
val measurer = rememberTextMeasurer()
val line = AnnotatedString("one two three four five")
plainWidth = measurer.measure(line, style, maxLines = 1).size.width
val result = justifyBioLine(line, measurer, style, 400)
justifiedWidth = measurer.measure(result, style, maxLines = 1).size.width
justified = result.text
}
composeRule.waitForIdle()
assertTrue(justifiedWidth > plainWidth)
assertTrue(justifiedWidth <= 400)
assertTrue(justified.contains(" "))
}
private fun album(
id: String,
name: String,