diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadRemoveDialog.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadRemoveDialog.kt index 1d5803af..24a88ae7 100644 --- a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadRemoveDialog.kt +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadRemoveDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.jellyfin.mobile.R @@ -22,7 +23,7 @@ import org.jellyfin.mobile.data.entity.DownloadEntity @Composable fun DownloadRemoveDialog( - download: DownloadEntity, + downloads: List, onConfirm: (Boolean) -> Unit, onDismiss: () -> Unit, ) { @@ -31,13 +32,25 @@ fun DownloadRemoveDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(text = stringResource(R.string.download_remove)) }, + title = { Text(text = pluralStringResource(R.plurals.download_remove_title, downloads.size)) }, text = { Column { - val name = remember(download, context) { - download.getDisplayName(context).orEmpty() + val name = remember(downloads, context) { + downloads + .firstOrNull() + .takeIf { downloads.size == 1 } + ?.getDisplayName(context) + .orEmpty() } - Text(text = stringResource(R.string.download_remove_description, name)) + Text( + text = pluralStringResource( + R.plurals.download_remove_description, + downloads.size, + downloads.size, + name, + ) + ) + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp), diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt index 701e2f78..7b40fc10 100644 --- a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt @@ -1,11 +1,19 @@ package org.jellyfin.mobile.ui.screens.downloads import android.text.format.Formatter +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.Checkbox import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.ListItem @@ -14,6 +22,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -38,10 +47,13 @@ fun DownloadsList( downloads: List, onOpen: (DownloadEntity) -> Unit, onDownload: (DownloadEntity) -> Unit, - onRemove: (DownloadEntity) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues.Zero, + selection: Set = emptySet(), + onToggleSelection: (DownloadEntity) -> Unit = {}, ) { + val selectionMode = selection.isNotEmpty() + LazyColumn( modifier = modifier, contentPadding = contentPadding, @@ -54,7 +66,9 @@ fun DownloadsList( downloadFiles = downloadFiles, onOpen = { onOpen(downloadFiles.download) }, onDownload = { onDownload(downloadFiles.download) }, - onRemove = { onRemove(downloadFiles.download) }, + onToggleSelection = { onToggleSelection(downloadFiles.download) }, + isSelected = selection.contains(downloadFiles.download.id), + selectionMode = selectionMode, ) } } @@ -66,8 +80,10 @@ fun DownloadItem( downloadFiles: DownloadFiles, onOpen: () -> Unit, onDownload: () -> Unit, - onRemove: () -> Unit, + onToggleSelection: () -> Unit, modifier: Modifier = Modifier, + isSelected: Boolean = false, + selectionMode: Boolean = false, ) { val (download, files) = downloadFiles val context = LocalContext.current @@ -83,13 +99,13 @@ fun DownloadItem( modifier = modifier .combinedClickable( onClick = { - if (!isVerified) { - onDownload() - } else { - onOpen() + when { + selectionMode -> onToggleSelection() + !isVerified -> onDownload() + else -> onOpen() } }, - onLongClick = { onRemove() }, + onLongClick = { onToggleSelection() }, ), text = { val name = remember(download, context) { download.getDisplayName(context).orEmpty() } @@ -100,21 +116,35 @@ fun DownloadItem( ) }, icon = { - val uri = remember(files) { - files.find { it.type == DownloadFileType.IMAGE_PRIMARY }?.uri - } + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedVisibility( + visible = selectionMode, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + ) { + Checkbox( + checked = isSelected, + onCheckedChange = null, + modifier = Modifier.padding(end = 8.dp) + ) + } - AsyncImage( - model = uri, - placeholder = painterResource(R.drawable.ic_local_movies_white_64), - fallback = painterResource(R.drawable.ic_local_movies_white_64), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.size( - width = 64.dp, - height = 64.dp, + val uri = remember(files) { + files.find { it.type == DownloadFileType.IMAGE_PRIMARY }?.uri + } + + AsyncImage( + model = uri, + placeholder = painterResource(R.drawable.ic_local_movies_white_64), + fallback = painterResource(R.drawable.ic_local_movies_white_64), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size( + width = 64.dp, + height = 64.dp, + ) ) - ) + } }, secondaryText = { if (download.status == DownloadStatus.DOWNLOADING || download.status == DownloadStatus.QUEUED) { diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt index f5b063b4..6755bb87 100644 --- a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsScreen.kt @@ -1,13 +1,25 @@ package org.jellyfin.mobile.ui.screens.downloads +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -16,10 +28,14 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -30,7 +46,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import org.jellyfin.mobile.R -import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.downloads.DownloadsViewModel @Composable @@ -39,16 +54,32 @@ fun DownloadsScreen( onBackPressed: () -> Unit = {}, ) { val downloads by viewModel.downloads.collectAsState() - var downloadToRemove by remember { mutableStateOf(null) } + val selection = remember { mutableStateSetOf() } + var showDeleteConfirm by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } - if (downloadToRemove != null) { + val selectionMode = selection.isNotEmpty() + + BackHandler(enabled = selectionMode) { + selection.clear() + } + + if (showDeleteConfirm) { + val downloadsToRemove = remember(selection, downloads) { + downloads + .map { it.download } + .filter { it.id in selection } + } DownloadRemoveDialog( - download = downloadToRemove!!, + downloads = downloadsToRemove, onConfirm = { keepLocalFiles -> - viewModel.removeDownload(downloadToRemove!!, deleteFiles = !keepLocalFiles) - downloadToRemove = null + downloadsToRemove.forEach { download -> + viewModel.removeDownload(download, deleteFiles = !keepLocalFiles) + } + selection.clear() + showDeleteConfirm = false }, - onDismiss = { downloadToRemove = null }, + onDismiss = { showDeleteConfirm = false }, ) } @@ -59,16 +90,103 @@ fun DownloadsScreen( topBar = { TopAppBar( title = { - Text(text = stringResource(R.string.downloads)) + AnimatedContent( + targetState = selectionMode, + transitionSpec = { + if (targetState) { + (slideInVertically { height -> height } + fadeIn()) togetherWith + (slideOutVertically { height -> -height } + fadeOut()) + } else { + (slideInVertically { height -> -height } + fadeIn()) togetherWith + (slideOutVertically { height -> height } + fadeOut()) + } + }, + label = "TitleAnimation", + ) { isSelectionMode -> + if (isSelectionMode) { + Text(text = stringResource(R.string.selected_count, selection.size)) + } else { + Text(text = stringResource(R.string.downloads)) + } + } }, navigationIcon = { - IconButton( - onClick = onBackPressed, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = null, - ) + Crossfade( + targetState = selectionMode, + label = "NavIconAnimation", + ) { isSelectionMode -> + if (isSelectionMode) { + IconButton( + onClick = { selection.clear() }, + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + ) + } + } else { + IconButton( + onClick = { onBackPressed() }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = null, + ) + } + } + } + }, + actions = { + Row { + AnimatedVisibility( + visible = selectionMode, + enter = fadeIn(), + exit = fadeOut(), + ) { + Row { + IconButton(onClick = { showDeleteConfirm = true }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null, + ) + } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + if (selection.size < downloads.size) { + DropdownMenuItem( + onClick = { + selection.addAll(downloads.map { it.download.id }) + showMenu = false + }, + ) { + Text(text = stringResource(R.string.select_all)) + } + } + + if (selection.isNotEmpty()) { + DropdownMenuItem( + onClick = { + selection.clear() + showMenu = false + }, + ) { + Text(text = stringResource(R.string.deselect_all)) + } + } + } + } + } + } } }, backgroundColor = Color.Transparent, @@ -84,7 +202,11 @@ fun DownloadsScreen( downloads = downloads, onOpen = { viewModel.openDownload(it) }, onDownload = { viewModel.download(it) }, - onRemove = { downloadToRemove = it }, + selection = selection, + onToggleSelection = { download -> + if (selection.contains(download.id)) selection.remove(download.id) + else selection.add(download.id) + }, contentPadding = innerPadding, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f0cf7d8..c51aac69 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,11 +160,20 @@ Cancel Download completed %1$d%% - Remove download + + Remove download + Remove downloads + Keep local file(s) - Do you want to remove "%1$s" from your downloads? + + Do you want to remove \"%2$s\" from your downloads? + Do you want to remove these %1$d downloads? + Download incomplete Choose download location Please choose where downloaded media should be saved. Downloads use WiFi only by default. Select folder + Select all + Deselect all + %1$d selected