Merge pull request #7299 from thunderbird/permission_screen
Add onboarding screen to ask for Android permissions
This commit is contained in:
commit
eb7dcf2875
96 changed files with 1087 additions and 24 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
13
core/android/permissions/build.gradle.kts
Normal file
13
core/android/permissions/build.gradle.kts
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()) }
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package app.k9mail.core.android.permissions
|
||||
|
||||
/**
|
||||
* System permissions we ask for during onboarding.
|
||||
*/
|
||||
enum class Permission {
|
||||
Contacts,
|
||||
Notifications,
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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)
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
24
feature/onboarding/main/build.gradle.kts
Normal file
24
feature/onboarding/main/build.gradle.kts
Normal 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)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
14
feature/onboarding/permissions/build.gradle.kts
Normal file
14
feature/onboarding/permissions/build.gradle.kts
Normal 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)
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -3,8 +3,8 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
namespace = "app.k9mail.feature.onboarding"
|
||||
resourcePrefix = "onboarding_"
|
||||
namespace = "app.k9mail.feature.onboarding.welcome"
|
||||
resourcePrefix = "onboarding_welcome_"
|
||||
}
|
||||
|
||||
dependencies {
|
|
@ -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(
|
|
@ -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
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue