diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 21:03:53 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 21:03:53 +0000 |
| commit | 19cc10e549749da0629aea4037008e915b8998a6 (patch) | |
| tree | fdd9245e52c372fa5c9a959662bf4b4cf54c8e07 | |
| parent | 8d980bb234b8303f17908621501620cb898d17ef (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.md | 25 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt | 45 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/listen/ReachabilityTest.kt | 32 |
3 files changed, 97 insertions, 5 deletions
@@ -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() |
