Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 0 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
57 changes: 41 additions & 16 deletions app/src/main/java/org/librefit/ui/screens/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -470,4 +495,4 @@ fun HomeScreenPreview() {
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,51 +33,144 @@ 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,
workoutId: Long,
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)) {
Expand All @@ -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
) {
Expand Down Expand Up @@ -147,7 +246,7 @@ private fun RequestPermissionsScreenContent(
modifier = Modifier
.padding(start = 10.dp),
checked = hasNotificationPermission,
onCheckedChange = { launchNotificationPermissionRequest() }
onCheckedChange = { handleNotificationPermissionRequest() }
)
}
}
Expand Down Expand Up @@ -235,7 +334,6 @@ private fun RequestPermissionsScreenContent(
}
}

@OptIn(ExperimentalPermissionsApi::class)
@Preview
@Composable
private fun RequestPermissionsScreenPreview() {
Expand All @@ -245,7 +343,7 @@ private fun RequestPermissionsScreenPreview() {
navController = rememberNavController(),
requestPermissionNextTime = Random.nextBoolean(),
hasNotificationPermission = hasNotificationPermission.value,
launchNotificationPermissionRequest = {
handleNotificationPermissionRequest = {
hasNotificationPermission.value = !hasNotificationPermission.value
},
saveRequestPermissionAgainPreference = {},
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@
<string name="dont_ask_again">Don\'t ask again</string>
<string name="skip_for_now">Skip for now</string>
<string name="label_continue">Continue</string>
<string name="notifications_permission_permanently_denied_desc">Notifications are blocked. Please enable them in system settings to receive workout updates.</string>
<string name="open_settings">Open Settings</string>


<!--Profile screen-->
Expand Down
2 changes: 0 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
Loading