Merge pull request #7299 from thunderbird/permission_screen

Add onboarding screen to ask for Android permissions
This commit is contained in:
cketti 2023-11-08 10:03:08 -05:00 committed by GitHub
commit eb7dcf2875
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 1087 additions and 24 deletions

View file

@ -60,7 +60,7 @@ dependencies {
implementation(projects.core.common)
implementation(projects.mail.common)
implementation(projects.feature.onboarding)
implementation(projects.feature.onboarding.main)
implementation(projects.feature.account.setup)
implementation(projects.feature.account.edit)

View file

@ -6,6 +6,7 @@ import app.k9mail.feature.account.edit.AccountEditExternalContract
import app.k9mail.feature.account.edit.featureAccountEditModule
import app.k9mail.feature.account.setup.AccountSetupExternalContract
import app.k9mail.feature.account.setup.featureAccountSetupModule
import app.k9mail.feature.onboarding.main.featureOnboardingModule
import app.k9mail.feature.preview.account.AccountOwnerNameProvider
import app.k9mail.feature.preview.account.InMemoryAccountStore
import app.k9mail.feature.preview.auth.AndroidKeyStoreDirectoryProvider
@ -18,6 +19,7 @@ import com.fsck.k9.mail.ssl.LocalKeyStore
import com.fsck.k9.mail.ssl.TrustManagerFactory
import com.fsck.k9.mail.ssl.TrustedSocketFactory
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.binds
import org.koin.dsl.module
@ -41,9 +43,12 @@ val featureModule: Module = module {
single { TrustManagerFactory.createInstance(get()) }
single<TrustedSocketFactory> { DefaultTrustedSocketFactory(get(), get()) }
single<OAuth2TokenProviderFactory> { RealOAuth2TokenProviderFactory(context = get()) }
single(named("ClientIdAppName")) { "App Name" }
single(named("ClientIdAppVersion")) { "App Version" }
includes(
accountModule,
featureOnboardingModule,
featureAccountSetupModule,
featureAccountEditModule,
)

View file

@ -7,10 +7,9 @@ import androidx.navigation.compose.NavHost
import app.k9mail.feature.account.edit.navigation.accountEditRoute
import app.k9mail.feature.account.edit.navigation.navigateToAccountEditIncomingServerSettings
import app.k9mail.feature.account.setup.navigation.accountSetupRoute
import app.k9mail.feature.account.setup.navigation.navigateToAccountSetup
import app.k9mail.feature.onboarding.navigation.NAVIGATION_ROUTE_ONBOARDING
import app.k9mail.feature.onboarding.navigation.navigateToOnboarding
import app.k9mail.feature.onboarding.navigation.onboardingRoute
import app.k9mail.feature.onboarding.main.navigation.NAVIGATION_ROUTE_ONBOARDING
import app.k9mail.feature.onboarding.main.navigation.navigateToOnboarding
import app.k9mail.feature.onboarding.main.navigation.onboardingRoute
@Composable
fun FeatureNavHost(
@ -24,8 +23,11 @@ fun FeatureNavHost(
modifier = modifier,
) {
onboardingRoute(
onStart = { navController.navigateToAccountSetup() },
onImport = { /* TODO */ },
onBack = navController::popBackStack,
onFinish = { accountUuid ->
navController.navigateToAccountEditIncomingServerSettings(accountUuid)
},
)
accountSetupRoute(
onBack = navController::popBackStack,

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
android {
namespace = "app.k9mail.core.android.permissions"
}
dependencies {
testImplementation(libs.androidx.test.core)
testImplementation(libs.robolectric)
testImplementation(libs.assertk)
}

View file

@ -0,0 +1,38 @@
package app.k9mail.core.android.permissions
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
/**
* Checks if a [Permission] has been granted to the app.
*/
class AndroidPermissionChecker(
private val context: Context,
) : PermissionChecker {
override fun checkPermission(permission: Permission): PermissionState {
return when (permission) {
Permission.Contacts -> {
checkSelfPermission(Manifest.permission.READ_CONTACTS)
}
Permission.Notifications -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
PermissionState.GrantedImplicitly
}
}
}
}
private fun checkSelfPermission(permission: String): PermissionState {
return if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) {
PermissionState.Granted
} else {
PermissionState.Denied
}
}
}

View file

@ -0,0 +1,8 @@
package app.k9mail.core.android.permissions
import org.koin.core.module.Module
import org.koin.dsl.module
val corePermissionsAndroidModule: Module = module {
factory<PermissionChecker> { AndroidPermissionChecker(context = get()) }
}

View file

@ -0,0 +1,9 @@
package app.k9mail.core.android.permissions
/**
* System permissions we ask for during onboarding.
*/
enum class Permission {
Contacts,
Notifications,
}

View file

@ -0,0 +1,8 @@
package app.k9mail.core.android.permissions
/**
* Checks if a [Permission] has been granted to the app.
*/
interface PermissionChecker {
fun checkPermission(permission: Permission): PermissionState
}

View file

@ -0,0 +1,10 @@
package app.k9mail.core.android.permissions
enum class PermissionState {
/**
* The permission is not a runtime permission in the Android version we're running on.
*/
GrantedImplicitly,
Granted,
Denied,
}

View file

@ -0,0 +1,66 @@
package app.k9mail.core.android.permissions
import android.Manifest
import android.app.Application
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(minSdk = Build.VERSION_CODES.TIRAMISU)
class AndroidPermissionCheckerTest {
private val application: Application = ApplicationProvider.getApplicationContext()
private val shadowApplication = Shadows.shadowOf(application)
private val permissionChecker = AndroidPermissionChecker(application)
@Test
fun `granted READ_CONTACTS permission`() {
shadowApplication.grantPermissions(Manifest.permission.READ_CONTACTS)
val result = permissionChecker.checkPermission(Permission.Contacts)
assertThat(result).isEqualTo(PermissionState.Granted)
}
@Test
fun `denied READ_CONTACTS permission`() {
shadowApplication.denyPermissions(Manifest.permission.READ_CONTACTS)
val result = permissionChecker.checkPermission(Permission.Contacts)
assertThat(result).isEqualTo(PermissionState.Denied)
}
@Test
fun `granted POST_NOTIFICATIONS permission`() {
shadowApplication.grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
val result = permissionChecker.checkPermission(Permission.Notifications)
assertThat(result).isEqualTo(PermissionState.Granted)
}
@Test
fun `denied POST_NOTIFICATIONS permission`() {
shadowApplication.denyPermissions(Manifest.permission.POST_NOTIFICATIONS)
val result = permissionChecker.checkPermission(Permission.Notifications)
assertThat(result).isEqualTo(PermissionState.Denied)
}
@Test
@Config(minSdk = Build.VERSION_CODES.S_V2, maxSdk = Build.VERSION_CODES.S_V2)
fun `POST_NOTIFICATIONS permission not available`() {
val result = permissionChecker.checkPermission(Permission.Notifications)
assertThat(result).isEqualTo(PermissionState.GrantedImplicitly)
}
}

View file

@ -0,0 +1,18 @@
package app.k9mail.core.ui.compose.common.image
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
/**
* An image with a coordinate to draw a (smaller) overlay icon on top of it.
*
* Example: An icon representing an Android permission with an overlay icon to indicate whether the permission has been
* granted.
*/
@Immutable
data class ImageWithOverlayCoordinate(
val image: ImageVector,
val overlayOffsetX: Dp,
val overlayOffsetY: Dp,
)

View file

@ -0,0 +1,9 @@
package app.k9mail.core.ui.compose.common.visibility
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
/**
* Sets a composable to be fully transparent when it should be hidden (but still present for layout purposes).
*/
fun Modifier.hide(hide: Boolean) = alpha(if (hide) 0f else 1f)

View file

@ -0,0 +1,43 @@
package app.k9mail.core.ui.compose.designsystem.atom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import app.k9mail.core.ui.compose.common.visibility.hide
import app.k9mail.core.ui.compose.theme.MainTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val LOADING_INDICATOR_DELAY = 500L
/**
* Only show a [CircularProgressIndicator] after [LOADING_INDICATOR_DELAY] ms.
*
* Use this to avoid flashing a loading indicator for loads that are usually very fast.
*/
@Composable
fun DelayedCircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = MainTheme.colors.secondary,
) {
var progressIndicatorVisible by remember { mutableStateOf(false) }
LaunchedEffect(key1 = Unit) {
launch {
delay(LOADING_INDICATOR_DELAY)
progressIndicatorVisible = true
}
}
CircularProgressIndicator(
modifier = Modifier
.hide(!progressIndicatorVisible)
.then(modifier),
color = color,
)
}

View file

@ -1,6 +1,9 @@
package app.k9mail.core.ui.compose.theme
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.MoveToInbox
import androidx.compose.material.icons.filled.Notifications
@ -48,6 +51,12 @@ object Icons {
val user: ImageVector
get() = MaterialIcons.Filled.AccountCircle
val check: ImageVector
get() = MaterialIcons.Filled.CheckCircle
val cancel: ImageVector
get() = MaterialIcons.Filled.Cancel
}
object Outlined {

View file

@ -0,0 +1,27 @@
package app.k9mail.core.ui.compose.theme
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.common.image.ImageWithOverlayCoordinate
import androidx.compose.material.icons.Icons as MaterialIcons
// We're using "by lazy" so not all icons are loaded into memory as soon as the object is accessed. But once a property
// is accessed we want to retain the `ImageWithOverlayCoordinate` instance.
object IconsWithBottomRightOverlay {
val person: ImageWithOverlayCoordinate by lazy {
ImageWithOverlayCoordinate(
image = MaterialIcons.Filled.Person,
overlayOffsetX = 24.dp,
overlayOffsetY = 20.dp,
)
}
val notification: ImageWithOverlayCoordinate by lazy {
ImageWithOverlayCoordinate(
image = MaterialIcons.Filled.Notifications,
overlayOffsetX = 23.dp,
overlayOffsetY = 19.dp,
)
}
}

View file

@ -16,6 +16,7 @@ data class Sizes(
val huger: Dp = 384.dp,
val icon: Dp = 24.dp,
val largeIcon: Dp = 32.dp,
val topBarHeight: Dp = 56.dp,
val bottomBarHeight: Dp = 56.dp,

View file

@ -3,6 +3,7 @@ package app.k9mail.feature.account.setup.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import app.k9mail.core.ui.compose.common.navigation.deepLinkComposable
import app.k9mail.feature.account.setup.ui.AccountSetupScreen
@ -23,3 +24,16 @@ fun NavGraphBuilder.accountSetupRoute(
)
}
}
fun NavGraphBuilder.nestedAccountSetupRoute(
route: String,
onBack: () -> Unit,
onFinish: (String) -> Unit,
) {
composable(route) {
AccountSetupScreen(
onBack = onBack,
onFinish = onFinish,
)
}
}

View file

@ -19,7 +19,7 @@ android {
dependencies {
implementation(projects.core.ui.compose.designsystem)
implementation(projects.app.ui.base)
implementation(projects.feature.onboarding)
implementation(projects.feature.onboarding.main)
implementation(projects.feature.account.setup)
implementation(projects.feature.account.edit)

View file

@ -11,7 +11,7 @@ import app.k9mail.feature.account.edit.navigation.NAVIGATION_ROUTE_ACCOUNT_EDIT_
import app.k9mail.feature.account.edit.navigation.withAccountUuid
import app.k9mail.feature.account.setup.navigation.NAVIGATION_ROUTE_ACCOUNT_SETUP
import app.k9mail.feature.launcher.ui.FeatureLauncherApp
import app.k9mail.feature.onboarding.navigation.NAVIGATION_ROUTE_ONBOARDING
import app.k9mail.feature.onboarding.main.navigation.NAVIGATION_ROUTE_ONBOARDING
import com.fsck.k9.ui.base.K9Activity
class FeatureLauncherActivity : K9Activity() {

View file

@ -2,10 +2,12 @@ package app.k9mail.feature.launcher.di
import app.k9mail.feature.account.edit.featureAccountEditModule
import app.k9mail.feature.account.setup.featureAccountSetupModule
import app.k9mail.feature.onboarding.main.featureOnboardingModule
import org.koin.dsl.module
val featureLauncherModule = module {
includes(
featureOnboardingModule,
featureAccountSetupModule,
featureAccountEditModule,
)

View file

@ -7,11 +7,10 @@ import androidx.navigation.compose.NavHost
import app.k9mail.core.ui.compose.common.activity.LocalActivity
import app.k9mail.feature.account.edit.navigation.accountEditRoute
import app.k9mail.feature.account.setup.navigation.accountSetupRoute
import app.k9mail.feature.account.setup.navigation.navigateToAccountSetup
import app.k9mail.feature.launcher.FeatureLauncherExternalContract.AccountSetupFinishedLauncher
import app.k9mail.feature.launcher.FeatureLauncherExternalContract.ImportSettingsLauncher
import app.k9mail.feature.onboarding.navigation.NAVIGATION_ROUTE_ONBOARDING
import app.k9mail.feature.onboarding.navigation.onboardingRoute
import app.k9mail.feature.onboarding.main.navigation.NAVIGATION_ROUTE_ONBOARDING
import app.k9mail.feature.onboarding.main.navigation.onboardingRoute
import org.koin.compose.koinInject
@Composable
@ -30,8 +29,9 @@ fun FeatureLauncherNavHost(
modifier = modifier,
) {
onboardingRoute(
onStart = { navController.navigateToAccountSetup() },
onImport = { importSettingsLauncher.launch() },
onBack = onBack,
onFinish = { accountUuid -> accountSetupFinishedLauncher.launch(accountUuid) },
)
accountSetupRoute(
onBack = onBack,

View file

@ -0,0 +1,24 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "app.k9mail.feature.onboarding.main"
resourcePrefix = "onboarding_main_"
buildTypes {
debug {
manifestPlaceholders["appAuthRedirectScheme"] = "FIXME: override this in your app project"
}
release {
manifestPlaceholders["appAuthRedirectScheme"] = "FIXME: override this in your app project"
}
}
}
dependencies {
implementation(projects.core.ui.compose.designsystem)
implementation(projects.feature.onboarding.welcome)
implementation(projects.feature.account.setup)
implementation(projects.feature.onboarding.permissions)
}

View file

@ -0,0 +1,11 @@
package app.k9mail.feature.onboarding.main
import app.k9mail.feature.onboarding.permissions.featureOnboardingPermissionsModule
import org.koin.core.module.Module
import org.koin.dsl.module
val featureOnboardingModule: Module = module {
includes(
featureOnboardingPermissionsModule,
)
}

View file

@ -0,0 +1,63 @@
package app.k9mail.feature.onboarding.main.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.k9mail.feature.account.setup.navigation.nestedAccountSetupRoute
import app.k9mail.feature.onboarding.permissions.ui.PermissionsScreen
import app.k9mail.feature.onboarding.welcome.ui.OnboardingScreen
private const val NESTED_NAVIGATION_ROUTE_WELCOME = "welcome"
private const val NESTED_NAVIGATION_ROUTE_ACCOUNT_SETUP = "account_setup"
private const val NESTED_NAVIGATION_ROUTE_PERMISSIONS = "permissions"
private fun NavController.navigateToAccountSetup() {
navigate(NESTED_NAVIGATION_ROUTE_ACCOUNT_SETUP)
}
private fun NavController.navigateToPermissions() {
navigate(NESTED_NAVIGATION_ROUTE_PERMISSIONS)
}
@Composable
fun OnboardingNavHost(
onImport: () -> Unit,
onBack: () -> Unit,
onFinish: (String) -> Unit,
) {
val navController = rememberNavController()
var accountUuid by rememberSaveable { mutableStateOf<String?>(null) }
NavHost(
navController = navController,
startDestination = NESTED_NAVIGATION_ROUTE_WELCOME,
) {
composable(route = NESTED_NAVIGATION_ROUTE_WELCOME) {
OnboardingScreen(
onStartClick = { navController.navigateToAccountSetup() },
onImportClick = onImport,
)
}
nestedAccountSetupRoute(
route = NESTED_NAVIGATION_ROUTE_ACCOUNT_SETUP,
onBack = onBack,
onFinish = { createdAccountUuid ->
accountUuid = createdAccountUuid
navController.navigateToPermissions()
},
)
composable(route = NESTED_NAVIGATION_ROUTE_PERMISSIONS) {
PermissionsScreen(
onNext = { onFinish(requireNotNull(accountUuid)) },
)
}
}
}

View file

@ -1,10 +1,9 @@
package app.k9mail.feature.onboarding.navigation
package app.k9mail.feature.onboarding.main.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import app.k9mail.core.ui.compose.common.navigation.deepLinkComposable
import app.k9mail.feature.onboarding.ui.OnboardingScreen
const val NAVIGATION_ROUTE_ONBOARDING = "onboarding"
@ -15,13 +14,15 @@ fun NavController.navigateToOnboarding(
}
fun NavGraphBuilder.onboardingRoute(
onStart: () -> Unit,
onImport: () -> Unit,
onBack: () -> Unit,
onFinish: (String) -> Unit,
) {
deepLinkComposable(route = NAVIGATION_ROUTE_ONBOARDING) {
OnboardingScreen(
onStartClick = onStart,
onImportClick = onImport,
OnboardingNavHost(
onImport = onImport,
onBack = onBack,
onFinish = onFinish,
)
}
}

View file

@ -0,0 +1,14 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "app.k9mail.feature.onboarding.permissions"
resourcePrefix = "onboarding_permissions_"
}
dependencies {
implementation(projects.core.ui.compose.designsystem)
implementation(projects.core.android.permissions)
implementation(projects.feature.account.common)
}

View file

@ -0,0 +1,21 @@
package app.k9mail.feature.onboarding.permissions
import app.k9mail.core.android.permissions.corePermissionsAndroidModule
import app.k9mail.feature.onboarding.permissions.domain.PermissionsDomainContract.UseCase
import app.k9mail.feature.onboarding.permissions.domain.usecase.CheckPermission
import app.k9mail.feature.onboarding.permissions.ui.PermissionsViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.Module
import org.koin.dsl.module
val featureOnboardingPermissionsModule: Module = module {
includes(corePermissionsAndroidModule)
factory<UseCase.CheckPermission> { CheckPermission(permissionChecker = get()) }
viewModel {
PermissionsViewModel(
checkPermission = get(),
)
}
}

View file

@ -0,0 +1,14 @@
package app.k9mail.feature.onboarding.permissions.domain
import app.k9mail.core.android.permissions.Permission
import app.k9mail.core.android.permissions.PermissionState
interface PermissionsDomainContract {
interface UseCase {
fun interface CheckPermission {
operator fun invoke(permission: Permission): PermissionState
}
}
}

View file

@ -0,0 +1,18 @@
package app.k9mail.feature.onboarding.permissions.domain.usecase
import app.k9mail.core.android.permissions.Permission
import app.k9mail.core.android.permissions.PermissionChecker
import app.k9mail.core.android.permissions.PermissionState
import app.k9mail.feature.onboarding.permissions.domain.PermissionsDomainContract.UseCase
/**
* Checks if a [Permission] has been granted to the app.
*/
class CheckPermission(
private val permissionChecker: PermissionChecker,
) : UseCase.CheckPermission {
override fun invoke(permission: Permission): PermissionState {
return permissionChecker.checkPermission(permission)
}
}

View file

@ -0,0 +1,174 @@
package app.k9mail.feature.onboarding.permissions.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.common.image.ImageWithOverlayCoordinate
import app.k9mail.core.ui.compose.designsystem.atom.Icon
import app.k9mail.core.ui.compose.designsystem.atom.button.Button
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody2
import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline6
import app.k9mail.core.ui.compose.theme.Icons
import app.k9mail.core.ui.compose.theme.IconsWithBottomRightOverlay
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.feature.onboarding.permissions.R
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.UiPermissionState
private val MAX_WIDTH = 500.dp
@Composable
internal fun PermissionBox(
icon: ImageWithOverlayCoordinate,
permissionState: UiPermissionState,
title: String,
description: String,
onAllowClick: () -> Unit,
) {
Column(
modifier = Modifier
.width(MAX_WIDTH)
.padding(horizontal = MainTheme.spacings.double),
) {
Row {
Box(
modifier = Modifier.padding(
end = MainTheme.spacings.double,
top = MainTheme.spacings.default,
bottom = MainTheme.spacings.default,
),
) {
IconWithPermissionStateOverlay(icon, permissionState)
}
Column {
TextHeadline6(text = title)
TextBody2(text = description)
}
}
val buttonAlpha by animateFloatAsState(
targetValue = if (permissionState == UiPermissionState.Granted) 0f else 1f,
label = "AllowButtonAlpha",
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.End,
) {
Spacer(modifier = Modifier.height(MainTheme.spacings.default))
Button(
text = stringResource(R.string.onboarding_permissions_allow_button),
onClick = onAllowClick,
modifier = Modifier.alpha(buttonAlpha),
)
}
}
}
@Composable
private fun IconWithPermissionStateOverlay(
icon: ImageWithOverlayCoordinate,
permissionState: UiPermissionState,
) {
Box {
val iconSize = MainTheme.sizes.largeIcon
val overlayIconSize = iconSize / 2
val overlayIconOffset = overlayIconSize / 2
val scalingFactor = iconSize / icon.image.defaultHeight
val overlayOffsetX = (icon.overlayOffsetX * scalingFactor) - overlayIconOffset
val overlayOffsetY = (icon.overlayOffsetY * scalingFactor) - overlayIconOffset
Icon(
imageVector = icon.image,
modifier = Modifier.size(iconSize),
)
when (permissionState) {
UiPermissionState.Unknown -> Unit
UiPermissionState.Granted -> {
Icon(
imageVector = Icons.Filled.check,
tint = MainTheme.colors.success,
modifier = Modifier
.size(overlayIconSize)
.offset(
x = overlayOffsetX,
y = overlayOffsetY,
),
)
}
UiPermissionState.Denied -> {
Icon(
imageVector = Icons.Filled.cancel,
tint = MainTheme.colors.warning,
modifier = Modifier
.size(overlayIconSize)
.offset(
x = overlayOffsetX,
y = overlayOffsetY,
),
)
}
}
}
}
@Preview(showBackground = true)
@Composable
internal fun PermissionBoxUnknownStatePreview() {
K9Theme {
PermissionBox(
icon = IconsWithBottomRightOverlay.person,
permissionState = UiPermissionState.Unknown,
title = "Contacts",
description = "Allow access to be able to display contact names and photos.",
onAllowClick = {},
)
}
}
@Preview(showBackground = true)
@Composable
internal fun PermissionBoxGrantedStatePreview() {
K9Theme {
PermissionBox(
icon = IconsWithBottomRightOverlay.person,
permissionState = UiPermissionState.Granted,
title = "Contacts",
description = "Allow access to be able to display contact names and photos.",
onAllowClick = {},
)
}
}
@Preview(showBackground = true)
@Composable
internal fun PermissionBoxDeniedStatePreview() {
K9Theme {
PermissionBox(
icon = IconsWithBottomRightOverlay.person,
permissionState = UiPermissionState.Denied,
title = "Contacts",
description = "Allow access to be able to display contact names and photos.",
onAllowClick = {},
)
}
}

View file

@ -0,0 +1,198 @@
package app.k9mail.feature.onboarding.permissions.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.common.PreviewDevices
import app.k9mail.core.ui.compose.common.visibility.hide
import app.k9mail.core.ui.compose.designsystem.atom.DelayedCircularProgressIndicator
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.atom.button.Button
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline5
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
import app.k9mail.core.ui.compose.theme.IconsWithBottomRightOverlay
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.feature.account.common.ui.AppTitleTopHeader
import app.k9mail.feature.onboarding.permissions.R
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.Event
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.State
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.UiPermissionState
import app.k9mail.feature.account.common.R as CommonR
@Composable
internal fun PermissionsContent(
state: State,
onEvent: (Event) -> Unit,
) {
val scrollState = rememberScrollState()
Scaffold(
bottomBar = {
BottomBar(state, onEvent, scrollState)
},
) { innerPadding ->
ResponsiveWidthContainer(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding),
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxHeight()
.verticalScroll(state = scrollState),
) {
HeaderArea()
ContentArea(state, onEvent)
// This provides some bottom padding but is also necessary to make the vertical arrangement have the
// desired effect of putting ContentArea() in the middle.
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
}
}
}
}
@Composable
private fun HeaderArea() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppTitleTopHeader()
TextHeadline5(
text = stringResource(R.string.onboarding_permissions_screen_title),
modifier = Modifier.padding(horizontal = MainTheme.spacings.double),
)
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
}
}
@Composable
private fun ContentArea(state: State, onEvent: (Event) -> Unit) {
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxHeight()
.padding(MainTheme.spacings.double),
) {
if (state.isLoading) {
DelayedCircularProgressIndicator()
} else {
PermissionBoxes(state, onEvent)
}
}
}
@Composable
private fun PermissionBoxes(
state: State,
onEvent: (Event) -> Unit,
) {
PermissionBox(
icon = IconsWithBottomRightOverlay.person,
permissionState = state.contactsPermissionState,
title = stringResource(R.string.onboarding_permissions_contacts_title),
description = stringResource(R.string.onboarding_permissions_contacts_description),
onAllowClick = { onEvent(Event.AllowContactsPermissionClicked) },
)
if (state.isNotificationsPermissionVisible) {
Spacer(modifier = Modifier.height(MainTheme.spacings.quadruple))
PermissionBox(
icon = IconsWithBottomRightOverlay.notification,
permissionState = state.notificationsPermissionState,
title = stringResource(R.string.onboarding_permissions_notifications_title),
description = stringResource(R.string.onboarding_permissions_notifications_description),
onAllowClick = { onEvent(Event.AllowNotificationsPermissionClicked) },
)
}
}
@Composable
private fun BottomBar(
state: State,
onEvent: (Event) -> Unit,
scrollState: ScrollState,
) {
// Elevate the bottom bar when some scrollable content is "underneath" it
val elevation by animateDpAsState(
targetValue = if (scrollState.canScrollForward) 8.dp else 0.dp,
label = "BottomBarElevation",
)
Surface(elevation = elevation) {
ResponsiveWidthContainer(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.padding(
start = MainTheme.spacings.double,
end = MainTheme.spacings.double,
top = MainTheme.spacings.half,
bottom = MainTheme.spacings.half,
)
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
Crossfade(
targetState = state.isNextButtonVisible,
label = "NextButton",
) { isNextButtonVisible ->
Button(
text = stringResource(CommonR.string.account_common_button_next),
onClick = { onEvent(Event.NextClicked) },
modifier = Modifier.hide(!isNextButtonVisible),
)
ButtonText(
text = stringResource(R.string.onboarding_permissions_skip_button),
onClick = { onEvent(Event.NextClicked) },
modifier = Modifier.hide(isNextButtonVisible),
)
}
}
}
}
}
@PreviewDevices
@Composable
internal fun PermissionContentPreview() {
K9Theme {
PermissionsContent(
state = State(
isLoading = false,
contactsPermissionState = UiPermissionState.Granted,
notificationsPermissionState = UiPermissionState.Denied,
isNotificationsPermissionVisible = true,
isNextButtonVisible = false,
),
onEvent = {},
)
}
}

