refactor(download): share download UI handling

This commit is contained in:
2026-06-05 15:29:06 +00:00
parent 85c53f8704
commit 88e8b3a41f
7 changed files with 117 additions and 115 deletions

View File

@@ -0,0 +1,33 @@
package hu.bbara.purefin.ui.common.permission
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
fun rememberNotificationPermissionGate(): (action: () -> Unit) -> Unit {
val pendingAction = remember { mutableStateOf<(() -> Unit)?>(null) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) {
// Proceed regardless — notification permission is nice-to-have for downloads.
val action = pendingAction.value
pendingAction.value = null
action?.invoke()
}
return remember(launcher) {
{ action ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pendingAction.value = action
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
action()
}
}
}
}

View File

@@ -1,20 +1,16 @@
package hu.bbara.purefin.ui.screen.episode
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind
@@ -26,6 +22,7 @@ import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
@@ -48,19 +45,13 @@ fun EpisodeScreen(
val episode = viewModel.episode.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val requestNotificationPermission = rememberNotificationPermissionGate()
val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& downloadState.value is DownloadState.NotDownloaded
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
if (downloadState.value is DownloadState.NotDownloaded) {
requestNotificationPermission {
viewModel.onDownloadClick()
}
} else {
viewModel.onDownloadClick()
}

View File

@@ -10,9 +10,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
@@ -32,8 +29,8 @@ import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.DownloadActionButton
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
@@ -137,16 +134,8 @@ internal fun EpisodeDetails(
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = 48.dp,
DownloadActionButton(
downloadState = downloadState,
modifier = Modifier.testTag(EpisodeDownloadButtonTag),
onClick = onDownloadClick
)

View File

@@ -1,19 +1,15 @@
package hu.bbara.purefin.ui.screen.movie
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind
@@ -23,6 +19,7 @@ import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -41,19 +38,13 @@ fun MovieScreen(
val movieItem = viewModel.movie.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val requestNotificationPermission = rememberNotificationPermissionGate()
val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& downloadState.value is DownloadState.NotDownloaded
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
if (downloadState.value is DownloadState.NotDownloaded) {
requestNotificationPermission {
viewModel.onDownloadClick()
}
} else {
viewModel.onDownloadClick()
}

View File

@@ -10,9 +10,6 @@ import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -28,8 +25,8 @@ import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.DownloadActionButton
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
@@ -98,16 +95,8 @@ internal fun MovieDetails(
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = 48.dp,
DownloadActionButton(
downloadState = downloadState,
modifier = Modifier.testTag(MovieDownloadButtonTag),
onClick = onDownloadClick
)

View File

@@ -1,9 +1,5 @@
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
@@ -32,6 +28,7 @@ import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.series.components.CastRow
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
@@ -54,37 +51,7 @@ fun SeriesScreen(
}
val seriesState = viewModel.series.collectAsStateWithLifecycle()
var pendingDownloadOption by remember { mutableStateOf<Pair<SeriesDownloadOption, Season>?>(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 requestNotificationPermission = rememberNotificationPermissionGate()
val seriesData = seriesState.value
if (seriesData != null) {
@@ -95,15 +62,14 @@ fun SeriesScreen(
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 canPerformDownloadOption(option: SeriesDownloadOption): Boolean =
option.canPerform(
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled
)
fun performDownloadOption(option: SeriesDownloadOption, selectedSeason: Season) {
if (!canPerformDownloadOption(option)) return
when (option) {
SeriesDownloadOption.SEASON ->
viewModel.downloadSeason(seriesData.id, selectedSeason.id)
@@ -119,16 +85,6 @@ fun SeriesScreen(
}
}
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 = seriesDownloadState,
@@ -136,9 +92,10 @@ fun SeriesScreen(
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = { option, selectedSeason ->
if (canPerformDownloadOption(option)) {
if (shouldRequestNotificationPermission(option)) {
pendingDownloadOption = option to selectedSeason
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
if (option.requiresNotificationPermission()) {
requestNotificationPermission {
performDownloadOption(option, selectedSeason)
}
} else {
performDownloadOption(option, selectedSeason)
}
@@ -264,6 +221,24 @@ private fun SeriesHeroContent(
SeriesMetaChips(series = series)
}
private fun SeriesDownloadOption.canPerform(
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
): Boolean = when (this) {
SeriesDownloadOption.SEASON -> seasonDownloadState is DownloadState.NotDownloaded
SeriesDownloadOption.SERIES -> seriesDownloadState is DownloadState.NotDownloaded
SeriesDownloadOption.SMART -> !isSmartDownloadEnabled
SeriesDownloadOption.DELETE_SMART -> true
}
private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = when (this) {
SeriesDownloadOption.SEASON,
SeriesDownloadOption.SERIES,
SeriesDownloadOption.SMART -> true
SeriesDownloadOption.DELETE_SMART -> false
}
@Preview(showBackground = true)
@Composable
private fun SeriesScreenPreview() {

View File

@@ -0,0 +1,34 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.DownloadDone
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.download.DownloadState
@Composable
fun DownloadActionButton(
downloadState: DownloadState,
onClick: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 48.dp,
) {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = when (downloadState) {
is DownloadState.NotDownloaded -> Icons.Outlined.Download
is DownloadState.Downloading -> Icons.Outlined.Close
is DownloadState.Downloaded -> Icons.Outlined.DownloadDone
is DownloadState.Failed -> Icons.Outlined.Download
},
height = height,
modifier = modifier,
onClick = onClick
)
}