diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 297fe865f..b8446e780 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,8 +165,6 @@ the latest Android toolkit: - [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for user preferences storage. - [Coil](https://github.com/coil-kt/coil) for asynchronous loading of images. -- [Accompanist](https://github.com/google/accompanist) for the handling of user permissions in - Jetpack Compose. - [Vico](https://github.com/patrykandpatrick/vico) for charts. - [Lottie Compose](https://github.com/airbnb/lottie-android) for animations. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60f168dc2..786fb164b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -174,9 +174,6 @@ dependencies { implementation(libs.androidx.datastore.core.android) implementation(libs.androidx.datastore.preferences) - // Google accompanist to handle permissions in jetpack compose - implementation(libs.accompanist.permissions) - // Lottie animations for jetpack compose implementation(libs.lottie.compose) diff --git a/app/src/main/java/org/librefit/ui/screens/home/HomeScreen.kt b/app/src/main/java/org/librefit/ui/screens/home/HomeScreen.kt index 407895a5d..f9a17404d 100644 --- a/app/src/main/java/org/librefit/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/home/HomeScreen.kt @@ -9,6 +9,7 @@ package org.librefit.ui.screens.home import android.Manifest +import android.content.pm.PackageManager import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope @@ -38,16 +39,19 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString @@ -56,13 +60,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import kotlinx.collections.immutable.persistentListOf import org.librefit.R import org.librefit.enums.InfoMode @@ -81,27 +86,49 @@ import org.librefit.ui.theme.LibreFitTheme import org.librefit.util.Formatter import kotlin.random.Random -@OptIn(ExperimentalPermissionsApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SharedTransitionScope.HomeScreen( navController: NavHostController, animatedVisibilityScope: AnimatedVisibilityScope, viewModel: HomeScreenViewModel = hiltViewModel(), ) { + val context = LocalContext.current + var hasNotificationPermission by remember { + mutableStateOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + ) + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + hasNotificationPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } val showKeepAndroidOpen by viewModel.showKeepAndroidOpen.collectAsStateWithLifecycle() val requestPermissionNextTime by viewModel.requestPermissionNextTime.collectAsStateWithLifecycle() - val notificationPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - rememberPermissionState( - Manifest.permission.POST_NOTIFICATIONS - ) - } else { - //Permission granted by default below Tiramisu - null - } - val routines by viewModel.routines.collectAsStateWithLifecycle() val runningWorkout by viewModel.runningWorkout.collectAsStateWithLifecycle() @@ -115,8 +142,6 @@ fun SharedTransitionScope.HomeScreen( showKeepAndroidOpen = showKeepAndroidOpen, onKeepAndroidOpenCheckboxChange = viewModel::saveKeepOpenAndroidCheckbox, navigateToRoutine = { workoutId -> - val hasNotificationPermission = notificationPermissionState?.status?.isGranted != false - val requestPermission = !hasNotificationPermission && requestPermissionNextTime if (requestPermission) { @@ -470,4 +495,4 @@ fun HomeScreenPreview() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/librefit/ui/screens/shared/RequestPermissionScreen.kt b/app/src/main/java/org/librefit/ui/screens/shared/RequestPermissionScreen.kt index b12a54659..b0904ded3 100644 --- a/app/src/main/java/org/librefit/ui/screens/shared/RequestPermissionScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/shared/RequestPermissionScreen.kt @@ -9,7 +9,13 @@ package org.librefit.ui.screens.shared import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,30 +33,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import org.librefit.R import org.librefit.enums.userPreferences.ThemeMode import org.librefit.nav.Route import org.librefit.ui.components.LibreFitLazyColumn import org.librefit.ui.components.LibreFitScaffold import org.librefit.ui.components.animations.PreferencesLottie +import org.librefit.ui.components.dialogs.ConfirmDialog import org.librefit.ui.theme.LibreFitTheme import kotlin.random.Random - -@OptIn(ExperimentalPermissionsApi::class) @Composable fun RequestPermissionScreen( navController: NavHostController, @@ -58,20 +69,108 @@ fun RequestPermissionScreen( requestPermissionNextTime: Boolean, saveRequestPermissionAgainPreference: (Boolean) -> Unit ) { - val notificationPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - rememberPermissionState( - Manifest.permission.POST_NOTIFICATIONS + val context = LocalContext.current + + var hasNotificationPermission by remember { + mutableStateOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + ) + } + + var showSettingsDialog by remember { mutableStateOf(false) } + + if (showSettingsDialog) { + ConfirmDialog( + title = stringResource(R.string.notifications_permission), + text = stringResource(R.string.notifications_permission_permanently_denied_desc), + confirmText = stringResource(R.string.open_settings), + onConfirm = { + showSettingsDialog = false + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + // This is the standard way to target app settings + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + }, + onDismiss = { + showSettingsDialog = false + } ) - } else { - //Permission granted by default below Tiramisu - null + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasNotificationPermission = isGranted + if (!isGranted) { + val activity = context as? Activity + if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val showRationale = ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.POST_NOTIFICATIONS + ) + if (!showRationale) { + // If it's denied and shouldShowRequestPermissionRationale is false, + // it means the user checked "Don't ask again" or it is permanently blocked. + showSettingsDialog = true + } + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + hasNotificationPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val handleNotificationPermissionRequest = { + val activity = context as? Activity + if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val showRationale = ActivityCompat.shouldShowRequestPermissionRationale( + activity, + Manifest.permission.POST_NOTIFICATIONS + ) + val checkPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) + + if (checkPermission == PackageManager.PERMISSION_DENIED && !showRationale) { + // We are in a permanently denied state (or pre-request state) + // To be safe, we attempt to launch the launcher. If the launcher returns + // immediately with false, we then trigger the Settings Redirect Dialog. + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } } RequestPermissionsScreenContent( navController = navController, requestPermissionNextTime = requestPermissionNextTime, - hasNotificationPermission = notificationPermissionState?.status?.isGranted != false, - launchNotificationPermissionRequest = { notificationPermissionState?.launchPermissionRequest() }, + hasNotificationPermission = hasNotificationPermission, + handleNotificationPermissionRequest = handleNotificationPermissionRequest, saveRequestPermissionAgainPreference = saveRequestPermissionAgainPreference, navigateToWorkoutScreen = { navController.navigate(Route.WorkoutScreen(workoutId = workoutId)) { @@ -82,13 +181,13 @@ fun RequestPermissionScreen( ) } -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun RequestPermissionsScreenContent( navController: NavHostController, requestPermissionNextTime: Boolean, hasNotificationPermission: Boolean, - launchNotificationPermissionRequest: () -> Unit, + handleNotificationPermissionRequest: () -> Unit, saveRequestPermissionAgainPreference: (Boolean) -> Unit, navigateToWorkoutScreen: () -> Unit ) { @@ -147,7 +246,7 @@ private fun RequestPermissionsScreenContent( modifier = Modifier .padding(start = 10.dp), checked = hasNotificationPermission, - onCheckedChange = { launchNotificationPermissionRequest() } + onCheckedChange = { handleNotificationPermissionRequest() } ) } } @@ -235,7 +334,6 @@ private fun RequestPermissionsScreenContent( } } -@OptIn(ExperimentalPermissionsApi::class) @Preview @Composable private fun RequestPermissionsScreenPreview() { @@ -245,7 +343,7 @@ private fun RequestPermissionsScreenPreview() { navController = rememberNavController(), requestPermissionNextTime = Random.nextBoolean(), hasNotificationPermission = hasNotificationPermission.value, - launchNotificationPermissionRequest = { + handleNotificationPermissionRequest = { hasNotificationPermission.value = !hasNotificationPermission.value }, saveRequestPermissionAgainPreference = {}, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf8caef5c..2742f277e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -254,6 +254,8 @@ Don\'t ask again Skip for now Continue + Notifications are blocked. Please enable them in system settings to receive workout updates. + Open Settings diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 55800d96d..08a19be94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,6 @@ composeBom = "2026.06.01" roomVersion = "2.8.4" appcompat = "1.7.1" ksp = "2.3.4" -accompanistPermissions = "0.37.3" lottieCompose = "6.7.1" coreSplashscreen = "1.2.0" composeM3 = "3.2.3" @@ -48,7 +47,6 @@ navigation-compose = { module = "androidx.navigation:navigation-compose", versio kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" } androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-core-android", version.ref = "datastore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "composeM3" }