diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/permission/NotificationPermissionGate.kt b/app/src/main/java/hu/bbara/purefin/ui/common/permission/NotificationPermissionGate.kt new file mode 100644 index 00000000..60ad6ddf --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/ui/common/permission/NotificationPermissionGate.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt index 5aea79a5..0a886854 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt @@ -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() } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt index 7e2cf945..da4573db 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt @@ -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 ) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt index b579aced..119f17dc 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt @@ -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() } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt index 15d05c4d..e85ede35 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt @@ -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 ) 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 7396c8fa..f02e47f9 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,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?>(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() { diff --git a/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/DownloadActionButton.kt b/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/DownloadActionButton.kt new file mode 100644 index 00000000..0cfdea86 --- /dev/null +++ b/core-ui/src/main/java/hu/bbara/purefin/ui/common/button/DownloadActionButton.kt @@ -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 + ) +}