summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 21:03:53 +0000
committerClaude <noreply@anthropic.com>2026-09-21 21:03:53 +0000
commit19cc10e549749da0629aea4037008e915b8998a6 (patch)
treefdd9245e52c372fa5c9a959662bf4b4cf54c8e07
parent8d980bb234b8303f17908621501620cb898d17ef (diff)
Make unsubscribe reachable, and guard view models for reachability
Found while auditing what phase 1 still owed: unsubscribe existed on the view model and no screen called it, so there was no way to remove a podcast. Not dead code -- a feature the user could not get to, which had survived the whole phase. The reachability guard covered helper objects and DAOs but not view models, which is the third place this class has hidden. It now checks that every view model function is reached by some screen, matching both a direct call and a method reference. It fails on unsubscribe before the fix and passes after. Unsubscribing asks first. It cascades to episodes, downloads and every playback position for that show, and none of that is recoverable from anywhere. CLAUDE.md now records what phase 1 deliberately does not have -- artwork, a player screen, queue reordering -- so the next session finds them written down rather than rediscovering them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-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()