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