View file

@ -0,0 +1,40 @@
package app.k9mail.feature.onboarding.permissions.ui
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
interface PermissionsContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
data class State(
val isLoading: Boolean = true,
val contactsPermissionState: UiPermissionState = UiPermissionState.Unknown,
val notificationsPermissionState: UiPermissionState = UiPermissionState.Unknown,
val isNotificationsPermissionVisible: Boolean = false,
val isNextButtonVisible: Boolean = false,
)
sealed interface Event {
data object LoadPermissionState : Event
data object AllowContactsPermissionClicked : Event
data object AllowNotificationsPermissionClicked : Event
data class ContactsPermissionResult(val success: Boolean) : Event
data class NotificationsPermissionResult(val success: Boolean) : Event
data object NextClicked : Event
}
sealed interface Effect {
data object RequestContactsPermission : Effect
data object RequestNotificationsPermission : Effect
data object NavigateNext : Effect
}
enum class UiPermissionState {
Unknown,
Granted,
Denied,
}
}

View file

@ -0,0 +1,72 @@
package app.k9mail.feature.onboarding.permissions.ui
import android.Manifest
import android.os.Build
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import app.k9mail.core.android.permissions.PermissionState
import app.k9mail.core.ui.compose.common.PreviewDevices
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.Effect
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.Event
import kotlinx.coroutines.Dispatchers
import org.koin.androidx.compose.koinViewModel
@Composable
fun PermissionsScreen(
viewModel: PermissionsContract.ViewModel = koinViewModel<PermissionsViewModel>(),
onNext: () -> Unit,
) {
val contactsPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { success ->
viewModel.event(Event.ContactsPermissionResult(success))
}
val notificationsPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) { success ->
viewModel.event(Event.NotificationsPermissionResult(success))
}
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
Effect.RequestContactsPermission -> contactsPermissionLauncher.requestContactsPermission()
Effect.RequestNotificationsPermission -> notificationsPermissionLauncher.requestNotificationsPermission()
Effect.NavigateNext -> onNext()
}
}
LaunchedEffect(key1 = Unit) {
dispatch(Event.LoadPermissionState)
}
PermissionsContent(
state = state.value,
onEvent = dispatch,
)
}
private fun ManagedActivityResultLauncher<String, Boolean>.requestContactsPermission() {
launch(Manifest.permission.READ_CONTACTS)
}
private fun ManagedActivityResultLauncher<String, Boolean>.requestNotificationsPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@PreviewDevices
@Composable
internal fun PermissionScreenPreview() {
K9Theme {
PermissionsScreen(
viewModel = PermissionsViewModel(
checkPermission = { PermissionState.Denied },
backgroundDispatcher = Dispatchers.Main.immediate,
),
onNext = {},
)
}
}

