summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt43
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt27
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt63
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt126
-rw-r--r--app/src/main/java/org/terst/listen/ui/SleepLabel.kt43
-rw-r--r--app/src/main/java/org/terst/listen/ui/TimeText.kt2
-rw-r--r--app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt81
-rw-r--r--app/src/test/java/org/terst/listen/ui/SleepLabelTest.kt60
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),
+ )
+ }
+}