diff options
| -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() |
