From 11abd4afbd5e7ee4604b9414f6451e7822e23396 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:50:15 +0000 Subject: [PATCH] ui: share bulk download deletion confirmation --- .../ui/components/DeleteDownloadsDialog.kt | 75 ++++++++++++ .../subtracks/ui/library/AlbumDetailScreen.kt | 30 ++--- .../ui/library/ArtistDetailScreen.kt | 33 +---- .../com/subtracks/ui/library/LibraryScreen.kt | 33 +---- .../ui/library/PlaylistDetailScreen.kt | 25 ++-- .../components/DeleteDownloadsDialogTest.kt | 114 ++++++++++++++++++ 6 files changed, 219 insertions(+), 91 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt index b7fc7c74..867d8781 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt @@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.subtracks.R +import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.DownloadList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch data class PendingDownloadDelete( val name: String, @@ -18,6 +29,70 @@ data class PendingDownloadDelete( val bytes: Long, ) +class PendingDownloadDeleteController internal constructor( + private val scope: CoroutineScope, + private val downloadedBytes: State Long>, +) { + var pending by mutableStateOf(null) + private set + + fun onAction( + target: MenuTarget, + action: BulkDownloadAction, + onAction: (DownloadList, String, BulkDownloadAction) -> Unit, + ) { + val (list, refId) = target.bulkRef() ?: return + onAction(target.title, list, refId, action, onAction) + } + + fun onAction( + name: String, + list: DownloadList, + refId: String, + action: BulkDownloadAction, + onAction: (DownloadList, String, BulkDownloadAction) -> Unit, + ) { + if (action == BulkDownloadAction.Delete) { + scope.launch { + pending = PendingDownloadDelete(name, list, refId, downloadedBytes.value(list, refId)) + } + } else { + onAction(list, refId, action) + } + } + + fun dismiss() { + pending = null + } +} + +@Composable +fun rememberPendingDownloadDelete( + dismissals: Flow, + downloadedBytes: suspend (DownloadList, String) -> Long, +): PendingDownloadDeleteController { + val scope = rememberCoroutineScope() + val currentDownloadedBytes = rememberUpdatedState(downloadedBytes) + val controller = remember(scope) { PendingDownloadDeleteController(scope, currentDownloadedBytes) } + DismissOnRequest(dismissals, controller::dismiss) + return controller +} + +@Composable +fun PendingDownloadDeleteConfirmation( + controller: PendingDownloadDeleteController, + onConfirm: (PendingDownloadDelete) -> Unit, +) { + controller.pending?.let { pending -> + DeleteDownloadsDialog( + name = pending.name, + bytes = pending.bytes, + onConfirm = { onConfirm(pending) }, + onDismiss = controller::dismiss, + ) + } +} + @Composable fun DeleteDownloadsDialog( name: String, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 1481b8de..8784fa5f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,6 +29,7 @@ import com.subtracks.data.model.Album import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Disc +import com.subtracks.data.model.DownloadList import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song @@ -39,16 +39,16 @@ import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ArtistChooserSheet import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost -import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.PendingDownloadDeleteConfirmation +import com.subtracks.ui.components.rememberPendingDownloadDelete import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.rememberArtworkColors import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -74,21 +74,16 @@ fun AlbumDetailRoute( val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle() CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) - val scope = rememberCoroutineScope() - var pendingDelete by remember { mutableStateOf(null) } + val dismissals = contextMenuHost?.dismissals ?: emptyFlow() + val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() } var showArtistChooser by remember { mutableStateOf(false) } - DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { - pendingDelete = null - showArtistChooser = false - } + DismissOnRequest(dismissals) { showArtistChooser = false } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val requestDownloadAction: (BulkDownloadAction) -> Unit = { action -> - if (action == BulkDownloadAction.Delete) { - scope.launch { pendingDelete = viewModel.downloadedBytes() } - } else { - viewModel.onDownloadAction(action) + pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested -> + viewModel.onDownloadAction(requested) } } val actions = @@ -141,13 +136,8 @@ fun AlbumDetailRoute( playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, ) - pendingDelete?.let { bytes -> - DeleteDownloadsDialog( - name = album?.name.orEmpty(), - bytes = bytes, - onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) }, - onDismiss = { pendingDelete = null }, - ) + PendingDownloadDeleteConfirmation(pendingDelete) { + viewModel.onDownloadAction(BulkDownloadAction.Delete) } if (showArtistChooser) { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 71a428ae..dcff2dcf 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -59,7 +59,6 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -111,13 +110,12 @@ import com.subtracks.ui.components.ArtistHeroArt import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt -import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.MenuTarget -import com.subtracks.ui.components.PendingDownloadDelete -import com.subtracks.ui.components.bulkRef +import com.subtracks.ui.components.PendingDownloadDeleteConfirmation +import com.subtracks.ui.components.rememberPendingDownloadDelete import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.theme.ArtworkColors @@ -130,7 +128,6 @@ import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberOverlaidNameBusy import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -217,26 +214,13 @@ fun ArtistDetailRoute( val offline by viewModel.offline.collectAsStateWithLifecycle() CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle() - val scope = rememberCoroutineScope() - var pendingDelete by remember { mutableStateOf(null) } - DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null } + val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes) val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val actions = ItemActions( playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, - bulkDownload = { target, action -> - target.bulkRef()?.let { (list, refId) -> - if (action == BulkDownloadAction.Delete) { - scope.launch { - pendingDelete = - PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId)) - } - } else { - viewModel.onDownloadAction(list, refId, action) - } - } - }, + bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) }, setStar = setStar, viewAlbum = onViewAlbum, ) @@ -267,13 +251,8 @@ fun ArtistDetailRoute( onShuffle = viewModel::shuffle, ) - pendingDelete?.let { pending -> - DeleteDownloadsDialog( - name = pending.name, - bytes = pending.bytes, - onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) }, - onDismiss = { pendingDelete = null }, - ) + PendingDownloadDeleteConfirmation(pendingDelete) { pending -> + viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 524ff018..63604dff 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -68,7 +68,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -126,13 +125,12 @@ import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ArtistArt import com.subtracks.ui.components.ContextMenuHost -import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget -import com.subtracks.ui.components.PendingDownloadDelete +import com.subtracks.ui.components.PendingDownloadDeleteConfirmation import com.subtracks.ui.components.SearchField -import com.subtracks.ui.components.bulkRef +import com.subtracks.ui.components.rememberPendingDownloadDelete import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.home.HomeRoute import com.subtracks.ui.home.HomeSection @@ -143,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor import com.subtracks.ui.theme.rememberArtworkColors import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import kotlin.math.abs @@ -278,26 +275,13 @@ fun LibraryRoute( val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title) val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() } val downloads = downloadsState?.value ?: emptyMap() - val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - var pendingDelete by remember { mutableStateOf(null) } - DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null } + val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes) val itemActions = ItemActions( playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, - bulkDownload = { target, action -> - target.bulkRef()?.let { (list, refId) -> - if (action == BulkDownloadAction.Delete) { - scope.launch { - pendingDelete = - PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId)) - } - } else { - viewModel.onDownloadAction(list, refId, action) - } - } - }, + bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) }, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, @@ -377,13 +361,8 @@ fun LibraryRoute( }, ) - pendingDelete?.let { pending -> - DeleteDownloadsDialog( - name = pending.name, - bytes = pending.bytes, - onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) }, - onDismiss = { pendingDelete = null }, - ) + PendingDownloadDeleteConfirmation(pendingDelete) { pending -> + viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index cf7a24a4..983503bb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey import com.subtracks.R import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.DownloadList import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSongItem @@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.CloseWhenDownloadsGone import com.subtracks.ui.components.ContextMenuHost -import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.PendingDownloadDeleteConfirmation +import com.subtracks.ui.components.rememberPendingDownloadDelete import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.rememberArtworkColors import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -60,16 +60,12 @@ fun PlaylistDetailRoute( val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle() CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) - val scope = rememberCoroutineScope() - var pendingDelete by remember { mutableStateOf(null) } - DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null } + val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() } val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val requestDownloadAction: (BulkDownloadAction) -> Unit = { action -> - if (action == BulkDownloadAction.Delete) { - scope.launch { pendingDelete = viewModel.downloadedBytes() } - } else { - viewModel.onDownloadAction(action) + pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested -> + viewModel.onDownloadAction(requested) } } val actions = @@ -104,13 +100,8 @@ fun PlaylistDetailRoute( playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, ) - pendingDelete?.let { bytes -> - DeleteDownloadsDialog( - name = playlist?.name.orEmpty(), - bytes = bytes, - onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) }, - onDismiss = { pendingDelete = null }, - ) + PendingDownloadDeleteConfirmation(pendingDelete) { + viewModel.onDownloadAction(BulkDownloadAction.Delete) } } diff --git a/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt index abd3edda..04effbc4 100644 --- a/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/components/DeleteDownloadsDialogTest.kt @@ -6,8 +6,13 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.BulkDownloadAction +import com.subtracks.data.model.DownloadList import com.subtracks.ui.theme.SubtracksTheme +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -55,6 +60,115 @@ class DeleteDownloadsDialogTest { composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed() } + @Test + fun sharedWorkflowLooksUpBytesAndConfirmsTheRequestedTargetOnce() { + val actions = mutableListOf>() + lateinit var controller: PendingDownloadDeleteController + renderWorkflow( + downloadedBytes = { list, refId -> + assertEquals(DownloadList.Album, list) + assertEquals("al1", refId) + 1_500_000 + }, + onAction = { list, refId, action -> actions += Triple(list, refId, action) }, + onController = { controller = it }, + ) + + composeRule.runOnIdle { + controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> } + } + composeRule.waitUntil { controller.pending != null } + composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("MB", substring = true).assertIsDisplayed() + composeRule.onNodeWithText("Delete").performClick() + + composeRule.runOnIdle { + assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Delete)), actions) + assertNull(controller.pending) + } + } + + @Test + fun sharedWorkflowCancelDoesNotConfirm() { + val actions = mutableListOf() + lateinit var controller: PendingDownloadDeleteController + renderWorkflow( + onAction = { _, _, action -> actions += action }, + onController = { controller = it }, + ) + + composeRule.runOnIdle { + controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> } + } + composeRule.waitUntil { controller.pending != null } + composeRule.onNodeWithText("Cancel").performClick() + + composeRule.runOnIdle { + assertEquals(emptyList(), actions) + assertNull(controller.pending) + } + } + + @Test + fun sharedWorkflowDismissesWithTheContextMenu() { + val dismissals = MutableSharedFlow(replay = 1) + lateinit var controller: PendingDownloadDeleteController + renderWorkflow(dismissals = dismissals, onController = { controller = it }) + + composeRule.runOnIdle { + controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> } + } + composeRule.waitUntil { controller.pending != null } + composeRule.runOnIdle { dismissals.tryEmit(Unit) } + + composeRule.waitUntil { controller.pending == null } + composeRule.onNodeWithText("Delete").assertDoesNotExist() + } + + @Test + fun sharedWorkflowDispatchesNonDeleteActionsImmediately() { + val actions = mutableListOf>() + var lookups = 0 + lateinit var controller: PendingDownloadDeleteController + renderWorkflow( + downloadedBytes = { _, _ -> + lookups++ + 0 + }, + onAction = { list, refId, action -> actions += Triple(list, refId, action) }, + onController = { controller = it }, + ) + + composeRule.runOnIdle { + controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Download) { list, refId, action -> + actions += Triple(list, refId, action) + } + } + + composeRule.runOnIdle { + assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Download)), actions) + assertEquals(0, lookups) + assertNull(controller.pending) + } + } + + private fun renderWorkflow( + dismissals: kotlinx.coroutines.flow.Flow = emptyFlow(), + downloadedBytes: suspend (DownloadList, String) -> Long = { _, _ -> 0 }, + onAction: (DownloadList, String, BulkDownloadAction) -> Unit = { _, _, _ -> }, + onController: (PendingDownloadDeleteController) -> Unit, + ) { + composeRule.setContent { + SubtracksTheme { + val controller = rememberPendingDownloadDelete(dismissals, downloadedBytes) + onController(controller) + PendingDownloadDeleteConfirmation(controller) { pending -> + onAction(pending.list, pending.refId, BulkDownloadAction.Delete) + } + } + } + } + private fun render( bytes: Long = 0, onConfirm: () -> Unit = {},