summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-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()