summaryrefslogtreecommitdiff
path: root/app
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 /app
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
Diffstat (limited to 'app')
-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
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()