summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md25
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt45
-rw-r--r--app/src/test/java/org/terst/listen/ReachabilityTest.kt32
3 files changed, 97 insertions, 5 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 130d3a8..f97e524 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -90,11 +90,26 @@ claims three separate times across these repos.
## State
-Phase 1 is built: subscribe (share sheet, pasted URL, OPML import), refresh, download, queue,
-play, variable speed, sleep timer, retention. 182 unit tests plus instrumented tests.
-
-**Not built yet:** everything in phase 2 — the rule engine, slow-drip subscriptions, and
-intro/outro skipping. Schema fields for manual skip offsets exist but nothing reads them.
+**Phase 1 is complete.** Subscribe (share sheet, pasted URL, OPML import), refresh, download,
+queue, play, variable speed, sleep timer, retention, unsubscribe, Android Auto with a browsable
+library and voice search. 207 unit tests plus instrumented tests covering the media browse tree.
+
+**Phase 2, not started:** the auto-queue rule engine, slow-drip subscriptions, and intro/outro
+skipping. `skipIntroMs`/`skipOutroMs` exist on `SubscriptionEntity` and nothing reads them.
+
+**Known gaps in what is built** (deliberate, not forgotten):
+
+- **No artwork anywhere.** The parser captures `imageUrl` at channel and item level and the
+ media session passes it to the lock screen, but no screen renders it. Needs an image-loading
+ dependency (Coil) and caching.
+- **No player screen.** There is a transport bar only: play/pause, skip, title. No scrubbing,
+ no per-show speed (the field exists on `SubscriptionEntity`, only the global is reachable).
+- **No queue reordering.** `QueueOrdering.move` and `QueueRepository.move` are written and
+ tested with no UI, which the reachability guard tolerates because they are on a class rather
+ than an object.
+- **Android Auto is unverified in a real car.** The session protocol is proven by
+ `MediaBrowseTest`; whether Auto's own compatibility checks pass on a head unit is untested,
+ and a sideloaded media app may need Auto's developer mode enabled.
## Domain notes worth keeping
diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
index f15debd..70f48e5 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -31,6 +31,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,6 +56,7 @@ fun SubscriptionScreen(
onBack: () -> Unit,
) {
val episodes by vm.episodesOf(subscriptionId).collectAsState(initial = emptyList())
+ var confirming by remember { mutableStateOf(false) }
val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId }
Scaffold(
@@ -64,9 +68,27 @@ fun SubscriptionScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
+ actions = {
+ IconButton(onClick = { confirming = true }) {
+ Icon(Icons.Default.Delete, contentDescription = "Unsubscribe")
+ }
+ },
)
},
) { padding ->
+ // Confirmed, because unsubscribing cascades: the episodes go, and with them every
+ // playback position and played mark for that show. None of it is recoverable.
+ UnsubscribeDialog(
+ visible = confirming,
+ showTitle = subscription?.title,
+ onDismiss = { confirming = false },
+ onConfirm = {
+ confirming = false
+ vm.unsubscribe(subscriptionId)
+ onBack()
+ },
+ )
+
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
items(episodes, key = { it.id }) { episode ->
EpisodeRow(
@@ -87,3 +109,26 @@ fun SubscriptionScreen(
}
}
+/** Confirmed, because unsubscribing cascades: episodes, downloads and every playback position
+ * for that show go with it, and none of it is recoverable. */
+@Composable
+private fun UnsubscribeDialog(
+ visible: Boolean,
+ showTitle: String?,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ if (!visible) return
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Unsubscribe?") },
+ text = {
+ Text(
+ "Removes ${showTitle ?: "this podcast"} along with its episodes, downloads and " +
+ "playback positions."
+ )
+ },
+ confirmButton = { TextButton(onClick = onConfirm) { Text("Unsubscribe") } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+}
diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
index ccd603a..6a33380 100644
--- a/app/src/test/java/org/terst/listen/ReachabilityTest.kt
+++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
@@ -106,6 +106,38 @@ class ReachabilityTest {
dead.isEmpty())
}
+ /**
+ * View models are the third place this has hidden. A function here with no screen calling
+ * it is not merely unused -- it is a feature the user cannot reach, which is how "you
+ * cannot unsubscribe from a podcast" survived a whole phase.
+ */
+ @Test
+ fun `every view model function is reachable from a screen`() {
+ val uiDir = File(mainDir, "ui")
+ val viewModels = uiDir.listFiles { f: File -> f.name.endsWith("ViewModel.kt") }?.toList()
+ assertTrue("expected view models under ${uiDir.path}", !viewModels.isNullOrEmpty())
+
+ val screens = uiDir.walkTopDown()
+ .filter { it.extension == "kt" && !it.name.endsWith("ViewModel.kt") }
+ .joinToString("\n") { it.readText() }
+
+ val dead = viewModels!!.flatMap { file ->
+ Regex("""^ fun (\w+)""", RegexOption.MULTILINE).findAll(file.readText())
+ .map { it.groupValues[1] }
+ .filterNot { it in allowedViewModelFunctions }
+ // A screen may call it directly or pass it as a reference (vm::refreshAll).
+ .filterNot { screens.contains("$it(") || screens.contains("::$it") }
+ .toList()
+ }
+
+ assertTrue(
+ "No screen reaches: ${dead.sorted()}. These are features the user cannot get to.",
+ dead.isEmpty(),
+ )
+ }
+
+ private val allowedViewModelFunctions = emptySet<String>()
+
@Test
fun `every helper object function has a caller`() {
val sources = mainDir.walkTopDown().filter { it.extension == "kt" }.toList()