From e6636c49251785af218f5a44970fa662fc2bdccf Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 11 Nov 2026 10:06:17 +0000 Subject: The now playing screen is the size of the thing it is for Four things Peter asked for, all on the episode screen, which is the now playing screen -- there is no separate player. The artwork is 72% of the width rather than a 96dp thumbnail, so it reads as the artwork of what is playing instead of a list icon. Short of full width on purpose: the title and the transport still have to be on the first screen. The three controls you press without looking -- back, the primary, forward -- have a row to themselves at a size worth aiming at. Five controls on one line made all five small. The ad button, the speed and the sleep timer share a quieter row below. The sleep timer is on the screen now instead of three taps into the overflow menu, which is the wrong depth for something reached for in the dark. The chip carries what the timer is waiting for -- it reads 14:55 while a countdown runs and "End of episode" in the other mode -- so nothing has to be opened to find out. It opens the same dialog the menu does; one set of durations, one explanation of the fade, two ways in. The notes are shown in full and never behind a tap. The fold existed so long notes could not push the transport off the screen, and the transport moved above them when the two screens merged -- so all it still did was hide the half of the screen it was there to protect. DescriptionPreview and ExpandableDescription go with it. The wording moved to SleepLabel, which is tested: it was inline in the menu and again in the dialog, and the chip would have been a third copy. Verified on the emulator rather than from the diff: the screenshot shows all four, the chip opened the dialog, and arming 15m turned the chip into a running countdown. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../java/org/terst/listen/ui/DescriptionPreview.kt | 43 ------- .../java/org/terst/listen/ui/EpisodeDescription.kt | 27 ----- .../main/java/org/terst/listen/ui/EpisodeMenu.kt | 6 +- .../java/org/terst/listen/ui/EpisodeOverflow.kt | 10 +- .../main/java/org/terst/listen/ui/EpisodeScreen.kt | 63 ++++++++--- .../java/org/terst/listen/ui/EpisodeTransport.kt | 126 +++++++++++++++++---- .../main/java/org/terst/listen/ui/SleepLabel.kt | 43 +++++++ app/src/main/java/org/terst/listen/ui/TimeText.kt | 2 +- .../org/terst/listen/ui/DescriptionPreviewTest.kt | 81 ------------- .../java/org/terst/listen/ui/SleepLabelTest.kt | 60 ++++++++++ 10 files changed, 259 insertions(+), 202 deletions(-) delete mode 100644 app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt delete mode 100644 app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt create mode 100644 app/src/main/java/org/terst/listen/ui/SleepLabel.kt delete mode 100644 app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt create mode 100644 app/src/test/java/org/terst/listen/ui/SleepLabelTest.kt (limited to 'app') diff --git a/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt b/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt deleted file mode 100644 index 667a4db..0000000 --- a/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.terst.listen.ui - -/** - * How much of a description shows before the listener asks for the rest. - * - * It used to be two lines, because a show with long notes pushed the transport off the screen. - * That reason is gone: when the player and the episode screen merged, the notes moved *below* - * the transport, so there is nothing underneath them left to push away. What the old limit - * actually did on a real episode was cut the notes at 140 characters and leave the bottom - * three-fifths of the screen empty. - * - * So the budget is now what the space will hold -- about ten lines on a phone, measured against - * the gap the old rule left behind -- and the tap still opens the rest for notes longer than - * that. The rule is line count *and* a character budget, because stripped HTML is often one - * long paragraph that no number of newline-delimited lines would limit at all. - */ -object DescriptionPreview { - - /** Shared with the Text that renders it, so the two cannot disagree about the cut. */ - const val PREVIEW_LINES = 10 - - private const val MAX_LINES = PREVIEW_LINES - - /** Roughly ten lines of body text on a phone. */ - const val MAX_CHARS = 700 - - private const val ELLIPSIS = "…" - - data class Preview(val text: String, val truncated: Boolean) - - fun collapse(text: String): Preview { - val lines = text.lines().filter { it.isNotBlank() } - val kept = lines.take(MAX_LINES).joinToString("\n") - val cutByLines = lines.size > MAX_LINES - if (kept.length <= MAX_CHARS) { - return if (cutByLines) Preview(kept + ELLIPSIS, true) else Preview(kept, false) - } - val head = kept.take(MAX_CHARS) - // Back to the last space so a word is not sliced, unless there is no space to back to. - val atWord = head.lastIndexOf(' ').takeIf { it > 0 }?.let { head.take(it) } ?: head - return Preview(atWord.trimEnd() + ELLIPSIS, true) - } -} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt deleted file mode 100644 index 90dc78e..0000000 --- a/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.terst.listen.ui - -import androidx.compose.foundation.clickable -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow - -/** As much of the notes as the screen holds; a tap shows the rest, and another folds it back. */ -@Composable -fun ExpandableDescription(text: String) { - val preview = DescriptionPreview.collapse(text) - var expanded by remember(text) { mutableStateOf(false) } - val showAll = expanded || !preview.truncated - Text( - if (showAll) text else preview.text, - style = MaterialTheme.typography.bodyMedium, - maxLines = if (showAll) Int.MAX_VALUE else DescriptionPreview.PREVIEW_LINES, - overflow = TextOverflow.Ellipsis, - modifier = if (preview.truncated) Modifier.clickable { expanded = !expanded } else Modifier, - ) -} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt index 4ce2f3b..f39d542 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt @@ -73,11 +73,7 @@ object EpisodeMenu { add( EpisodeMenuItem( EpisodeMenuAction.SLEEP, - when (sleep) { - null -> "Sleep timer" - is SleepTimer.State.Countdown -> "Sleep timer \u00b7 ${formatClock(sleep.remainingMs)}" - SleepTimer.State.EndOfEpisode -> "Sleep timer \u00b7 end of episode" - }, + SleepLabel.menu(sleep), ), ) } diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt index b2f8ecb..fb966e6 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt @@ -112,7 +112,7 @@ private fun perform(action: EpisodeMenuAction, actions: EpisodeDetailActions, wa * nowhere until 2026-11-03. */ @Composable -private fun SleepTimerDialog(sleep: SleepTimer.State?, actions: EpisodeDetailActions, dismiss: () -> Unit) { +internal fun SleepTimerDialog(sleep: SleepTimer.State?, actions: EpisodeDetailActions, dismiss: () -> Unit) { val running = sleep != null AlertDialog( onDismissRequest = dismiss, @@ -120,13 +120,7 @@ private fun SleepTimerDialog(sleep: SleepTimer.State?, actions: EpisodeDetailAct text = { Column { Text( - when (sleep) { - null -> "Volume fades over the last 20 seconds rather than cutting." - is SleepTimer.State.Countdown -> - "Stopping in ${formatClock(sleep.remainingMs)}. Volume fades over the last 20 seconds." - SleepTimer.State.EndOfEpisode -> - "Stopping when this episode ends. Volume fades over the last 20 seconds." - }, + SleepLabel.explain(sleep), style = MaterialTheme.typography.bodyMedium, ) Row(Modifier.padding(top = 12.dp)) { diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt index 5635f28..e480cdc 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -3,10 +3,10 @@ package org.terst.listen.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -30,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow @@ -124,6 +125,7 @@ fun EpisodeScreen( ).withJudging(judging), modifier = Modifier.padding(padding), actions = episodeActions(vm, player, current, nowPlaying, hooks), + sleep = sleep, ) } } @@ -208,38 +210,67 @@ private fun EpisodeDetail( detail: EpisodeDetailState, modifier: Modifier, actions: EpisodeDetailActions, + sleep: SleepTimer.State? = null, ) { + // The dialog belongs to the screen that opens it, not to the screen above: the chip in the + // transport is the only way in from here, and hoisting the flag any higher only widened + // EpisodeScreen's argument list for a flag nothing else reads. + var sleepOpen by remember { mutableStateOf(false) } + if (sleepOpen) SleepTimerDialog(sleep, actions) { sleepOpen = false } Column( modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - AsyncImage( - model = episode.imageUrl, - contentDescription = null, - modifier = Modifier - .size(96.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + AsyncImage( + model = episode.imageUrl, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(ARTWORK_WIDTH) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + Column { + // Three lines, not two: this is the only place the whole title is shown, and an + // episode title that gives up halfway is no use on the screen you opened to read it. + Text( + episode.title, + style = MaterialTheme.typography.titleLarge, + maxLines = 3, + overflow = TextOverflow.Ellipsis, ) - Column(Modifier.weight(1f)) { - Text(episode.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) - } + Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) } EpisodeScrubber(detail.playback, actions) SkipReadout(detail.skips, detail.playback.breaks) - EpisodeTransport(detail.playback, detail.speed, actions) + EpisodeTransport(detail.playback, detail.speed, actions, sleep) { sleepOpen = true } RuleAttribution(episode, detail.ruleName) - episode.description?.let { ExpandableDescription(stripHtml(it)) } + // In full, and never behind a tap. The notes used to collapse so long ones could not push + // the transport off the screen; the transport moved above them a while ago, so all the + // fold did was hide the half of the screen it was protecting -- "don't make me tap to + // expand the full details". + episode.description?.let { + Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium) + } } } +/** + * How much of the width the artwork takes. + * + * A fraction rather than a size, because the point is that it reads as the artwork of the thing + * you are listening to rather than as a thumbnail in a list -- "larger artwork and controls". + * Short of the full width so the title and the transport stay on the first screen: the artwork + * is worth looking at and the transport is what the screen is for. + */ +private const val ARTWORK_WIDTH = 0.72f + /** * Which episode the screen is showing: the route's, until the player moves off it. * diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt index 3c4cd05..b8185bf 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt @@ -2,14 +2,17 @@ package org.terst.listen.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.FastForward import androidx.compose.material.icons.filled.FastRewind @@ -31,9 +34,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.terst.listen.playback.SleepTimer /** - * The one transport: back, the single primary control, forward, the ad button, and the speed. + * The one transport, in two rows: what you reach for while driving or half asleep, then what + * you set once. + * + * Five controls on one line made all five small, and the three that matter are the three you + * press without looking -- Peter asked for "larger artwork and controls". So back, the primary + * and forward take a row of their own at a size worth aiming at, and the ad button, the speed + * and the sleep timer share a quieter one below. * * The primary button's wording says what tapping it will do (Play, Resume, Pause) and it is the * only play control on the screen. Speed sits behind a tap showing the current value, so a row @@ -42,34 +52,108 @@ import androidx.compose.ui.unit.dp * The ad button is the same gesture the notification carries, and it is here for the same reason * it is there: detection accounts for roughly 38% of the advertising the feeds imply, so most of * what gets heard was never found, and this is the only moment anyone notices. + * + * Sleep is here rather than only in the overflow menu because it is a playback control that is + * reached for in the dark, and three taps away in a menu is two too many. The chip carries what + * the timer is waiting for, so nothing has to be opened to find out. */ @Composable -fun EpisodeTransport(playback: EpisodePlayback, speed: Float, actions: EpisodeDetailActions) { - Row( +fun EpisodeTransport( + playback: EpisodePlayback, + speed: Float, + actions: EpisodeDetailActions, + sleep: SleepTimer.State? = null, + onSleep: () -> Unit = {}, +) { + Column( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - IconButton(onClick = { actions.skip(-Transport.BACK_MS) }) { - Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds") + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { actions.skip(-Transport.BACK_MS) }, + modifier = Modifier.size(SEEK_BUTTON), + ) { + Icon( + Icons.Default.FastRewind, + contentDescription = "Back 15 seconds", + modifier = Modifier.size(SEEK_ICON), + ) + } + Button(onClick = actions.togglePlayPause, modifier = Modifier.height(PRIMARY_HEIGHT)) { + Icon( + if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(PRIMARY_ICON), + ) + Text( + Transport.primaryLabel(playback.isPlaying, playback.positionMs), + Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + IconButton( + onClick = { actions.skip(Transport.FORWARD_MS) }, + modifier = Modifier.size(SEEK_BUTTON), + ) { + Icon( + Icons.Default.FastForward, + contentDescription = "Forward 25 seconds", + modifier = Modifier.size(SEEK_ICON), + ) + } } - Button(onClick = actions.togglePlayPause) { - Icon( - if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Text(Transport.primaryLabel(playback.isPlaying, playback.positionMs), Modifier.padding(start = 6.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // A circle-and-slash, the same mark the notification uses, so the two read as one + // control in two places rather than two features. + IconButton(onClick = actions.reportAd, enabled = playback.isPlaying) { + Icon(Icons.Default.Block, contentDescription = "This is an ad") + } + SpeedChip(speed, actions.setSpeed) + SleepChip(sleep, onSleep) } - IconButton(onClick = { actions.skip(Transport.FORWARD_MS) }) { - Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds") + } +} + +/** + * How big the three controls you press without looking are. + * + * One definition each rather than a number at each use: the seek buttons are a matched pair and + * drifting apart would read as a mistake. Sized up from the Material default, which is built for + * a list row rather than for the one screen whose whole job is these three buttons. + */ +private val SEEK_BUTTON = 64.dp +private val SEEK_ICON = 36.dp +private val PRIMARY_HEIGHT = 64.dp +private val PRIMARY_ICON = 32.dp + +/** + * The sleep timer, showing what it is waiting for. + * + * Outlined rather than filled while nothing is running, and filled once something is, so whether + * the app is going to stop on its own is answerable from across the room. + */ +@Composable +private fun SleepChip(sleep: SleepTimer.State?, onSleep: () -> Unit) { + val label = SleepLabel.chip(sleep) + if (sleep == null) { + OutlinedButton(onClick = onSleep, contentPadding = SPEED_PADDING) { + Icon(Icons.Default.Bedtime, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(label, Modifier.padding(start = 6.dp)) } - // A circle-and-slash, the same mark the notification uses, so the two read as one - // control in two places rather than two features. - IconButton(onClick = actions.reportAd, enabled = playback.isPlaying) { - Icon(Icons.Default.Block, contentDescription = "This is an ad") + } else { + Button(onClick = onSleep, contentPadding = SPEED_PADDING) { + Icon(Icons.Default.Bedtime, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(label, Modifier.padding(start = 6.dp)) } - SpeedChip(speed, actions.setSpeed) } } diff --git a/app/src/main/java/org/terst/listen/ui/SleepLabel.kt b/app/src/main/java/org/terst/listen/ui/SleepLabel.kt new file mode 100644 index 0000000..71e7f37 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SleepLabel.kt @@ -0,0 +1,43 @@ +package org.terst.listen.ui + +import org.terst.listen.playback.SleepTimer + +/** + * What the app says about the sleep timer, in the three places it says it. + * + * The wording was inline in two of them -- the overflow item and the dialog's explanation -- and + * putting a control on the episode screen would have made a third copy. The fade is mentioned + * wherever there is room for a sentence, because a timer that silences before it stops is + * surprising if you do not know it is coming. + */ +object SleepLabel { + + /** The overflow menu's line, which names the feature because nothing else there does. */ + fun menu(state: SleepTimer.State?): String = when (state) { + null -> "Sleep timer" + is SleepTimer.State.Countdown -> "Sleep timer \u00b7 ${formatClock(state.remainingMs)}" + SleepTimer.State.EndOfEpisode -> "Sleep timer \u00b7 end of episode" + } + + /** + * The chip on the episode screen, where the moon beside it already names the feature. + * + * Short because it sits in a row of controls: what matters at a glance is whether anything + * is running and how long is left, not what the thing is called. + */ + fun chip(state: SleepTimer.State?): String = when (state) { + null -> "Sleep" + is SleepTimer.State.Countdown -> formatClock(state.remainingMs) + SleepTimer.State.EndOfEpisode -> "End of episode" + } + + /** The dialog's sentence, which is the one place with room to explain the fade. */ + fun explain(state: SleepTimer.State?): String = when (state) { + null -> "Volume fades over the last 20 seconds rather than cutting." + is SleepTimer.State.Countdown -> + "Stopping in ${formatClock(state.remainingMs)}. $FADE" + SleepTimer.State.EndOfEpisode -> "Stopping when this episode ends. $FADE" + } + + private const val FADE = "Volume fades over the last 20 seconds." +} diff --git a/app/src/main/java/org/terst/listen/ui/TimeText.kt b/app/src/main/java/org/terst/listen/ui/TimeText.kt index f70f80f..1d84b24 100644 --- a/app/src/main/java/org/terst/listen/ui/TimeText.kt +++ b/app/src/main/java/org/terst/listen/ui/TimeText.kt @@ -13,7 +13,7 @@ import java.util.concurrent.TimeUnit * first, and had grown five and three callers respectively. Both are pure, both are read on * screens that share no code, and each file had defined its own minutes-per-hour -- so editing * the now-playing bar meant editing the transcript gutter's formatter. One home, one set of - * constants, tested directly: the same arrangement as [SpeedChoices], [DescriptionPreview] and + * constants, tested directly: the same arrangement as [SpeedChoices], [SleepLabel] and * `WaitingForServerText`, which is already this app's rule for anything it says out loud. */ private const val MS_PER_SECOND = 1000 diff --git a/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt b/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt deleted file mode 100644 index 4a61403..0000000 --- a/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.terst.listen.ui - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * How much of an episode's notes shows before the listener asks for the rest. - * - * The fixtures are sized from the constants rather than written out, because they were not: the - * budget went from two lines to ten when the transport moved above the notes, and three tests - * that were really about "more than the budget is cut" had the old budget baked into their - * text. - */ -class DescriptionPreviewTest { - - /** One line more than fits, so the cut is exercised whatever the budget becomes. */ - private fun linesBeyondTheBudget(): String = - (1..DescriptionPreview.PREVIEW_LINES + 1).joinToString("\n") { "Line $it." } - - private fun keptLines(): String = - (1..DescriptionPreview.PREVIEW_LINES).joinToString("\n") { "Line $it." } - - @Test - fun `a short description is shown whole`() { - val p = DescriptionPreview.collapse("A short note.") - assertEquals("A short note.", p.text) - assertFalse(p.truncated) - } - - @Test - fun `two lines fit`() { - val p = DescriptionPreview.collapse("First.\nSecond.") - assertEquals("First.\nSecond.", p.text) - assertFalse(p.truncated) - } - - @Test - fun `a line beyond the budget is cut and the rest is offered`() { - val p = DescriptionPreview.collapse(linesBeyondTheBudget()) - assertEquals(keptLines() + "…", p.text) - assertTrue(p.truncated) - } - - /** Everything within the budget is shown, which is the case the old two-line rule failed. */ - @Test - fun `a description that fits the budget is shown whole`() { - val p = DescriptionPreview.collapse(keptLines()) - assertEquals(keptLines(), p.text) - assertFalse(p.truncated) - } - - @Test - fun `blank lines do not use up the allowance`() { - val p = DescriptionPreview.collapse("First.\n\n\nSecond.") - assertEquals("First.\nSecond.", p.text) - assertFalse(p.truncated) - } - - @Test - fun `one long paragraph is cut at a word boundary within the budget`() { - val long = "word ".repeat(DescriptionPreview.MAX_CHARS).trim() - val p = DescriptionPreview.collapse(long) - assertTrue(p.truncated) - assertTrue(p.text.endsWith("word…")) - assertTrue(p.text.length <= DescriptionPreview.MAX_CHARS + 1) - } - - @Test - fun `an unbroken string is hard cut rather than dropped`() { - val p = DescriptionPreview.collapse("x".repeat(DescriptionPreview.MAX_CHARS * 2)) - assertTrue(p.truncated) - assertEquals(DescriptionPreview.MAX_CHARS + 1, p.text.length) - } - - @Test - fun `an empty description previews as nothing`() { - assertEquals(DescriptionPreview.Preview("", false), DescriptionPreview.collapse("")) - } -} diff --git a/app/src/test/java/org/terst/listen/ui/SleepLabelTest.kt b/app/src/test/java/org/terst/listen/ui/SleepLabelTest.kt new file mode 100644 index 0000000..f043fa9 --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/SleepLabelTest.kt @@ -0,0 +1,60 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.terst.listen.playback.SleepTimer + +class SleepLabelTest { + + @Test + fun theMenuNamesTheFeatureWhenNothingIsRunning() { + assertEquals("Sleep timer", SleepLabel.menu(null)) + } + + @Test + fun theMenuCarriesTheTimeLeft() { + assertEquals( + "Sleep timer · 12:04", + SleepLabel.menu(SleepTimer.State.Countdown(12 * 60 * 1000L + 4_000)), + ) + } + + @Test + fun theMenuSaysWhenItIsWaitingForTheEpisodeToEnd() { + assertEquals("Sleep timer · end of episode", SleepLabel.menu(SleepTimer.State.EndOfEpisode)) + } + + /** The chip sits beside a moon, so it spends its width on the state rather than the name. */ + @Test + fun theChipShowsOnlyWhatIsLeft() { + assertEquals("Sleep", SleepLabel.chip(null)) + assertEquals("12:04", SleepLabel.chip(SleepTimer.State.Countdown(12 * 60 * 1000L + 4_000))) + assertEquals("End of episode", SleepLabel.chip(SleepTimer.State.EndOfEpisode)) + } + + /** + * The fade is mentioned in every one of these, running or not: being silenced twenty seconds + * before being stopped is surprising if you do not know it is coming. + */ + @Test + fun theExplanationAlwaysMentionsTheFade() { + val all = listOf(null, SleepTimer.State.Countdown(60_000), SleepTimer.State.EndOfEpisode) + + all.forEach { state -> + val said = SleepLabel.explain(state) + assertEquals("fade should be mentioned for $state, got: $said", true, said.contains("fades")) + } + } + + @Test + fun theExplanationSaysWhatItIsWaitingFor() { + assertEquals( + "Stopping in 1:00. Volume fades over the last 20 seconds.", + SleepLabel.explain(SleepTimer.State.Countdown(60_000)), + ) + assertEquals( + "Stopping when this episode ends. Volume fades over the last 20 seconds.", + SleepLabel.explain(SleepTimer.State.EndOfEpisode), + ) + } +} -- cgit v1.2.3