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 /app/src | |
| 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
Diffstat (limited to 'app/src')
| -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 |
2 files changed, 77 insertions, 0 deletions
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() |
