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 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.core.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind 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.navigation.LocalNavigationBackStack
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow 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.EpisodeDetails
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
@@ -48,19 +45,13 @@ fun EpisodeScreen(
val episode = viewModel.episode.collectAsStateWithLifecycle() val episode = viewModel.episode.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle() val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val requestNotificationPermission = rememberNotificationPermissionGate()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val onDownloadClick = { val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU if (downloadState.value is DownloadState.NotDownloaded) {
&& downloadState.value is DownloadState.NotDownloaded requestNotificationPermission {
) { viewModel.onDownloadClick()
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
} else { } else {
viewModel.onDownloadClick() 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.Icons
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast 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.material.icons.outlined.MoreVert
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme 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.core.download.DownloadState
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.player.PlayerActivity 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.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis import hu.bbara.purefin.ui.common.media.MediaSynopsis
@@ -137,16 +134,8 @@ internal fun EpisodeDetails(
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) )
Row() { Row() {
MediaActionButton( DownloadActionButton(
backgroundColor = MaterialTheme.colorScheme.surface, downloadState = downloadState,
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,
modifier = Modifier.testTag(EpisodeDownloadButtonTag), modifier = Modifier.testTag(EpisodeDownloadButtonTag),
onClick = onDownloadClick onClick = onDownloadClick
) )

View File

@@ -1,19 +1,15 @@
package hu.bbara.purefin.ui.screen.movie 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel import hu.bbara.purefin.core.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.core.image.ArtworkKind 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.model.Movie
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow 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.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -41,19 +38,13 @@ fun MovieScreen(
val movieItem = viewModel.movie.collectAsStateWithLifecycle() val movieItem = viewModel.movie.collectAsStateWithLifecycle()
val downloadState = viewModel.downloadState.collectAsStateWithLifecycle() val downloadState = viewModel.downloadState.collectAsStateWithLifecycle()
val requestNotificationPermission = rememberNotificationPermissionGate()
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
// Proceed with download regardless — notification is nice-to-have
viewModel.onDownloadClick()
}
val onDownloadClick = { val onDownloadClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU if (downloadState.value is DownloadState.NotDownloaded) {
&& downloadState.value is DownloadState.NotDownloaded requestNotificationPermission {
) { viewModel.onDownloadClick()
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
} else { } else {
viewModel.onDownloadClick() 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.Icons
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast 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.material.icons.outlined.MoreVert
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.core.download.DownloadState
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.player.PlayerActivity 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.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis import hu.bbara.purefin.ui.common.media.MediaSynopsis
@@ -98,16 +95,8 @@ internal fun MovieDetails(
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) )
Row() { Row() {
MediaActionButton( DownloadActionButton(
backgroundColor = MaterialTheme.colorScheme.surface, downloadState = downloadState,
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,
modifier = Modifier.testTag(MovieDownloadButtonTag), modifier = Modifier.testTag(MovieDownloadButtonTag),
onClick = onDownloadClick onClick = onDownloadClick
) )

View File

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