From b226eaeba237686ff537b800448508d4ed2eacb1 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 23 May 2026 18:45:27 +0000 Subject: [PATCH] feat(downloads): update downloaded item handling --- .../download/components/DownloadsContent.kt | 21 ++- .../purefin/ui/screen/series/SeriesScreen.kt | 102 ++++++++-- .../series/components/SeriesComponents.kt | 174 ++++++++++++------ .../core/feature/downloads/DownloadedItem.kt | 7 + .../feature/downloads/DownloadsViewModel.kt | 19 +- gradle.properties | 2 +- 6 files changed, 239 insertions(+), 86 deletions(-) create mode 100644 core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadedItem.kt diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt index dd7b20e7..36c7978f 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/download/components/DownloadsContent.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.ui.common.card.PosterCard import hu.bbara.purefin.core.feature.downloads.DownloadsViewModel +import hu.bbara.purefin.ui.common.card.PosterCard @Composable fun DownloadsContent( @@ -101,14 +101,17 @@ fun DownloadsContent( } } } - items(downloads.value, key = { item -> item.id }) { item -> - PosterCard( - item = item, - onMovieSelected = viewModel::onMovieSelected, - onSeriesSelected = viewModel::onSeriesSelected, - onEpisodeSelected = { _, _, _ -> }, - modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.id}") - ) + items(downloads.value, key = { item -> item.media.id }) { item -> + Column( + modifier = Modifier.testTag("$DownloadsItemTagPrefix${item.media.id}") + ) { + PosterCard( + item = item.media, + onMovieSelected = viewModel::onMovieSelected, + onSeriesSelected = viewModel::onSeriesSelected, + onEpisodeSelected = { _, _, _ -> }, + ) + } } } } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt index 9b92e3b9..f668b565 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt @@ -1,5 +1,9 @@ package hu.bbara.purefin.ui.screen.series +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme @@ -50,30 +54,94 @@ fun SeriesScreen( } val seriesState = viewModel.series.collectAsStateWithLifecycle() + var pendingDownloadOption by remember { mutableStateOf?>(null) } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { _ -> + // Proceed with download regardless — notification is nice-to-have + val pendingOption = pendingDownloadOption + pendingDownloadOption = null + val (option, selectedSeason) = pendingOption ?: return@rememberLauncherForActivityResult + val seriesData = seriesState.value ?: return@rememberLauncherForActivityResult + val canPerformOption = when (option) { + SeriesDownloadOption.SEASON -> viewModel.seasonDownloadState.value is DownloadState.NotDownloaded + SeriesDownloadOption.SERIES -> viewModel.seriesDownloadState.value is DownloadState.NotDownloaded + SeriesDownloadOption.SMART -> !viewModel.isSmartDownloadEnabled.value + SeriesDownloadOption.DELETE_SMART -> true + } + if (!canPerformOption) return@rememberLauncherForActivityResult + when (option) { + SeriesDownloadOption.SEASON -> + viewModel.downloadSeason(seriesData.id, selectedSeason.id) + + SeriesDownloadOption.SERIES -> + viewModel.downloadSeries(seriesData) + + SeriesDownloadOption.SMART -> + viewModel.enableSmartDownload(seriesData.id) + + SeriesDownloadOption.DELETE_SMART -> + viewModel.deleteSmartDownloads(seriesData.id) + } + } val seriesData = seriesState.value if (seriesData != null && seriesData.seasons.isNotEmpty()) { LaunchedEffect(seriesData) { viewModel.observeSeriesDownloadState(seriesData) } + val seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value + val seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value + val isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value + + fun canPerformDownloadOption(option: SeriesDownloadOption): Boolean = when (option) { + SeriesDownloadOption.SEASON -> seasonDownloadState is DownloadState.NotDownloaded + SeriesDownloadOption.SERIES -> seriesDownloadState is DownloadState.NotDownloaded + SeriesDownloadOption.SMART -> !isSmartDownloadEnabled + SeriesDownloadOption.DELETE_SMART -> true + } + + fun performDownloadOption(option: SeriesDownloadOption, selectedSeason: Season) { + if (!canPerformDownloadOption(option)) return + when (option) { + SeriesDownloadOption.SEASON -> + viewModel.downloadSeason(seriesData.id, selectedSeason.id) + + SeriesDownloadOption.SERIES -> + viewModel.downloadSeries(seriesData) + + SeriesDownloadOption.SMART -> + viewModel.enableSmartDownload(seriesData.id) + + SeriesDownloadOption.DELETE_SMART -> + viewModel.deleteSmartDownloads(seriesData.id) + } + } + + fun shouldRequestNotificationPermission(option: SeriesDownloadOption): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false + return when (option) { + SeriesDownloadOption.SEASON, + SeriesDownloadOption.SERIES, + SeriesDownloadOption.SMART -> canPerformDownloadOption(option) + SeriesDownloadOption.DELETE_SMART -> false + } + } + SeriesScreenInternal( series = seriesData, - seriesDownloadState = viewModel.seriesDownloadState.collectAsStateWithLifecycle().value, - seasonDownloadState = viewModel.seasonDownloadState.collectAsStateWithLifecycle().value, - isSmartDownloadEnabled = viewModel.isSmartDownloadEnabled.collectAsStateWithLifecycle().value, + seriesDownloadState = seriesDownloadState, + seasonDownloadState = seasonDownloadState, + isSmartDownloadEnabled = isSmartDownloadEnabled, onDownloadOptionSelected = { option, selectedSeason -> - when (option) { - SeriesDownloadOption.SEASON -> - viewModel.downloadSeason(seriesData.id, selectedSeason.id) - - SeriesDownloadOption.SERIES -> - viewModel.downloadSeries(seriesData) - - SeriesDownloadOption.SMART -> - viewModel.enableSmartDownload(seriesData.id) - - SeriesDownloadOption.DELETE_SMART -> - viewModel.deleteSmartDownloads(seriesData.id) + if (canPerformDownloadOption(option)) { + if (shouldRequestNotificationPermission(option)) { + pendingDownloadOption = option to selectedSeason + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + performDownloadOption(option, selectedSeason) + } } }, onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes, @@ -132,8 +200,8 @@ private fun SeriesScreenInternal( ) { SeriesActionButtons( nextUpEpisode = nextUpEpisode, - seriesDownloadState = seriesDownloadState, selectedSeason = selectedSeason, + seriesDownloadState = seriesDownloadState, seasonDownloadState = seasonDownloadState, isSmartDownloadEnabled = isSmartDownloadEnabled, offline = offline, @@ -192,7 +260,7 @@ private fun SeriesScreenPreview() { AppTheme { SeriesScreenInternal( series = previewSeries(), - seriesDownloadState = DownloadState.Downloading(progressPercent = 0.58f), + seriesDownloadState = DownloadState.NotDownloaded, seasonDownloadState = DownloadState.NotDownloaded, isSmartDownloadEnabled = true, onDownloadOptionSelected = { _, _ -> }, diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt index 6e26e4c1..7e48a988 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt @@ -26,18 +26,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.DownloadDone import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.PlayCircle -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -48,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -111,8 +113,8 @@ internal fun SeriesMetaChips(series: Series) { @Composable internal fun SeriesActionButtons( nextUpEpisode: Episode?, - seriesDownloadState: DownloadState, selectedSeason: Season, + seriesDownloadState: DownloadState, seasonDownloadState: DownloadState, isSmartDownloadEnabled: Boolean, offline: Boolean, @@ -121,7 +123,6 @@ internal fun SeriesActionButtons( ) { val context = LocalContext.current val navigationManager = LocalNavigationManager.current - val scheme = MaterialTheme.colorScheme var showDownloadDialog by remember { mutableStateOf(false) } val playAction = remember(nextUpEpisode, offline) { nextUpEpisode?.let { episode -> @@ -163,11 +164,7 @@ internal fun SeriesActionButtons( MediaActionButton( backgroundColor = MaterialTheme.colorScheme.surface, iconColor = MaterialTheme.colorScheme.onSurface, - icon = when { - seriesDownloadState is DownloadState.Downloading -> Icons.Outlined.Close - seriesDownloadState is DownloadState.Downloaded -> Icons.Outlined.DownloadDone - else -> Icons.Outlined.Download - }, + icon = Icons.Outlined.Download, height = 48.dp, modifier = Modifier.testTag(SeriesDownloadButtonTag), onClick = { showDownloadDialog = true } @@ -175,8 +172,9 @@ internal fun SeriesActionButtons( } if (showDownloadDialog) { - DownloadOptionsDialog( + DownloadOptionsBottomSheet( selectedSeasonName = selectedSeason.name, + seriesDownloadState = seriesDownloadState, seasonDownloadState = seasonDownloadState, isSmartDownloadEnabled = isSmartDownloadEnabled, onDownloadOptionSelected = { @@ -195,68 +193,136 @@ internal enum class SeriesDownloadOption { DELETE_SMART } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DownloadOptionsDialog( +private fun DownloadOptionsBottomSheet( selectedSeasonName: String, + seriesDownloadState: DownloadState, seasonDownloadState: DownloadState, isSmartDownloadEnabled: Boolean, onDownloadOptionSelected: (SeriesDownloadOption) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + ModalBottomSheet( onDismissRequest = onDismiss, modifier = Modifier.testTag(SeriesDownloadDialogTag), - title = { Text("Download") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Download options", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) Text( text = "Choose how to download this series.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - }, - confirmButton = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton( - onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) }, - modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag) - ) { - Text( - when (seasonDownloadState) { - is DownloadState.Downloaded -> "$selectedSeasonName Downloaded" - is DownloadState.Downloading -> "Downloading $selectedSeasonName" - else -> "Download $selectedSeasonName" - } - ) - } - TextButton( - onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) }, - modifier = Modifier.testTag(SeriesDownloadAllButtonTag) - ) { - Text("Download All") - } - } - }, - dismissButton = { + + DownloadOptionRow( + title = "Download selected season", + supportingText = when (seasonDownloadState) { + is DownloadState.Downloaded -> "$selectedSeasonName is already downloaded." + is DownloadState.Downloading -> "$selectedSeasonName is downloading." + else -> "Save episodes from $selectedSeasonName for offline viewing." + }, + icon = Icons.Outlined.Download, + onClick = { onDownloadOptionSelected(SeriesDownloadOption.SEASON) }, + enabled = seasonDownloadState is DownloadState.NotDownloaded, + modifier = Modifier.testTag(SeriesDownloadSeasonButtonTag) + ) + DownloadOptionRow( + title = "Download all episodes", + supportingText = when (seriesDownloadState) { + is DownloadState.Downloaded -> "All episodes in this series are already downloaded." + is DownloadState.Downloading -> "All episodes in this series are downloading." + else -> "Save every available episode in this series." + }, + icon = Icons.Outlined.Download, + onClick = { onDownloadOptionSelected(SeriesDownloadOption.SERIES) }, + enabled = seriesDownloadState is DownloadState.NotDownloaded, + modifier = Modifier.testTag(SeriesDownloadAllButtonTag) + ) if (isSmartDownloadEnabled) { - TextButton( + DownloadOptionRow( + title = "Delete series downloads", + supportingText = "Turns off Smart Downloads and removes offline episodes for this series.", + icon = Icons.Outlined.Delete, onClick = { onDownloadOptionSelected(SeriesDownloadOption.DELETE_SMART) }, modifier = Modifier.testTag(SeriesSmartDownloadButtonTag), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text("Delete Smart Downloads") - } + destructive = true + ) } else { - TextButton( + DownloadOptionRow( + title = "Smart Downloads", + supportingText = "Automatically keep the next unwatched episodes available offline.", + icon = Icons.Outlined.AutoAwesome, onClick = { onDownloadOptionSelected(SeriesDownloadOption.SMART) }, modifier = Modifier.testTag(SeriesSmartDownloadButtonTag) - ) { - Text("Smart Download") - } + ) } } + } +} + +@Composable +private fun DownloadOptionRow( + title: String, + supportingText: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + destructive: Boolean = false, + enabled: Boolean = true, +) { + val contentColor = if (!enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else if (destructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + val supportingColor = if (!enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else if (destructive) { + MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + ListItem( + headlineContent = { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = contentColor + ) + }, + supportingContent = { + Text( + text = supportingText, + style = MaterialTheme.typography.bodyMedium, + color = supportingColor + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = modifier.clickable(enabled = enabled, onClick = onClick) ) } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadedItem.kt b/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadedItem.kt new file mode 100644 index 00000000..216b0fca --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadedItem.kt @@ -0,0 +1,7 @@ +package hu.bbara.purefin.core.feature.downloads + +import hu.bbara.purefin.core.model.MediaUiModel + +data class DownloadedItem( + val media: MediaUiModel, +) diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadsViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadsViewModel.kt index 38012b41..6d1c9f4e 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadsViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/downloads/DownloadsViewModel.kt @@ -48,16 +48,25 @@ class DownloadsViewModel @Inject constructor( private val activeDownloadsMap = downloadManager.observeActiveDownloads() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) - /** Items that are fully downloaded and not currently in progress. */ + /** Offline items with saved content available, excluding items that are only currently in progress. */ val downloads = combine( offlineCatalogReader.movies, offlineCatalogReader.series, - activeDownloadsMap - ) { movies, series, inProgress -> + offlineCatalogReader.episodes, + activeDownloadsMap, + ) { movies, series, episodes, inProgress -> movies.values .filter { it.id.toString() !in inProgress } - .map { MovieUiModel(it) } + - series.values.map { SeriesUiModel(it) } + .map { DownloadedItem(media = MovieUiModel(it)) } + + series.values.filter { series -> + episodes.values.any { episode -> + episode.seriesId == series.id && episode.id.toString() !in inProgress + } + }.map { + DownloadedItem( + media = SeriesUiModel(it), + ) + } } /** Items currently being downloaded with their progress. */ diff --git a/gradle.properties b/gradle.properties index 6fb79afb..69e81ed7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,5 +29,5 @@ android.r8.strictFullModeForKeepRules=false android.builtInKotlin=false android.newDsl=false purefinReleaseVersionCode=1000018 -purefinDebugVersionCode=1000000 +purefinDebugVersionCode=1000001 purefinTvVersionCode=2000009