diff options
Diffstat (limited to 'app')
10 files changed, 259 insertions, 202 deletions
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,39 +210,68 @@ 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. * * The route argument cannot be the answer on its own -- the queue advances with no navigation at 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), + ) + } +} |
