diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 58c75496..63058fa1 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 7f7bde08..07723b86 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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 { - if (text.isBlank()) return listOf("") val lines = mutableListOf() - 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, + 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, val more: Boolean, + val less: Boolean = false, ) /** Collapses text to two lines, appending a bold More only when it does not fit. */ diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 00e810bd..52e82ad9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -110,6 +110,10 @@ class ArtistDetailViewModel( _bioExpanded.value = true } + fun collapseBio() { + _bioExpanded.value = false + } + suspend fun downloadedBytes( list: DownloadList, refId: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfbfee3f..a2303c1e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Loading artist bio… Artist bio unavailable. More + Less %1$s · %2$s Genre Starred diff --git a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt index 6dd83601..e825e02a 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/LibraryRepositoryTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/ui/library/ArtistDetailScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/library/ArtistDetailScreenTest.kt index 4dd488d2..c8e9f5fd 100644 --- a/app/src/test/kotlin/com/subtracks/ui/library/ArtistDetailScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/library/ArtistDetailScreenTest.kt @@ -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? = null + var paragraphBreak: List? = 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,