View file

@ -0,0 +1,108 @@
package app.k9mail.feature.onboarding.permissions.ui
import androidx.lifecycle.viewModelScope
import app.k9mail.core.android.permissions.Permission
import app.k9mail.core.android.permissions.PermissionState
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.onboarding.permissions.domain.PermissionsDomainContract.UseCase
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.Effect
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.Event
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.State
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.UiPermissionState
import app.k9mail.feature.onboarding.permissions.ui.PermissionsContract.ViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PermissionsViewModel(
private val checkPermission: UseCase.CheckPermission,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : BaseViewModel<State, Event, Effect>(initialState = State(isLoading = true)), ViewModel {
override fun event(event: Event) {
when (event) {
Event.LoadPermissionState -> handleOneTimeEvent(event, ::loadPermissionState)
Event.AllowContactsPermissionClicked -> handleAllowContactsPermissionClicked()
Event.AllowNotificationsPermissionClicked -> handleAllowNotificationsPermissionClicked()
is Event.ContactsPermissionResult -> handleContactsPermissionResult(event.success)
is Event.NotificationsPermissionResult -> handleNotificationsPermissionResult(event.success)
Event.NextClicked -> handleNextClicked()
}
}
private fun loadPermissionState() {
viewModelScope.launch {
val (contactsPermissionState, notificationsPermissionState) = withContext(backgroundDispatcher) {
arrayOf(
checkPermission(Permission.Contacts),
checkPermission(Permission.Notifications),
)
}
val contactsUiPermissionState = when (contactsPermissionState) {
PermissionState.GrantedImplicitly -> error("Unexpected case")
PermissionState.Granted -> UiPermissionState.Granted
PermissionState.Denied -> UiPermissionState.Unknown
}
val notificationsUiPermissionState = when (notificationsPermissionState) {
PermissionState.GrantedImplicitly -> UiPermissionState.Unknown
PermissionState.Granted -> UiPermissionState.Granted
PermissionState.Denied -> UiPermissionState.Unknown
}
val isNotificationsPermissionVisible = notificationsPermissionState != PermissionState.GrantedImplicitly
updateState { state ->
state.copy(
isLoading = false,
contactsPermissionState = contactsUiPermissionState,
notificationsPermissionState = notificationsUiPermissionState,
isNotificationsPermissionVisible = isNotificationsPermissionVisible,
)
}
updateNextButtonState()
}
}
private fun handleAllowContactsPermissionClicked() {
emitEffect(Effect.RequestContactsPermission)
}
private fun handleAllowNotificationsPermissionClicked() {
emitEffect(Effect.RequestNotificationsPermission)
}
private fun handleContactsPermissionResult(success: Boolean) {
updateState { state ->
state.copy(
contactsPermissionState = if (success) UiPermissionState.Granted else UiPermissionState.Denied,
)
}
updateNextButtonState()
}
private fun handleNotificationsPermissionResult(success: Boolean) {
updateState { state ->
state.copy(
notificationsPermissionState = if (success) UiPermissionState.Granted else UiPermissionState.Denied,
)
}
updateNextButtonState()
}
private fun updateNextButtonState() {
updateState { state ->
val isContactsPermissionGranted = state.contactsPermissionState == UiPermissionState.Granted
val isNotificationsPermissionGrantedOrHidden = !state.isNotificationsPermissionVisible ||
state.notificationsPermissionState == UiPermissionState.Granted
state.copy(
isNextButtonVisible = isContactsPermissionGranted && isNotificationsPermissionGrantedOrHidden,
)
}
}
private fun handleNextClicked() {
emitEffect(Effect.NavigateNext)
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Title of the onboarding screen to ask for Android permissions -->
<string name="onboarding_permissions_screen_title">Improve the experience</string>
<!-- Title of the section asking for the permission to read contacts -->
<string name="onboarding_permissions_contacts_title">Contacts</string>
<!-- Description of why we ask for the permission to read contacts -->
<string name="onboarding_permissions_contacts_description">Allow access to be able to provide contact suggestions and to display contact names and photos.</string>
<!-- Title of the section asking for permission to post notifications -->
<string name="onboarding_permissions_notifications_title">Notifications</string>
<!-- Description of why we ask the permission to post notifications -->
<string name="onboarding_permissions_notifications_description">Turn on notifications so you don\'t miss any emails.</string>
<!-- Text of the button that will trigger the system dialog to ask for an Android permission -->
<string name="onboarding_permissions_allow_button">Allow</string>
<!-- Text of the button to skip the onboarding screen to ask for Android permissions -->
<string name="onboarding_permissions_skip_button">Skip</string>
</resources>

View file

@ -3,8 +3,8 @@ plugins {
}
android {
namespace = "app.k9mail.feature.onboarding"
resourcePrefix = "onboarding_"
namespace = "app.k9mail.feature.onboarding.welcome"
resourcePrefix = "onboarding_welcome_"
}
dependencies {

View file

@ -1,4 +1,4 @@
package app.k9mail.feature.onboarding.ui
package app.k9mail.feature.onboarding.welcome.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@ -23,7 +23,7 @@ import app.k9mail.core.ui.compose.designsystem.template.ResponsiveContent
import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.onboarding.R
import app.k9mail.feature.onboarding.welcome.R
@Composable
internal fun OnboardingContent(

View file

@ -1,4 +1,4 @@
package app.k9mail.feature.onboarding.ui
package app.k9mail.feature.onboarding.welcome.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

View file

@ -39,7 +39,12 @@ include(
include(
":feature:launcher",
":feature:onboarding",
)
include(
":feature:onboarding:main",
":feature:onboarding:welcome",
":feature:onboarding:permissions",
)
include(
@ -64,6 +69,7 @@ include(
":core:featureflags",
":core:testing",
":core:android:common",
":core:android:permissions",
":core:android:testing",
":core:ui:compose:common",
":core:ui:compose:designsystem",