mirror of
https://github.com/ProtonMail/android-mail.git
synced 2026-05-15 09:50:40 +00:00
feat(auth): Add Signup flow and UI.
This commit is contained in:
Generated
+35
@@ -7,6 +7,41 @@
|
||||
</value>
|
||||
</option>
|
||||
<option name="RIGHT_MARGIN" value="120" />
|
||||
<JavaCodeStyleSettings>
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="" withSubpackages="true" static="false" module="true" />
|
||||
<package name="android" withSubpackages="true" static="true" />
|
||||
<package name="androidx" withSubpackages="true" static="true" />
|
||||
<package name="com" withSubpackages="true" static="true" />
|
||||
<package name="junit" withSubpackages="true" static="true" />
|
||||
<package name="net" withSubpackages="true" static="true" />
|
||||
<package name="org" withSubpackages="true" static="true" />
|
||||
<package name="java" withSubpackages="true" static="true" />
|
||||
<package name="javax" withSubpackages="true" static="true" />
|
||||
<package name="" withSubpackages="true" static="true" />
|
||||
<emptyLine />
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="androidx" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="junit" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="net" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="org" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="java" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="javax" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_IMPORT_LAYOUT">
|
||||
<value>
|
||||
|
||||
@@ -267,6 +267,8 @@ dependencies {
|
||||
implementation(project(":design-system"))
|
||||
implementation(project(":presentation-compose"))
|
||||
|
||||
implementation(libs.proton.core.pass.validator)
|
||||
|
||||
debugImplementation(libs.bundles.app.debug)
|
||||
|
||||
// Environment configuration
|
||||
|
||||
@@ -45,6 +45,7 @@ fun Launcher(activityActions: MainActivity.Actions, viewModel: LauncherViewModel
|
||||
onRecoveryEmail = { viewModel.submit(LauncherViewModel.Action.OpenRecoveryEmail) },
|
||||
onReportBug = { viewModel.submit(LauncherViewModel.Action.OpenReport) },
|
||||
onSignIn = { viewModel.submit(LauncherViewModel.Action.SignIn(it)) },
|
||||
onSignUp = { viewModel.submit(LauncherViewModel.Action.SignUp) },
|
||||
onSubscription = { viewModel.submit(LauncherViewModel.Action.OpenSubscription) },
|
||||
onSwitchToAccount = { viewModel.submit(LauncherViewModel.Action.SwitchToAccount(it)) },
|
||||
onRequestNotificationPermission = {
|
||||
@@ -69,6 +70,7 @@ object Launcher {
|
||||
|
||||
data class Actions(
|
||||
val onSignIn: (UserId?) -> Unit,
|
||||
val onSignUp: () -> Unit,
|
||||
val onSubscription: () -> Unit,
|
||||
val onReportBug: () -> Unit,
|
||||
val onPasswordManagement: () -> Unit,
|
||||
|
||||
@@ -49,6 +49,7 @@ import me.proton.android.core.auth.presentation.AuthOrchestrator
|
||||
import me.proton.android.core.auth.presentation.login.LoginInput
|
||||
import me.proton.android.core.auth.presentation.onAddAccountResult
|
||||
import me.proton.android.core.auth.presentation.onLoginResult
|
||||
import me.proton.android.core.auth.presentation.onSignUpResult
|
||||
import me.proton.android.core.payment.presentation.PaymentOrchestrator
|
||||
import me.proton.android.core.payment.presentation.onUpgradeResult
|
||||
import me.proton.core.domain.entity.UserId
|
||||
@@ -153,7 +154,8 @@ class LauncherViewModel @Inject constructor(
|
||||
with(authOrchestrator) {
|
||||
register(context)
|
||||
onAddAccountResult { result -> if (!result) context.finish() }
|
||||
onLoginResult { result -> if (result != null) onSwitchToAccount(result.userId.toUserId()) }
|
||||
onLoginResult { result -> if (result != null) { onSwitchToAccount(result.userId.toUserId()) } }
|
||||
onSignUpResult { result -> if (result != null) { onSwitchToAccount(result.userId.toUserId()) } }
|
||||
userSessionRepository.observe(context.lifecycle, minActiveState = Lifecycle.State.RESUMED)
|
||||
.onAccountTwoFactorNeeded { startSecondFactorWorkflow(it.userId.toLocalUserId()) }
|
||||
.onAccountTwoPasswordNeeded { startTwoPassModeWorkflow(it.userId.toLocalUserId()) }
|
||||
@@ -177,6 +179,7 @@ class LauncherViewModel @Inject constructor(
|
||||
is Action.OpenSubscription -> onOpenSubscription()
|
||||
is Action.RequestNotificationPermission -> onRequestNotificationPermission()
|
||||
is Action.SignIn -> onSignIn(action.userId)
|
||||
is Action.SignUp -> onSignUp()
|
||||
is Action.SwitchToAccount -> onSwitchToAccount(action.userId)
|
||||
}
|
||||
}
|
||||
@@ -213,6 +216,10 @@ class LauncherViewModel @Inject constructor(
|
||||
authOrchestrator.startLoginWorkflow(LoginInput(username = address))
|
||||
}
|
||||
|
||||
private fun onSignUp() = viewModelScope.launch {
|
||||
authOrchestrator.startSignUpWorkflow()
|
||||
}
|
||||
|
||||
private fun onSwitchToAccount(userId: UserId) = viewModelScope.launch {
|
||||
setPrimaryAccount(userId)
|
||||
}
|
||||
@@ -231,6 +238,7 @@ class LauncherViewModel @Inject constructor(
|
||||
data object OpenSubscription : Action
|
||||
data object RequestNotificationPermission : Action
|
||||
data class SignIn(val userId: UserId?) : Action
|
||||
data object SignUp : Action
|
||||
data class SwitchToAccount(val userId: UserId) : Action
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ import org.junit.Test
|
||||
@SmokeTest
|
||||
@HiltAndroidTest
|
||||
@UninstallModules(ServerProofModule::class)
|
||||
internal class MessageLoadingTests : MockedNetworkTest() {
|
||||
internal class MessageLoadTests : MockedNetworkTest() {
|
||||
|
||||
@JvmField
|
||||
@BindValue
|
||||
@@ -66,7 +66,7 @@ lottie = "6.1.0"
|
||||
material = "1.12.0"
|
||||
mockk = "1.13.17"
|
||||
paparazzi = "1.3.4"
|
||||
proton-core = "32.0.1"
|
||||
proton-core = "32.1.0"
|
||||
proton-rust-core = "0.81.0"
|
||||
kotlinpoet-ksp = "2.1.0"
|
||||
leakcanary = "2.14"
|
||||
@@ -227,6 +227,7 @@ proton-core-network-data = { module = "me.proton.core:network-data", version.ref
|
||||
proton-core-network-domain = { module = "me.proton.core:network-domain", version.ref = "proton-core" }
|
||||
proton-core-notification-dagger = { module = "me.proton.core:notification-dagger", version.ref = "proton-core" }
|
||||
proton-core-observability-dagger = { module = "me.proton.core:observability-dagger", version.ref = "proton-core" }
|
||||
proton-core-pass-validator = { module = "me.proton.core:pass-validator", version.ref = "proton-core" }
|
||||
proton-core-payment-dagger = { module = "me.proton.core:payment-dagger", version.ref = "proton-core" }
|
||||
proton-core-paymentIap-dagger = { module = "me.proton.core:payment-iap-dagger", version.ref = "proton-core" }
|
||||
proton-core-plan-dagger = { module = "me.proton.core:plan-dagger", version.ref = "proton-core" }
|
||||
|
||||
@@ -62,6 +62,8 @@ dependencies {
|
||||
implementation(libs.proton.core.challenge.data)
|
||||
implementation(libs.proton.core.challenge.domain)
|
||||
implementation(libs.proton.core.challenge.presentation)
|
||||
implementation(libs.proton.core.account.domain)
|
||||
// implementation(libs.proton.core.challenge)
|
||||
implementation(libs.proton.core.domain)
|
||||
implementation(libs.proton.core.presentation)
|
||||
implementation(libs.proton.core.presentationCompose)
|
||||
|
||||
+8
-4
@@ -30,6 +30,7 @@ import me.proton.android.core.auth.presentation.login.LoginOutput
|
||||
import me.proton.android.core.auth.presentation.secondfactor.SecondFactorActivity
|
||||
import me.proton.android.core.auth.presentation.secondfactor.SecondFactorArg
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpActivity
|
||||
import me.proton.android.core.auth.presentation.signup.SignupOutput
|
||||
import me.proton.android.core.auth.presentation.twopass.TwoPassActivity
|
||||
import me.proton.android.core.auth.presentation.twopass.TwoPassArg
|
||||
|
||||
@@ -100,14 +101,17 @@ object StartTwoPassMode : ActivityResultContract<String, Boolean>() {
|
||||
}
|
||||
}
|
||||
|
||||
object StartSignUp : ActivityResultContract<Unit, Boolean>() {
|
||||
object StartSignUp : ActivityResultContract<Unit, SignupOutput?>() {
|
||||
|
||||
override fun createIntent(context: Context, input: Unit) = Intent(context, SignUpActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean = when (resultCode) {
|
||||
Activity.RESULT_OK -> true
|
||||
else -> false
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): SignupOutput? = when (resultCode) {
|
||||
Activity.RESULT_OK -> intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, SignUpActivity.ARG_OUTPUT, SignupOutput::class.java)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -22,6 +22,7 @@ import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import me.proton.android.core.auth.presentation.login.LoginInput
|
||||
import me.proton.android.core.auth.presentation.login.LoginOutput
|
||||
import me.proton.android.core.auth.presentation.signup.SignupOutput
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthOrchestrator @Inject constructor() {
|
||||
@@ -38,7 +39,7 @@ class AuthOrchestrator @Inject constructor() {
|
||||
private var onLoginHelpResultListener: ((result: Boolean) -> Unit)? = {}
|
||||
private var onSecondFactorResultListener: ((result: Boolean) -> Unit)? = {}
|
||||
private var onTwoPassModeResultListener: ((result: Boolean) -> Unit)? = {}
|
||||
private var onSignUpResultListener: ((result: Boolean) -> Unit)? = {}
|
||||
private var onSignUpResultListener: ((result: SignupOutput?) -> Unit)? = {}
|
||||
|
||||
private fun registerAddAccountResult(caller: ActivityResultCaller): ActivityResultLauncher<Unit> =
|
||||
caller.registerForActivityResult(StartAddAccount) {
|
||||
@@ -93,7 +94,7 @@ class AuthOrchestrator @Inject constructor() {
|
||||
onTwoPassModeResultListener = block
|
||||
}
|
||||
|
||||
fun setOnSignUpResult(block: (result: Boolean) -> Unit) {
|
||||
fun setOnSignUpResult(block: (result: SignupOutput?) -> Unit) {
|
||||
onSignUpResultListener = block
|
||||
}
|
||||
|
||||
@@ -205,7 +206,7 @@ fun AuthOrchestrator.onTwoPassModeResult(block: (result: Boolean) -> Unit): Auth
|
||||
return this
|
||||
}
|
||||
|
||||
fun AuthOrchestrator.onSignUpResult(block: (result: Boolean) -> Unit): AuthOrchestrator {
|
||||
fun AuthOrchestrator.onSignUpResult(block: (result: SignupOutput?) -> Unit): AuthOrchestrator {
|
||||
setOnSignUpResult { block(it) }
|
||||
return this
|
||||
}
|
||||
|
||||
+1
@@ -20,5 +20,6 @@ package me.proton.android.core.auth.presentation
|
||||
object LogTag {
|
||||
const val DEFAULT = "core.auth.presentation.default"
|
||||
const val LOGIN = "core.auth.presentation.login"
|
||||
const val SIGNUP = "core.auth.presentation.signup"
|
||||
const val SESSION = "core.auth.presentation.session"
|
||||
}
|
||||
|
||||
+9
-12
@@ -16,18 +16,15 @@
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
package me.proton.android.core.auth.presentation
|
||||
|
||||
sealed interface CreatePasswordState {
|
||||
data object Idle : CreatePasswordState
|
||||
data object Loading : CreatePasswordState
|
||||
data object Validating : CreatePasswordState
|
||||
data class FormError(val message: String?) : CreatePasswordState
|
||||
data class Success(val password: String) : CreatePasswordState
|
||||
import me.proton.core.presentation.ui.ProtonActivity
|
||||
import me.proton.core.presentation.utils.ProtectScreenConfiguration
|
||||
import me.proton.core.presentation.utils.protectScreen
|
||||
|
||||
abstract class ProtonSecureActivity : ProtonActivity() {
|
||||
|
||||
private val configuration = ProtectScreenConfiguration(true)
|
||||
private val screenProtector by protectScreen(configuration)
|
||||
|
||||
val isLoading: Boolean
|
||||
get() = when (this) {
|
||||
is Validating -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -23,10 +23,10 @@ import androidx.activity.compose.setContent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import me.proton.android.core.auth.presentation.AuthOrchestrator
|
||||
import me.proton.android.core.auth.presentation.onLoginResult
|
||||
import me.proton.android.core.auth.presentation.onSignUpResult
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.presentation.ui.ProtonActivity
|
||||
import me.proton.core.presentation.utils.addOnBackPressedCallback
|
||||
import me.proton.core.presentation.utils.showToast
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -46,19 +46,14 @@ class AddAccountActivity : ProtonActivity() {
|
||||
addOnBackPressedCallback { onClose() }
|
||||
|
||||
authOrchestrator.register(this)
|
||||
authOrchestrator.onLoginResult { result ->
|
||||
if (result != null) onSuccess()
|
||||
}
|
||||
authOrchestrator.onLoginResult { result -> if (result != null) onSuccess() }
|
||||
authOrchestrator.onSignUpResult { result -> if (result != null) onSuccess() }
|
||||
|
||||
setContent {
|
||||
ProtonTheme {
|
||||
AddAccountScreenMail(
|
||||
onSignInClicked = { authOrchestrator.startLoginWorkflow() },
|
||||
onSignUpClicked = {
|
||||
showToast("Coming soon")
|
||||
// Enable when ready:
|
||||
// authOrchestrator.startSignUpWorkflow()
|
||||
}
|
||||
onSignUpClicked = { authOrchestrator.startSignUpWorkflow() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-7
@@ -24,24 +24,19 @@ import androidx.activity.compose.setContent
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import me.proton.android.core.auth.presentation.AuthOrchestrator
|
||||
import me.proton.android.core.auth.presentation.ProtonSecureActivity
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.presentation.ui.ProtonActivity
|
||||
import me.proton.core.presentation.utils.ProtectScreenConfiguration
|
||||
import me.proton.core.presentation.utils.addOnBackPressedCallback
|
||||
import me.proton.core.presentation.utils.errorToast
|
||||
import me.proton.core.presentation.utils.protectScreen
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class LoginActivity : ProtonActivity() {
|
||||
class LoginActivity : ProtonSecureActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var authOrchestrator: AuthOrchestrator
|
||||
|
||||
private val configuration = ProtectScreenConfiguration(true)
|
||||
private val screenProtector by protectScreen(configuration)
|
||||
|
||||
private val input: LoginInput
|
||||
get() = requireNotNull(IntentCompat.getParcelableExtra(intent, ARG_INPUT, LoginInput::class.java))
|
||||
|
||||
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:OptIn(ExperimentalMaterialApi::class)
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ExposedDropdownMenuBox
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultNorm
|
||||
|
||||
data class Country(
|
||||
val countryCode: String,
|
||||
val callingCode: Int? = null,
|
||||
val name: String,
|
||||
val flagId: Int = 0
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CountryCodeDropDown(
|
||||
modifier: Modifier = Modifier,
|
||||
isLoading: Boolean = false,
|
||||
data: List<Country> = emptyList(),
|
||||
onInputChanged: (Country) -> Unit = {}
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var selected by remember { mutableStateOf(data.firstOrNull()) }
|
||||
|
||||
if (selected == null) {
|
||||
return
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && !isLoading,
|
||||
onExpandedChange = {}
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.clickable { expanded = !expanded },
|
||||
contentColor = ProtonTheme.colors.textNorm,
|
||||
elevation = 0.dp
|
||||
) {
|
||||
CountryListItem(
|
||||
country = selected,
|
||||
trailing = {
|
||||
if (data.size > 1) {
|
||||
TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
data.forEach { country ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
expanded = false
|
||||
selected = country
|
||||
onInputChanged(country)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp, 0.dp)
|
||||
) {
|
||||
CountryListItem(country = country)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
internal fun CountryListItem(
|
||||
country: Country?,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
text = {
|
||||
Text(
|
||||
text = "+${country?.callingCode}",
|
||||
style = ProtonTypography.Default.defaultNorm
|
||||
)
|
||||
},
|
||||
trailing = { trailing?.invoke() }
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CountryDropDownPreview() {
|
||||
ProtonTheme {
|
||||
CountryCodeDropDown(
|
||||
data = listOf(
|
||||
Country(
|
||||
countryCode = "CH",
|
||||
callingCode = 1,
|
||||
name = "Switzerland"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.compose.viewmodel.stopTimeoutMillis
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CreatePasswordViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val mutableAction = MutableStateFlow<CreatePasswordAction?>(null)
|
||||
|
||||
val state: StateFlow<CreatePasswordState> = mutableAction.flatMapLatest { action ->
|
||||
when (action) {
|
||||
null -> flowOf(CreatePasswordState.Idle)
|
||||
is CreatePasswordAction.SetNavigationDone -> onSetNavigationDone()
|
||||
is CreatePasswordAction.Submit -> onCreatePassword(action.confirmPassword)
|
||||
}
|
||||
}.stateIn(viewModelScope, WhileSubscribed(stopTimeoutMillis), CreatePasswordState.Idle)
|
||||
|
||||
fun submit(action: CreatePasswordAction) = viewModelScope.launch {
|
||||
mutableAction.emit(action)
|
||||
}
|
||||
|
||||
private fun onCreatePassword(password: String): Flow<CreatePasswordState> = flow {
|
||||
emit(CreatePasswordState.Loading)
|
||||
emit(CreatePasswordState.Success(password))
|
||||
}
|
||||
|
||||
private fun onSetNavigationDone(): Flow<CreatePasswordState> = flow {
|
||||
emit(CreatePasswordState.Idle)
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
|
||||
sealed interface RecoveryMethodOperation
|
||||
|
||||
sealed interface CreateRecoveryAction : RecoveryMethodOperation {
|
||||
data class SelectCreateRecovery(val recoveryMethod: RecoveryMethod) : CreateRecoveryAction
|
||||
data class SubmitEmail(val email: String, val recoveryFrameDetails: ChallengeFrameDetails) : CreateRecoveryAction
|
||||
data class SubmitPhone(
|
||||
val callingCode: String,
|
||||
val phoneNumber: String,
|
||||
val recoveryFrameDetails: ChallengeFrameDetails
|
||||
) : CreateRecoveryAction
|
||||
|
||||
data object SetNavigationDone : CreateRecoveryAction
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
sealed class CreateRecoveryState(
|
||||
open val recoveryMethod: RecoveryMethod,
|
||||
open val countries: List<Country>? = null
|
||||
) {
|
||||
|
||||
data class Idle(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
override val countries: List<Country>? = null
|
||||
) : CreateRecoveryState(recoveryMethod, countries)
|
||||
|
||||
data class Loading(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
override val countries: List<Country>? = null
|
||||
) : CreateRecoveryState(recoveryMethod, countries)
|
||||
|
||||
data class Validating(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
override val countries: List<Country>
|
||||
) : CreateRecoveryState(recoveryMethod)
|
||||
|
||||
sealed class FormError(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
open val message: String?
|
||||
) : CreateRecoveryState(recoveryMethod) {
|
||||
|
||||
data class Email(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
override val message: String?
|
||||
) : FormError(recoveryMethod, message)
|
||||
|
||||
data class Phone(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
override val message: String?
|
||||
) : FormError(recoveryMethod, message)
|
||||
}
|
||||
|
||||
data class Success(
|
||||
override val recoveryMethod: RecoveryMethod,
|
||||
val value: String
|
||||
) : CreateRecoveryState(recoveryMethod)
|
||||
|
||||
val isLoading: Boolean
|
||||
get() = when (this) {
|
||||
is Validating -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
enum class RecoveryMethod(val value: Int) {
|
||||
Email(0), Phone(1);
|
||||
|
||||
companion object {
|
||||
val map = entries.associateBy { it.value }
|
||||
fun enumOf(value: Int) = map[value] ?: Email
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.challenge.domain.ChallengeManager
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
import me.proton.core.compose.viewmodel.stopTimeoutMillis
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CreateRecoveryViewModel @Inject constructor(
|
||||
private val challengeManager: ChallengeManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val mutableAction = MutableStateFlow<CreateRecoveryAction?>(null)
|
||||
|
||||
val state: StateFlow<CreateRecoveryState> = mutableAction.flatMapLatest { action ->
|
||||
when (action) {
|
||||
null -> flowOf(CreateRecoveryState.Idle(RecoveryMethod.Email))
|
||||
is CreateRecoveryAction.SelectCreateRecovery -> onSelectRecoveryMethod(action.recoveryMethod)
|
||||
is CreateRecoveryAction.SubmitEmail -> onSubmitEmail(action.email, action.recoveryFrameDetails)
|
||||
is CreateRecoveryAction.SubmitPhone -> onSubmitPhone(
|
||||
action.callingCode,
|
||||
action.phoneNumber,
|
||||
action.recoveryFrameDetails
|
||||
)
|
||||
|
||||
is CreateRecoveryAction.SetNavigationDone -> onSetNavigationDone()
|
||||
}
|
||||
}.stateIn(viewModelScope, WhileSubscribed(stopTimeoutMillis), CreateRecoveryState.Idle(RecoveryMethod.Email))
|
||||
|
||||
private fun onSelectRecoveryMethod(recoveryMethod: RecoveryMethod): Flow<CreateRecoveryState> = flow {
|
||||
val countries = when (recoveryMethod) {
|
||||
RecoveryMethod.Phone -> listOf(
|
||||
Country("CH", 41, "Switzerland"),
|
||||
Country("US", 1, "US")
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
emit(CreateRecoveryState.Idle(recoveryMethod, countries))
|
||||
}
|
||||
|
||||
private fun onSubmitEmail(email: String, recoveryFrameDetails: ChallengeFrameDetails): Flow<CreateRecoveryState> =
|
||||
flow {
|
||||
challengeManager.addOrUpdateFrameToFlow(recoveryFrameDetails)
|
||||
emit(CreateRecoveryState.Success(RecoveryMethod.Email, email))
|
||||
}
|
||||
|
||||
private fun onSubmitPhone(
|
||||
callingCode: String,
|
||||
phoneNumber: String,
|
||||
recoveryFrameDetails: ChallengeFrameDetails
|
||||
): Flow<CreateRecoveryState> = flow {
|
||||
challengeManager.addOrUpdateFrameToFlow(recoveryFrameDetails)
|
||||
emit(CreateRecoveryState.Success(RecoveryMethod.Phone, "$callingCode$phoneNumber"))
|
||||
}
|
||||
|
||||
private fun onSetNavigationDone(): Flow<CreateRecoveryState> = flow {
|
||||
emit(state.value)
|
||||
}
|
||||
|
||||
fun submit(action: CreateRecoveryAction) = viewModelScope.launch {
|
||||
mutableAction.emit(action)
|
||||
}
|
||||
}
|
||||
-468
@@ -1,468 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("UseComposableActions")
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material.Divider
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
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.rememberCoroutineScope
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.android.core.auth.presentation.challenge.SIGNUP_CHALLENGE_FLOW_NAME
|
||||
import me.proton.android.core.auth.presentation.challenge.SIGNUP_CHALLENGE_USERNAME_FRAME
|
||||
import me.proton.android.core.auth.presentation.challenge.TextChange
|
||||
import me.proton.core.challenge.presentation.compose.LocalClipManager
|
||||
import me.proton.core.challenge.presentation.compose.LocalClipManager.OnClipChangedDisposableEffect
|
||||
import me.proton.core.challenge.presentation.compose.PayloadController
|
||||
import me.proton.core.challenge.presentation.compose.payload
|
||||
import me.proton.core.compose.component.ProtonCloseButton
|
||||
import me.proton.core.compose.component.ProtonOutlinedTextFieldWithError
|
||||
import me.proton.core.compose.component.ProtonSolidButton
|
||||
import me.proton.core.compose.component.ProtonTextButton
|
||||
import me.proton.core.compose.component.appbar.ProtonTopAppBar
|
||||
import me.proton.core.compose.theme.LocalColors
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultSmallWeak
|
||||
|
||||
internal const val USERNAME_FIELD_TAG = "USERNAME_FIELD_TAG"
|
||||
internal const val EMAIL_FIELD_TAG = "EMAIL_FIELD_TAG"
|
||||
internal const val PHONE_FIELD_TAG = "PHONE_FIELD_TAG"
|
||||
|
||||
@Composable
|
||||
fun CreateUsernameScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (String) -> Unit = {},
|
||||
viewModel: CreateUsernameViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
CreateUsernameScreen(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onUsernameSubmitted = { viewModel.submit(it) },
|
||||
onCreateExternalClicked = { viewModel.submit(it) },
|
||||
onCreateInternalClicked = { viewModel.submit(it) },
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = {
|
||||
onSuccess(it)
|
||||
viewModel.submit(CreateUsernameAction.SetNavigationDone)
|
||||
},
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateUsernameScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onUsernameSubmitted: (CreateUsernameAction.Submit) -> Unit = {},
|
||||
onCreateExternalClicked: (CreateUsernameAction.CreateExternalAccount) -> Unit = {},
|
||||
onCreateInternalClicked: (CreateUsernameAction.CreateInternalAccount) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (String) -> Unit = {},
|
||||
state: CreateUsernameState
|
||||
) {
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is CreateUsernameState.Error -> onErrorMessage(state.message)
|
||||
is CreateUsernameState.FormError -> onErrorMessage(state.message)
|
||||
is CreateUsernameState.Success -> onSuccess(state.username)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
ChooseUsernameScaffold(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onUsernameSubmitted = onUsernameSubmitted,
|
||||
onCreateExternalClicked = onCreateExternalClicked,
|
||||
onCreateInternalClicked = onCreateInternalClicked,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseUsernameScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onUsernameSubmitted: (CreateUsernameAction.Submit) -> Unit = {},
|
||||
onCreateExternalClicked: (CreateUsernameAction.CreateExternalAccount) -> Unit,
|
||||
onCreateInternalClicked: (CreateUsernameAction.CreateInternalAccount) -> Unit,
|
||||
@DrawableRes protonLogo: Int = R.drawable.ic_logo_proton,
|
||||
@StringRes titleText: Int = R.string.auth_signup_title,
|
||||
state: CreateUsernameState
|
||||
) {
|
||||
val inputError = state is CreateUsernameState.FormError
|
||||
val isLoading = state.isLoading
|
||||
val accountType = state.accountType
|
||||
val domains = if (state is CreateUsernameState.Idle) state.domains else null
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
ProtonTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
ProtonCloseButton(onCloseClicked = onCloseClicked)
|
||||
},
|
||||
backgroundColor = LocalColors.current.backgroundNorm
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.SmallSpacing)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.height(64.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
painter = painterResource(protonLogo),
|
||||
contentDescription = null,
|
||||
alignment = Alignment.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(titleText),
|
||||
style = ProtonTypography.Default.headline,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
|
||||
when (accountType) {
|
||||
AccountType.Internal ->
|
||||
CreateInternalForm(
|
||||
modifier = modifier,
|
||||
enabled = !isLoading,
|
||||
onUsernameSubmitted = onUsernameSubmitted,
|
||||
onCreateExternalClicked = onCreateExternalClicked,
|
||||
usernameError = when {
|
||||
inputError -> stringResource(R.string.auth_signup_username_assistive_text)
|
||||
else -> null
|
||||
},
|
||||
isLoading = isLoading,
|
||||
domains = domains
|
||||
)
|
||||
|
||||
AccountType.External ->
|
||||
CreateExternalForm(
|
||||
modifier = modifier,
|
||||
enabled = !isLoading,
|
||||
onExternalEmailSubmitted = onUsernameSubmitted,
|
||||
onCreateInternalClicked = onCreateInternalClicked,
|
||||
emailError = when {
|
||||
inputError -> stringResource(R.string.auth_signup_email_assistive_text)
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateInternalForm(
|
||||
modifier: Modifier = Modifier,
|
||||
onUsernameSubmitted: (CreateUsernameAction.Submit) -> Unit,
|
||||
onCreateExternalClicked: (CreateUsernameAction.CreateExternalAccount) -> Unit,
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_subtitle,
|
||||
usernameError: String? = null,
|
||||
isLoading: Boolean = false,
|
||||
enabled: Boolean,
|
||||
domains: List<Domain>?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var domain by rememberSaveable { mutableStateOf("") }
|
||||
val usernameChanges = remember { MutableStateFlow(TextChange()) }
|
||||
val usernameHasFocus = remember { mutableStateOf(false) }
|
||||
val usernamePayloadController = remember { PayloadController() }
|
||||
val usernameTextCopies = remember { MutableStateFlow("") }
|
||||
|
||||
LocalClipManager.current?.OnClipChangedDisposableEffect {
|
||||
if (usernameHasFocus.value) usernameTextCopies.value = it
|
||||
}
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val usernameFrameDetails = usernamePayloadController.flush()
|
||||
onUsernameSubmitted(CreateUsernameAction.Submit(username, AccountType.Internal, usernameFrameDetails))
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(subtitleText),
|
||||
style = ProtonTypography.Default.defaultSmallWeak,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = ProtonDimens.SmallSpacing)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(DefaultSpacing)
|
||||
) {
|
||||
ProtonOutlinedTextFieldWithError(
|
||||
text = username,
|
||||
onValueChanged = {
|
||||
usernameChanges.value = usernameChanges.value.roll(it)
|
||||
username = it
|
||||
},
|
||||
enabled = enabled,
|
||||
errorText = usernameError,
|
||||
label = { Text(text = stringResource(id = R.string.auth_signup_email_username)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.onFocusChanged { usernameHasFocus.value = it.hasFocus }
|
||||
.fillMaxWidth()
|
||||
.padding(top = DefaultSpacing)
|
||||
.payload(
|
||||
flow = SIGNUP_CHALLENGE_FLOW_NAME,
|
||||
frame = SIGNUP_CHALLENGE_USERNAME_FRAME,
|
||||
onTextChanged = usernameChanges.map { it.toPair() },
|
||||
onTextCopied = usernameTextCopies,
|
||||
onFrameUpdated = {},
|
||||
payloadController = usernamePayloadController
|
||||
)
|
||||
.testTag(USERNAME_FIELD_TAG)
|
||||
)
|
||||
|
||||
DomainDropDown(
|
||||
isLoading = isLoading,
|
||||
data = domains ?: emptyList(),
|
||||
onInputChanged = { domain = it }
|
||||
)
|
||||
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
enabled = enabled,
|
||||
loading = isLoading,
|
||||
onClick = ::onSubmit,
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(R.string.auth_signup_next))
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
|
||||
color = LocalColors.current.separatorNorm
|
||||
)
|
||||
|
||||
ProtonTextButton(
|
||||
contained = false,
|
||||
onClick = { onCreateExternalClicked(CreateUsernameAction.CreateExternalAccount) },
|
||||
modifier = Modifier
|
||||
.padding(vertical = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(R.string.auth_signup_use_current_email))
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(id = R.string.auth_signup_internal_footnote),
|
||||
style = ProtonTypography.Default.defaultSmallWeak
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun CreateExternalForm(
|
||||
modifier: Modifier = Modifier,
|
||||
onExternalEmailSubmitted: (CreateUsernameAction.Submit) -> Unit,
|
||||
onCreateInternalClicked: (CreateUsernameAction.CreateInternalAccount) -> Unit,
|
||||
emailError: String? = null,
|
||||
enabled: Boolean
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
val emailChanges = remember { MutableStateFlow(TextChange()) }
|
||||
val emailHasFocus = remember { mutableStateOf(false) }
|
||||
val emailPayloadController = remember { PayloadController() }
|
||||
val emailTextCopies = remember { MutableStateFlow("") }
|
||||
|
||||
LocalClipManager.current?.OnClipChangedDisposableEffect {
|
||||
if (emailHasFocus.value) emailTextCopies.value = it
|
||||
}
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val emailFrameDetails = emailPayloadController.flush()
|
||||
onExternalEmailSubmitted(CreateUsernameAction.Submit(email, AccountType.External, emailFrameDetails))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(DefaultSpacing)
|
||||
) {
|
||||
ProtonOutlinedTextFieldWithError(
|
||||
text = email,
|
||||
onValueChanged = {
|
||||
emailChanges.value = emailChanges.value.roll(it)
|
||||
email = it
|
||||
},
|
||||
enabled = enabled,
|
||||
errorText = emailError,
|
||||
label = { Text(text = stringResource(id = R.string.auth_email)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.onFocusChanged { emailHasFocus.value = it.hasFocus }
|
||||
.fillMaxWidth()
|
||||
.padding(top = DefaultSpacing)
|
||||
.payload(
|
||||
flow = SIGNUP_CHALLENGE_FLOW_NAME,
|
||||
frame = SIGNUP_CHALLENGE_USERNAME_FRAME,
|
||||
onTextChanged = emailChanges.map { it.toPair() },
|
||||
onTextCopied = emailTextCopies,
|
||||
onFrameUpdated = {},
|
||||
payloadController = emailPayloadController
|
||||
)
|
||||
.testTag(EMAIL_FIELD_TAG)
|
||||
)
|
||||
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
enabled = enabled,
|
||||
loading = !enabled,
|
||||
onClick = ::onSubmit,
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(R.string.auth_signup_next))
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
|
||||
color = LocalColors.current.separatorNorm
|
||||
)
|
||||
|
||||
ProtonTextButton(
|
||||
contained = false,
|
||||
onClick = { onCreateInternalClicked(CreateUsernameAction.CreateInternalAccount) },
|
||||
modifier = Modifier
|
||||
.padding(vertical = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(R.string.auth_signup_get_encrypted_email))
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(id = R.string.auth_signup_external_footnote),
|
||||
style = ProtonTypography.Default.defaultSmallWeak
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateUsernameScreenPreview() {
|
||||
ProtonTheme {
|
||||
CreateUsernameScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateInternalPreview() {
|
||||
ProtonTheme {
|
||||
CreateInternalForm(
|
||||
enabled = true,
|
||||
onUsernameSubmitted = {},
|
||||
onCreateExternalClicked = {},
|
||||
usernameError = stringResource(id = R.string.auth_login_assistive_text),
|
||||
domains = listOf("protonmail.com", "protonmail.ch")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateExternalPreview() {
|
||||
ProtonTheme {
|
||||
CreateExternalForm(
|
||||
enabled = true,
|
||||
onExternalEmailSubmitted = {},
|
||||
onCreateInternalClicked = {},
|
||||
emailError = stringResource(id = R.string.auth_login_assistive_text)
|
||||
)
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
sealed class CreateUsernameState(
|
||||
open val accountType: AccountType
|
||||
) {
|
||||
|
||||
data class Idle(
|
||||
override val accountType: AccountType,
|
||||
val domains: List<Domain>? = null
|
||||
) : CreateUsernameState(accountType)
|
||||
|
||||
data class Loading(
|
||||
override val accountType: AccountType
|
||||
) : CreateUsernameState(accountType)
|
||||
|
||||
data class Validating(
|
||||
override val accountType: AccountType
|
||||
) : CreateUsernameState(accountType)
|
||||
|
||||
data class Error(
|
||||
override val accountType: AccountType,
|
||||
val message: String?
|
||||
) : CreateUsernameState(accountType)
|
||||
|
||||
data class FormError(
|
||||
override val accountType: AccountType,
|
||||
val message: String?
|
||||
) : CreateUsernameState(accountType)
|
||||
|
||||
data class Success(
|
||||
override val accountType: AccountType,
|
||||
val username: String
|
||||
) : CreateUsernameState(accountType)
|
||||
|
||||
val isLoading: Boolean
|
||||
get() = when (this) {
|
||||
is Validating,
|
||||
is Loading -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
enum class AccountType {
|
||||
Internal, External
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.challenge.domain.ChallengeManager
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
import me.proton.core.compose.viewmodel.stopTimeoutMillis
|
||||
import me.proton.core.presentation.savedstate.state
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CreateUsernameViewModel @Inject constructor(
|
||||
private val challengeManager: ChallengeManager,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
private var currentAccountType: AccountType by savedStateHandle.state(AccountType.External)
|
||||
private var domains: List<Domain> = emptyList()
|
||||
private val mutableAction = MutableStateFlow<CreateUsernameAction?>(null)
|
||||
|
||||
val state: StateFlow<CreateUsernameState> = mutableAction.flatMapLatest { action ->
|
||||
when (action) {
|
||||
null -> flowOf(CreateUsernameState.Idle(currentAccountType))
|
||||
is CreateUsernameAction.SetNavigationDone -> onSetNavigationDone()
|
||||
is CreateUsernameAction.CreateExternalAccount -> onCreateExternalAccount()
|
||||
is CreateUsernameAction.CreateInternalAccount -> onCreateInternalAccount()
|
||||
is CreateUsernameAction.Load -> onInit(currentAccountType)
|
||||
is CreateUsernameAction.Submit -> onSubmit(action.type, action.value, action.usernameFrameDetails)
|
||||
}
|
||||
}.stateIn(viewModelScope, WhileSubscribed(stopTimeoutMillis), CreateUsernameState.Loading(currentAccountType))
|
||||
|
||||
private fun onInit(accountType: AccountType): Flow<CreateUsernameState> = flow {
|
||||
emitAll(
|
||||
when (accountType) {
|
||||
AccountType.Internal -> onCreateInternalAccount()
|
||||
AccountType.External -> onCreateExternalAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onCreateExternalAccount(): Flow<CreateUsernameState> = flow {
|
||||
currentAccountType = AccountType.External
|
||||
emit(CreateUsernameState.Idle(AccountType.External))
|
||||
}
|
||||
|
||||
@Suppress("ForbiddenComment")
|
||||
private fun onCreateInternalAccount(): Flow<CreateUsernameState> = flow {
|
||||
currentAccountType = AccountType.Internal
|
||||
emit(CreateUsernameState.Loading(AccountType.Internal))
|
||||
// fixme: refactor to fetch from API
|
||||
domains = listOf("protonmail.com", "protonmail.ch")
|
||||
emit(CreateUsernameState.Idle(AccountType.Internal, domains))
|
||||
}
|
||||
|
||||
private fun onSubmit(
|
||||
accountType: AccountType,
|
||||
username: String,
|
||||
usernameFrameDetails: ChallengeFrameDetails
|
||||
): Flow<CreateUsernameState> = flow {
|
||||
challengeManager.addOrUpdateFrameToFlow(usernameFrameDetails)
|
||||
emit(CreateUsernameState.Success(accountType, username))
|
||||
}
|
||||
|
||||
private fun onSetNavigationDone(): Flow<CreateUsernameState> = flow {
|
||||
emit(CreateUsernameState.Idle(currentAccountType, domains))
|
||||
}
|
||||
|
||||
fun submit(action: CreateUsernameAction) = viewModelScope.launch {
|
||||
mutableAction.emit(action)
|
||||
}
|
||||
}
|
||||
+51
-17
@@ -18,33 +18,36 @@
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import me.proton.android.core.auth.presentation.AuthOrchestrator
|
||||
import me.proton.android.core.auth.presentation.ProtonSecureActivity
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addCountryPickerDialog
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addCreatePasswordScreen
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addCreateRecoveryScreen
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addCreateUsernameScreen
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addMainScreen
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addSignUpCongratsScreen
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addSignUpCreateUserScreen
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpRoutes.addSkipRecoveryDialog
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.presentation.ui.ProtonActivity
|
||||
import me.proton.core.presentation.utils.ProtectScreenConfiguration
|
||||
import me.proton.core.presentation.utils.addOnBackPressedCallback
|
||||
import me.proton.core.presentation.utils.errorToast
|
||||
import me.proton.core.presentation.utils.protectScreen
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SignUpActivity : ProtonActivity() {
|
||||
class SignUpActivity : ProtonSecureActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var authOrchestrator: AuthOrchestrator
|
||||
|
||||
private val configuration = ProtectScreenConfiguration(true)
|
||||
private val screenProtector by protectScreen(configuration)
|
||||
private val signUpViewModel: SignUpViewModel by viewModels()
|
||||
|
||||
override fun onDestroy() {
|
||||
authOrchestrator.unregister()
|
||||
@@ -61,28 +64,48 @@ class SignUpActivity : ProtonActivity() {
|
||||
setContent {
|
||||
ProtonTheme {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
route = "sign_up",
|
||||
navController = navController,
|
||||
startDestination = SignUpRoutes.Route.CreateUsername()
|
||||
) {
|
||||
addCreateUsernameScreen(
|
||||
navController = navController,
|
||||
onClose = { navController.popBackStack() },
|
||||
onErrorMessage = { onErrorMessage(it) }
|
||||
onClose = { onClose() },
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
addCreatePasswordScreen(
|
||||
navController = navController,
|
||||
onClose = { navController.popBackStack() },
|
||||
onErrorMessage = { onErrorMessage(it) }
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
addCreateRecoveryScreen(
|
||||
navController = navController,
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
onClose = { navController.popBackStack() }
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
addMainScreen(
|
||||
addSkipRecoveryDialog(
|
||||
navController = navController,
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
addCountryPickerDialog(
|
||||
navController = navController,
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
addSignUpCreateUserScreen(
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
onSuccess = { onSuccess() }
|
||||
navController = navController,
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
addSignUpCongratsScreen(
|
||||
onSuccess = { userId -> onSuccess(userId) },
|
||||
onError = {
|
||||
onErrorMessage(it)
|
||||
onClose()
|
||||
},
|
||||
navGraphViewModel = signUpViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +113,10 @@ class SignUpActivity : ProtonActivity() {
|
||||
}
|
||||
|
||||
private fun onErrorMessage(message: String?) {
|
||||
errorToast(message ?: getString(R.string.presentation_error_general))
|
||||
val errorMessage = if (message.isNullOrEmpty()) {
|
||||
getString(R.string.presentation_error_general)
|
||||
} else message
|
||||
errorToast(errorMessage)
|
||||
}
|
||||
|
||||
private fun onClose() {
|
||||
@@ -98,8 +124,16 @@ class SignUpActivity : ProtonActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun onSuccess() {
|
||||
setResult(Activity.RESULT_OK)
|
||||
private fun onSuccess(userId: String) {
|
||||
setResult(
|
||||
Activity.RESULT_OK,
|
||||
Intent().apply { putExtra(ARG_OUTPUT, SignupOutput(userId = userId)) }
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_OUTPUT = "arg.signupOutput"
|
||||
}
|
||||
}
|
||||
|
||||
+88
-2
@@ -18,8 +18,94 @@
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
sealed interface SignUpOperation : RecoveryMethodOperation
|
||||
import me.proton.android.core.auth.presentation.signup.ui.Country
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
|
||||
sealed interface SignUpOperation
|
||||
|
||||
sealed interface SignUpAction : SignUpOperation {
|
||||
data object SignUp : SignUpAction
|
||||
data object CreatePlan : SignUpAction // add in the future
|
||||
data object CreateUser : SignUpAction
|
||||
data object FinalizeSignup : SignUpAction
|
||||
}
|
||||
|
||||
sealed interface CreateUsernameAction : SignUpAction {
|
||||
data class LoadData(
|
||||
val accountType: AccountType
|
||||
) : CreateUsernameAction
|
||||
|
||||
data object CreateExternalAccount : CreateUsernameAction
|
||||
data object CreateInternalAccount : CreateUsernameAction
|
||||
data class Perform(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val value: String,
|
||||
val domain: String?,
|
||||
val accountType: AccountType,
|
||||
val usernameFrameDetails: ChallengeFrameDetails
|
||||
) : CreateUsernameAction
|
||||
|
||||
data class CreateUsernameClosed(
|
||||
val back: Boolean = false
|
||||
) : CreateUsernameAction
|
||||
}
|
||||
|
||||
sealed interface CreatePasswordAction : SignUpAction {
|
||||
data object LoadData : CreatePasswordAction
|
||||
data class Perform(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val password: String,
|
||||
val confirmPassword: String
|
||||
) : CreatePasswordAction
|
||||
|
||||
data class CreatePasswordClosed(
|
||||
val back: Boolean = false
|
||||
) : CreatePasswordAction
|
||||
}
|
||||
|
||||
sealed interface CreateRecoveryAction : SignUpAction {
|
||||
|
||||
data class SelectRecoveryMethod(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val locale: String
|
||||
) : CreateRecoveryAction
|
||||
|
||||
data class SubmitRecoveryEmail(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val recoveryMethod: RecoveryMethod = RecoveryMethod.Email,
|
||||
val email: String,
|
||||
val recoveryFrameDetails: ChallengeFrameDetails
|
||||
) : CreateRecoveryAction
|
||||
|
||||
data class SubmitRecoveryPhone(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val recoveryMethod: RecoveryMethod = RecoveryMethod.Phone,
|
||||
val callingCode: String,
|
||||
val phoneNumber: String,
|
||||
val recoveryFrameDetails: ChallengeFrameDetails
|
||||
) : CreateRecoveryAction
|
||||
|
||||
sealed interface DialogAction : CreateRecoveryAction {
|
||||
data object WantSkipRecovery : DialogAction
|
||||
|
||||
data object RecoverySkipped : DialogAction
|
||||
|
||||
data object WantSkipDialogClosed : DialogAction
|
||||
|
||||
data class PickCountry(
|
||||
val recoveryMethod: RecoveryMethod = RecoveryMethod.Phone
|
||||
) : DialogAction
|
||||
|
||||
data class CountryPicked(
|
||||
val recoveryMethod: RecoveryMethod = RecoveryMethod.Phone,
|
||||
val country: Country
|
||||
) : DialogAction
|
||||
|
||||
data object CountryPickerClosed : DialogAction
|
||||
}
|
||||
|
||||
data class CreateRecoveryClosed(
|
||||
val back: Boolean = false
|
||||
) : CreateRecoveryAction
|
||||
}
|
||||
|
||||
+137
-38
@@ -18,17 +18,22 @@
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import me.proton.android.core.auth.presentation.signup.ui.CountryPickerScreen
|
||||
import me.proton.android.core.auth.presentation.signup.ui.CreatePasswordScreen
|
||||
import me.proton.android.core.auth.presentation.signup.ui.CreateRecoveryScreen
|
||||
import me.proton.android.core.auth.presentation.signup.ui.CreateRecoverySkipDialog
|
||||
import me.proton.android.core.auth.presentation.signup.ui.CreateUsernameScreen
|
||||
import me.proton.android.core.auth.presentation.signup.ui.SignUpCongratsScreen
|
||||
import me.proton.android.core.auth.presentation.signup.ui.SignUpLoadingScreen
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
|
||||
object SignUpRoutes {
|
||||
sealed class Route(val route: String) {
|
||||
data object SignUp : Route("auth/signup") {
|
||||
|
||||
operator fun invoke() = route
|
||||
}
|
||||
|
||||
data object CreateUsername : Route("auth/signup/create/username") {
|
||||
|
||||
operator fun invoke() = route
|
||||
@@ -43,77 +48,171 @@ object SignUpRoutes {
|
||||
|
||||
operator fun invoke() = route
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ForbiddenComment")
|
||||
fun NavGraphBuilder.addMainScreen(
|
||||
onErrorMessage: (String?) -> Unit,
|
||||
onSuccess: () -> Unit // todo: add user id
|
||||
) {
|
||||
composable(
|
||||
route = Route.SignUp.route
|
||||
) {
|
||||
SignUpScreen(
|
||||
onSuccess = { onSuccess() },
|
||||
onErrorMessage = { onErrorMessage(it) }
|
||||
)
|
||||
data object CountryPicker : Route("auth/signup/create/recovery/countrypicker") {
|
||||
|
||||
operator fun invoke() = route
|
||||
}
|
||||
|
||||
data object SkipRecovery : Route("auth/signup/create/recovery/skip") {
|
||||
|
||||
operator fun invoke() = route
|
||||
}
|
||||
|
||||
data object SignUpCreateUser : Route("auth/signup/user/create") {
|
||||
|
||||
operator fun invoke() = route
|
||||
}
|
||||
|
||||
data object SignUpCongrats : Route("auth/signup/congrats") {
|
||||
|
||||
operator fun invoke() = route
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addCreateUsernameScreen(
|
||||
navController: NavHostController,
|
||||
onClose: () -> Unit,
|
||||
onErrorMessage: (String?) -> Unit
|
||||
onErrorMessage: (String?) -> Unit,
|
||||
navGraphViewModel: SignUpViewModel
|
||||
) {
|
||||
composable(
|
||||
route = Route.CreateUsername.route
|
||||
) {
|
||||
CreateUsernameScreen(
|
||||
onCloseClicked = { onClose() },
|
||||
onBackClicked = { onClose() },
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
onSuccess = {
|
||||
val password = it
|
||||
navController.navigate(Route.CreatePassword())
|
||||
}
|
||||
onSuccess = { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
viewModel = navGraphViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addCreatePasswordScreen(
|
||||
navController: NavHostController,
|
||||
onClose: () -> Unit,
|
||||
onErrorMessage: (String?) -> Unit
|
||||
onErrorMessage: (String?) -> Unit,
|
||||
navGraphViewModel: SignUpViewModel
|
||||
) {
|
||||
composable(
|
||||
route = Route.CreatePassword.route
|
||||
) {
|
||||
CreatePasswordScreen(
|
||||
onBackClicked = { navController.popBackStack() },
|
||||
onBackClicked = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
onSuccess = {
|
||||
val password = it
|
||||
navController.navigate(Route.CreateRecovery())
|
||||
}
|
||||
onSuccess = { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
viewModel = navGraphViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addCreateRecoveryScreen(
|
||||
navController: NavHostController,
|
||||
onClose: () -> Unit,
|
||||
onErrorMessage: (String?) -> Unit
|
||||
onErrorMessage: (String?) -> Unit,
|
||||
navGraphViewModel: SignUpViewModel
|
||||
) {
|
||||
composable(
|
||||
route = Route.CreateRecovery.route
|
||||
) {
|
||||
CreateRecoveryScreen(
|
||||
onBackClicked = { onClose() },
|
||||
onSkipClicked = { onClose() },
|
||||
onBackClicked = { navController.popBackStack() },
|
||||
onWantSkip = {
|
||||
navController.navigate(Route.SkipRecovery.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onCountryPickerClicked = {
|
||||
navController.navigate(Route.CountryPicker.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onErrorMessage = { onErrorMessage(it) },
|
||||
onSuccess = { method, value ->
|
||||
val recovery = it // add to viewmodel
|
||||
navController.navigate(Route.SignUp())
|
||||
}
|
||||
onSuccess = { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
viewModel = navGraphViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addCountryPickerDialog(navController: NavHostController, navGraphViewModel: SignUpViewModel) {
|
||||
dialog(
|
||||
route = Route.CountryPicker.route,
|
||||
dialogProperties = DialogProperties(
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
CountryPickerScreen(
|
||||
viewModel = navGraphViewModel,
|
||||
onCloseClick = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addSkipRecoveryDialog(navController: NavHostController, navGraphViewModel: SignUpViewModel) {
|
||||
dialog(route = Route.SkipRecovery.route) {
|
||||
CreateRecoverySkipDialog(
|
||||
onCloseClicked = { navController.popBackStack() },
|
||||
onSkip = { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
viewModel = navGraphViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addSignUpCreateUserScreen(
|
||||
navController: NavHostController,
|
||||
onErrorMessage: (String?) -> Unit,
|
||||
navGraphViewModel: SignUpViewModel
|
||||
) {
|
||||
composable(
|
||||
route = Route.SignUpCreateUser.route
|
||||
) {
|
||||
SignUpLoadingScreen(
|
||||
onErrorMessage = {
|
||||
onErrorMessage(it)
|
||||
navController.popBackStack()
|
||||
},
|
||||
onSuccess = {
|
||||
navController.navigate(Route.SignUpCongrats.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
viewModel = navGraphViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.addSignUpCongratsScreen(
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
navGraphViewModel: SignUpViewModel
|
||||
) {
|
||||
composable(
|
||||
route = Route.SignUpCongrats.route
|
||||
) {
|
||||
SignUpCongratsScreen(
|
||||
onStartUsingApp = { onSuccess(it) },
|
||||
onErrorMessage = {
|
||||
onError(it)
|
||||
},
|
||||
viewModel = navGraphViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
@Composable
|
||||
fun SignUpScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onSuccess: () -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SignUpScreen(
|
||||
modifier = modifier,
|
||||
onSuccess = onSuccess,
|
||||
onErrorMessage = onErrorMessage,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignUpScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onSuccess: () -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
state: SignUpState
|
||||
) {
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is SignUpState.Error -> onErrorMessage(state.message)
|
||||
is SignUpState.Success -> onSuccess()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
SignUpLoading(modifier = modifier)
|
||||
}
|
||||
|
||||
|
||||
+15
-14
@@ -18,19 +18,20 @@
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
import me.proton.android.core.auth.presentation.LogTag
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import uniffi.proton_account_uniffi.SimpleSignupState
|
||||
|
||||
sealed interface CreateUsernameOperation
|
||||
|
||||
sealed interface CreateUsernameAction : CreateUsernameOperation {
|
||||
data object Load : CreateUsernameAction
|
||||
data object CreateExternalAccount : CreateUsernameAction
|
||||
data object CreateInternalAccount : CreateUsernameAction
|
||||
data class Submit(
|
||||
val value: String,
|
||||
val type: AccountType,
|
||||
val usernameFrameDetails: ChallengeFrameDetails
|
||||
) : CreateUsernameAction
|
||||
|
||||
data object SetNavigationDone : CreateUsernameAction
|
||||
fun SimpleSignupState.mapToNavigationRoute(): String {
|
||||
return when (this) {
|
||||
SimpleSignupState.WANT_USERNAME -> SignUpRoutes.Route.CreateUsername
|
||||
SimpleSignupState.WANT_PASSWORD -> SignUpRoutes.Route.CreatePassword
|
||||
SimpleSignupState.WANT_RECOVERY -> SignUpRoutes.Route.CreateRecovery
|
||||
SimpleSignupState.WANT_CREATE -> SignUpRoutes.Route.SignUpCreateUser
|
||||
SimpleSignupState.COMPLETE -> SignUpRoutes.Route.SignUpCongrats
|
||||
SimpleSignupState.INVALID -> {
|
||||
CoreLogger.e(LogTag.SIGNUP, "Received invalid state")
|
||||
throw IllegalStateException("Received invalid state from Rust.")
|
||||
}
|
||||
}.route
|
||||
}
|
||||
+184
-5
@@ -18,11 +18,190 @@
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.DataInput
|
||||
import me.proton.android.core.auth.presentation.signup.ui.Country
|
||||
import me.proton.android.core.auth.presentation.signup.ui.Domain
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
|
||||
sealed interface SignUpState {
|
||||
sealed interface DataInput : SignUpState
|
||||
data object SigningUp : SignUpState
|
||||
|
||||
data class Error(val message: String?) : SignUpState
|
||||
|
||||
@Suppress("ForbiddenComment")
|
||||
data object Success : SignUpState // todo: add user data
|
||||
data class SignUpError(val message: String?) : SignUpState
|
||||
data object SignUpSuccess : SignUpState
|
||||
data class LoginSuccess(val userId: String) : SignUpState
|
||||
}
|
||||
|
||||
// region username
|
||||
sealed interface CreateUsernameState : DataInput {
|
||||
|
||||
val accountType: AccountType
|
||||
val isLoading: Boolean
|
||||
|
||||
data class Idle(
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = false
|
||||
) : CreateUsernameState
|
||||
|
||||
data class Load(
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = true
|
||||
) : CreateUsernameState
|
||||
|
||||
data class LoadingComplete(
|
||||
override val accountType: AccountType,
|
||||
val domains: List<Domain>? = null,
|
||||
override val isLoading: Boolean = false
|
||||
) : CreateUsernameState
|
||||
|
||||
data class Creating(
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = false
|
||||
) : CreateUsernameState
|
||||
|
||||
sealed class ValidationError(
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = false
|
||||
) : CreateUsernameState {
|
||||
|
||||
data object UsernameEmpty : ValidationError(AccountType.Username, false)
|
||||
|
||||
data object InternalUsernameEmpty : ValidationError(AccountType.Internal, false)
|
||||
|
||||
data object EmailEmpty : ValidationError(AccountType.External, false)
|
||||
|
||||
data class Other(
|
||||
override val accountType: AccountType,
|
||||
val field: ValidationField,
|
||||
val message: String?
|
||||
) : ValidationError(accountType, false)
|
||||
}
|
||||
|
||||
data class Error(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = false,
|
||||
val message: String?
|
||||
) : CreateUsernameState
|
||||
|
||||
data class Success(
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = false,
|
||||
val username: String,
|
||||
val domain: String? = null,
|
||||
val route: String
|
||||
) : CreateUsernameState
|
||||
|
||||
data class Closed(
|
||||
override val accountType: AccountType,
|
||||
override val isLoading: Boolean = false
|
||||
) : CreateUsernameState
|
||||
}
|
||||
|
||||
enum class ValidationField {
|
||||
USERNAME,
|
||||
EMAIL
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region password
|
||||
sealed interface CreatePasswordState : DataInput {
|
||||
|
||||
data object Idle : CreatePasswordState
|
||||
data object Creating : CreatePasswordState
|
||||
sealed interface ValidationError : CreatePasswordState {
|
||||
data object PasswordEmpty : ValidationError
|
||||
data object ConfirmPasswordMissMatch : ValidationError
|
||||
data class Other(
|
||||
val message: String?
|
||||
) : ValidationError
|
||||
}
|
||||
|
||||
data class Error(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val message: String?
|
||||
) : CreatePasswordState
|
||||
|
||||
data class Success(val route: String) : CreatePasswordState
|
||||
data object Closed : CreatePasswordState
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region recovery
|
||||
sealed interface CreateRecoveryState : DataInput {
|
||||
|
||||
data class Idle(
|
||||
val recoveryMethod: RecoveryMethod = RecoveryMethod.Email,
|
||||
val countries: List<Country>? = null,
|
||||
val defaultCountry: Country?
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class Creating(
|
||||
val recoveryMethod: RecoveryMethod
|
||||
) : CreateRecoveryState
|
||||
|
||||
sealed class ValidationError(
|
||||
open val message: String?
|
||||
) : CreateRecoveryState {
|
||||
|
||||
data class Email(
|
||||
override val message: String?
|
||||
) : ValidationError(message)
|
||||
|
||||
data class Phone(
|
||||
override val message: String?
|
||||
) : ValidationError(message)
|
||||
}
|
||||
|
||||
data class Error(
|
||||
val unused: Long = System.currentTimeMillis(),
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val message: String?
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class Success(
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val value: String,
|
||||
val route: String
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class WantCountryPicker(
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val countries: List<Country>
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class OnCountryPicked(
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val country: Country
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class CountryPickerFailed(
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val country: Country?
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class WantSkip(
|
||||
val recoveryMethod: RecoveryMethod
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class SkipSuccess(
|
||||
val recoveryMethod: RecoveryMethod,
|
||||
val route: String
|
||||
) : CreateRecoveryState
|
||||
|
||||
data class SkipFailed(
|
||||
val recoveryMethod: RecoveryMethod
|
||||
) : CreateRecoveryState
|
||||
|
||||
data object Closed : CreateRecoveryState
|
||||
}
|
||||
|
||||
enum class RecoveryMethod(val value: Int) {
|
||||
Email(0), Phone(1);
|
||||
|
||||
companion object {
|
||||
|
||||
val map = entries.associateBy { it.value }
|
||||
fun enumOf(value: Int) = map[value] ?: Email
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.compose.viewmodel.stopTimeoutMillis
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SignUpViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val mutableAction: MutableStateFlow<SignUpAction?> = MutableStateFlow(null)
|
||||
|
||||
val state: StateFlow<SignUpState> = mutableAction.flatMapLatest { _ ->
|
||||
onSignUp()
|
||||
}.stateIn(viewModelScope, WhileSubscribed(stopTimeoutMillis), SignUpState.SigningUp)
|
||||
|
||||
private fun onSignUp() = flow<SignUpState> {
|
||||
emit(SignUpState.SigningUp)
|
||||
}
|
||||
|
||||
fun submit(action: SignUpAction) = viewModelScope.launch {
|
||||
mutableAction.emit(action)
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -18,9 +18,8 @@
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
|
||||
sealed interface CreatePasswordOperation
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed interface CreatePasswordAction : CreatePasswordOperation {
|
||||
data class Submit(val password: String, val confirmPassword: String) : CreatePasswordAction
|
||||
data object SetNavigationDone : CreatePasswordAction
|
||||
}
|
||||
@Parcelize
|
||||
data class SignupOutput(val userId: String) : Parcelable
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:OptIn(ExperimentalMaterialApi::class)
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultNorm
|
||||
|
||||
data class Country(
|
||||
val countryCode: String,
|
||||
val callingCode: Int? = null,
|
||||
val name: String,
|
||||
val flagId: Int = 0
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CountryCodeDropDown(
|
||||
modifier: Modifier = Modifier,
|
||||
country: Country?,
|
||||
onNavigateToCountryPicker: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onNavigateToCountryPicker),
|
||||
contentColor = ProtonTheme.colors.textNorm,
|
||||
elevation = 0.dp,
|
||||
backgroundColor = ProtonTheme.colors.backgroundSecondary
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "+${country?.callingCode ?: ""}",
|
||||
style = ProtonTypography.Default.defaultNorm,
|
||||
color = ProtonTheme.colors.textNorm,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_proton_chevron_down),
|
||||
contentDescription = stringResource(R.string.auth_signup_recovery_select_country)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CountryDropDownPreview() {
|
||||
ProtonTheme {
|
||||
CountryCodeDropDown(
|
||||
country = Country(
|
||||
countryCode = "CH",
|
||||
callingCode = 1,
|
||||
name = "Switzerland"
|
||||
),
|
||||
onNavigateToCountryPicker = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import java.util.Locale
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.WantCountryPicker
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.CountryPickerFailed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.OnCountryPicked
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.compose.component.ProtonCloseButton
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultNorm
|
||||
import me.proton.core.compose.theme.defaultSmallWeak
|
||||
|
||||
@Composable
|
||||
fun CountryPickerScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClick: () -> Unit = {},
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val currentState = state
|
||||
LaunchedEffect(currentState) {
|
||||
when (currentState) {
|
||||
is OnCountryPicked,
|
||||
is CountryPickerFailed -> onCloseClick()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
when (currentState) {
|
||||
is WantCountryPicker -> CountryPickerContentScreen(
|
||||
modifier = modifier,
|
||||
onCloseClick = {
|
||||
viewModel.perform(CreateRecoveryAction.DialogAction.CountryPickerClosed)
|
||||
},
|
||||
onCountrySelected = {
|
||||
viewModel.perform(CreateRecoveryAction.DialogAction.CountryPicked(country = it))
|
||||
},
|
||||
state = currentState
|
||||
)
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountryPickerContentScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClick: () -> Unit = {},
|
||||
onCountrySelected: (Country) -> Unit = {},
|
||||
state: WantCountryPicker
|
||||
) {
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
val filteredCountries by remember(searchQuery, state.countries) {
|
||||
derivedStateOf {
|
||||
if (searchQuery.isEmpty()) {
|
||||
state.countries
|
||||
} else {
|
||||
state.countries.filter { country ->
|
||||
country.name.contains(searchQuery, ignoreCase = true) || country.countryCode.contains(
|
||||
searchQuery,
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(ProtonDimens.DefaultSpacing)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = ProtonDimens.DefaultSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ProtonCloseButton(onCloseClicked = onCloseClick)
|
||||
|
||||
ProtonSearchBar(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = ProtonDimens.DefaultSpacing),
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
placeholder = stringResource(id = R.string.auth_signup_countries_search)
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
items(filteredCountries) { country ->
|
||||
CountryItem(
|
||||
country = country,
|
||||
onClick = { onCountrySelected(country) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProtonSearchBar(
|
||||
modifier: Modifier = Modifier,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = placeholder,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_proton_magnifier),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountryItem(country: Country, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
contentDescription = null,
|
||||
alignment = Alignment.Center,
|
||||
painter = painterResource(id = country.getFlagDrawable())
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = country.name,
|
||||
style = ProtonTypography.Default.defaultNorm
|
||||
)
|
||||
Text(
|
||||
text = country.countryCode,
|
||||
style = ProtonTypography.Default.defaultSmallWeak
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "+${country.callingCode}",
|
||||
style = ProtonTypography.Default.defaultNorm
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Country.getFlagDrawable(): Int {
|
||||
val context = LocalContext.current
|
||||
return context.resources.getIdentifier(
|
||||
"flag_${countryCode.lowercase(Locale.ROOT)}",
|
||||
"drawable",
|
||||
context.packageName
|
||||
)
|
||||
}
|
||||
+129
-48
@@ -18,18 +18,18 @@
|
||||
|
||||
@file:Suppress("UseComposableActions")
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -47,6 +46,17 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Closed
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Creating
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Error
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Idle
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Success
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.ValidationError.ConfirmPasswordMissMatch
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.ValidationError.Other
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.ValidationError.PasswordEmpty
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.compose.component.HyperlinkText
|
||||
import me.proton.core.compose.component.ProtonPasswordOutlinedTextFieldWithError
|
||||
import me.proton.core.compose.component.ProtonSolidButton
|
||||
@@ -61,45 +71,65 @@ fun CreatePasswordScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClicked: () -> Unit,
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (String) -> Unit = {},
|
||||
viewModel: CreatePasswordViewModel = hiltViewModel()
|
||||
onSuccess: (String) -> Unit = { },
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
CreatePasswordScreen(
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
viewModel.perform(CreatePasswordAction.CreatePasswordClosed(back = true))
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is Closed -> onBackClicked()
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
CreatePasswordContent(
|
||||
modifier = modifier,
|
||||
onBackClicked = onBackClicked,
|
||||
onPasswordSubmitted = { viewModel.submit(it) },
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = {
|
||||
onSuccess(it)
|
||||
viewModel.submit(CreatePasswordAction.SetNavigationDone)
|
||||
onBackClicked = {
|
||||
viewModel.perform(CreatePasswordAction.CreatePasswordClosed(back = true))
|
||||
},
|
||||
onPasswordSubmitted = { viewModel.perform(it) },
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = { route -> onSuccess(route) },
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreatePasswordScreen(
|
||||
fun CreatePasswordContent(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClicked: () -> Unit = {},
|
||||
onPasswordSubmitted: (CreatePasswordAction.Submit) -> Unit = {},
|
||||
onPasswordSubmitted: (CreatePasswordAction.Perform) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (String) -> Unit = {},
|
||||
state: CreatePasswordState = CreatePasswordState.Idle
|
||||
state: SignUpState = Idle
|
||||
) {
|
||||
val isLoading = state is Creating
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is CreatePasswordState.FormError -> onErrorMessage(state.message)
|
||||
is CreatePasswordState.Success -> onSuccess(state.password)
|
||||
is Error -> onErrorMessage(state.message)
|
||||
is Success -> onSuccess(state.route)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
val isLoading = state is CreatePasswordState.Loading
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
val passwordError = (state as? CreatePasswordState.FormError)?.message
|
||||
val confirmPasswordError = (state as? CreatePasswordState.FormError)?.message
|
||||
|
||||
val passwordError = when (state) {
|
||||
is PasswordEmpty -> stringResource(R.string.auth_signup_validation_password)
|
||||
is Other -> state.message ?: stringResource(R.string.auth_signup_validation_password_input_invalid)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val confirmPasswordError = when (state) {
|
||||
is ConfirmPasswordMissMatch -> stringResource(R.string.auth_signup_validation_passwords_do_not_match)
|
||||
is Other -> state.message ?: stringResource(R.string.auth_signup_validation_password_input_invalid)
|
||||
else -> null
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
@@ -107,12 +137,7 @@ fun CreatePasswordScreen(
|
||||
ProtonTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
painterResource(id = me.proton.core.presentation.R.drawable.ic_proton_close),
|
||||
contentDescription = stringResource(id = R.string.auth_login_close)
|
||||
)
|
||||
}
|
||||
NavigationBackButton(onBackClicked = onBackClicked)
|
||||
},
|
||||
backgroundColor = LocalColors.current.backgroundNorm
|
||||
)
|
||||
@@ -131,35 +156,34 @@ fun CreatePasswordScreen(
|
||||
text = stringResource(id = R.string.auth_signup_create_password)
|
||||
)
|
||||
|
||||
ProtonPasswordOutlinedTextFieldWithError(
|
||||
text = password,
|
||||
onValueChanged = { password = it },
|
||||
PasswordField(
|
||||
password = password,
|
||||
onPasswordChanged = { password = it },
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
label = { Text(text = stringResource(id = R.string.auth_signup_password)) },
|
||||
errorText = passwordError,
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
|
||||
ProtonPasswordOutlinedTextFieldWithError(
|
||||
text = confirmPassword,
|
||||
onValueChanged = { confirmPassword = it },
|
||||
PasswordConfirmationField(
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChanged = { confirmPassword = it },
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
label = { Text(text = stringResource(id = R.string.auth_signup_repeat_password)) },
|
||||
errorText = confirmPasswordError,
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
loading = isLoading,
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight),
|
||||
onClick = { onPasswordSubmitted(CreatePasswordAction.Submit(password, confirmPassword)) }
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.auth_signup_next))
|
||||
}
|
||||
|
||||
NextButton(
|
||||
isLoading = isLoading,
|
||||
onClick = {
|
||||
onPasswordSubmitted(
|
||||
CreatePasswordAction.Perform(
|
||||
password = password,
|
||||
confirmPassword = confirmPassword
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
|
||||
TermsPolicyFooter()
|
||||
}
|
||||
@@ -167,6 +191,62 @@ fun CreatePasswordScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
password: String,
|
||||
onPasswordChanged: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
errorText: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ProtonPasswordOutlinedTextFieldWithError(
|
||||
text = password,
|
||||
onValueChanged = onPasswordChanged,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
label = { Text(text = stringResource(id = R.string.auth_signup_password)) },
|
||||
errorText = errorText,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordConfirmationField(
|
||||
confirmPassword: String,
|
||||
onConfirmPasswordChanged: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
errorText: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ProtonPasswordOutlinedTextFieldWithError(
|
||||
text = confirmPassword,
|
||||
onValueChanged = onConfirmPasswordChanged,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
label = { Text(text = stringResource(id = R.string.auth_signup_repeat_password)) },
|
||||
errorText = errorText,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextButton(
|
||||
isLoading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
loading = isLoading,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(ProtonDimens.DefaultButtonMinHeight),
|
||||
onClick = onClick
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.auth_signup_next))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TermsPolicyFooter() {
|
||||
LearnMoreText(text = R.string.auth_signup_terms_privacy_conditions_footer)
|
||||
@@ -195,6 +275,7 @@ fun LearnMoreText(@StringRes text: Int) {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@@ -204,6 +285,6 @@ fun LearnMoreText(@StringRes text: Int) {
|
||||
@Composable
|
||||
internal fun CreatePasswordScreenPreview() {
|
||||
ProtonTheme {
|
||||
CreatePasswordScreen()
|
||||
CreatePasswordContent()
|
||||
}
|
||||
}
|
||||
+167
-62
@@ -18,9 +18,11 @@
|
||||
|
||||
@file:Suppress("UseComposableActions")
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import java.util.Locale
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -34,8 +36,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Tab
|
||||
import androidx.compose.material.TabRow
|
||||
@@ -58,7 +58,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
@@ -73,6 +72,31 @@ import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.android.core.auth.presentation.challenge.SIGNUP_CHALLENGE_FLOW_NAME
|
||||
import me.proton.android.core.auth.presentation.challenge.SIGNUP_CHALLENGE_RECOVERY_FRAME
|
||||
import me.proton.android.core.auth.presentation.challenge.TextChange
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.CreateRecoveryClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.PickCountry
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.WantSkipRecovery
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.SelectRecoveryMethod
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.SubmitRecoveryEmail
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.SubmitRecoveryPhone
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Closed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.CountryPickerFailed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Creating
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Error
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Idle
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.OnCountryPicked
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.SkipFailed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.SkipSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Success
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.ValidationError
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.ValidationError.Email
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.ValidationError.Phone
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.WantCountryPicker
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.WantSkip
|
||||
import me.proton.android.core.auth.presentation.signup.RecoveryMethod
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.challenge.presentation.compose.PayloadController
|
||||
import me.proton.core.challenge.presentation.compose.payload
|
||||
import me.proton.core.compose.component.ProtonOutlinedTextFieldWithError
|
||||
@@ -95,42 +119,65 @@ import me.proton.core.compose.theme.defaultStrongNorm
|
||||
fun CreateRecoveryScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClicked: () -> Unit = {},
|
||||
onSkipClicked: () -> Unit = {},
|
||||
onWantSkip: () -> Unit = {},
|
||||
onCountryPickerClicked: (List<Country>) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (RecoveryMethod, String) -> Unit = { method, value -> },
|
||||
viewModel: CreateRecoveryViewModel = hiltViewModel()
|
||||
onSuccess: (String) -> Unit = { },
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
viewModel.perform(CreateRecoveryClosed(back = true))
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is Closed -> onBackClicked()
|
||||
is WantSkip -> onWantSkip()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CreateRecoveryScreen(
|
||||
modifier = modifier,
|
||||
onBackClicked = onBackClicked,
|
||||
onSkipClicked = onSkipClicked,
|
||||
onTabSelected = { viewModel.submit(CreateRecoveryAction.SelectCreateRecovery(it)) },
|
||||
onRecoverySubmitted = { viewModel.submit(it) },
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = { recoveryMethod, value ->
|
||||
onSuccess(recoveryMethod, value)
|
||||
viewModel.submit(CreateRecoveryAction.SetNavigationDone)
|
||||
onBackClicked = { viewModel.perform(CreateRecoveryClosed(back = true)) },
|
||||
onWantSkip = { viewModel.perform(WantSkipRecovery) },
|
||||
onCountryPickerClicked = onCountryPickerClicked,
|
||||
onTabSelected = {
|
||||
viewModel.perform(
|
||||
SelectRecoveryMethod(
|
||||
recoveryMethod = it,
|
||||
locale = Locale.getDefault().country
|
||||
)
|
||||
)
|
||||
},
|
||||
onRecoverySubmitted = { viewModel.perform(it) },
|
||||
onCountryPicker = { viewModel.perform(PickCountry()) },
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = { route -> onSuccess(route) },
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRecoveryScreen(
|
||||
state: CreateRecoveryState,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClicked: () -> Unit = {},
|
||||
onSkipClicked: () -> Unit = {},
|
||||
onWantSkip: () -> Unit = {},
|
||||
onCountryPickerClicked: (List<Country>) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onTabSelected: (RecoveryMethod) -> Unit = {},
|
||||
onRecoverySubmitted: (CreateRecoveryAction) -> Unit = {},
|
||||
onSuccess: (RecoveryMethod, String) -> Unit = { recovery, method -> }
|
||||
onCountryPicker: () -> Unit = {},
|
||||
onSuccess: (String) -> Unit = { },
|
||||
state: SignUpState
|
||||
) {
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is CreateRecoveryState.FormError -> onErrorMessage(state.message)
|
||||
is CreateRecoveryState.Success -> onSuccess(state.recoveryMethod, state.value)
|
||||
is ValidationError -> onErrorMessage(state.message)
|
||||
is Success -> onSuccess(state.route)
|
||||
is WantCountryPicker -> onCountryPickerClicked(state.countries)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -138,9 +185,10 @@ fun CreateRecoveryScreen(
|
||||
RecoveryMethodScaffold(
|
||||
modifier = modifier,
|
||||
onBackClicked = onBackClicked,
|
||||
onSkipClicked = onSkipClicked,
|
||||
onWantSkipClicked = onWantSkip,
|
||||
onRecoverySubmitted = onRecoverySubmitted,
|
||||
onTabSelected = onTabSelected,
|
||||
onCountryPicker = onCountryPicker,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
@@ -149,14 +197,18 @@ fun CreateRecoveryScreen(
|
||||
fun RecoveryMethodScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClicked: () -> Unit = {},
|
||||
onSkipClicked: () -> Unit = {},
|
||||
onWantSkipClicked: () -> Unit = {},
|
||||
onTabSelected: (RecoveryMethod) -> Unit = {},
|
||||
onRecoverySubmitted: (CreateRecoveryAction) -> Unit = {},
|
||||
state: CreateRecoveryState
|
||||
onCountryPicker: () -> Unit = {},
|
||||
state: SignUpState
|
||||
) {
|
||||
val isLoading = state is CreateRecoveryState.Loading
|
||||
val emailError = (state as? CreateRecoveryState.FormError.Email)?.message
|
||||
val phoneError = (state as? CreateRecoveryState.FormError.Phone)?.message
|
||||
val isLoading = state is Creating
|
||||
val emailError = (state as? Email)?.message
|
||||
val phoneError = (state as? Phone)?.message
|
||||
val selectedCountry = (state as? CreateRecoveryState)?.country()
|
||||
|
||||
val currentMethod = (state as? CreateRecoveryState)?.currentMethod() ?: RecoveryMethod.Email
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
@@ -164,16 +216,11 @@ fun RecoveryMethodScaffold(
|
||||
ProtonTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
painterResource(id = me.proton.core.presentation.R.drawable.ic_proton_close),
|
||||
contentDescription = stringResource(id = R.string.auth_login_close)
|
||||
)
|
||||
}
|
||||
NavigationBackButton(onBackClicked = onBackClicked)
|
||||
},
|
||||
actions = {
|
||||
ProtonTextButton(
|
||||
onClick = onSkipClicked
|
||||
onClick = onWantSkipClicked
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.auth_signup_recovery_skip),
|
||||
@@ -202,7 +249,7 @@ fun RecoveryMethodScaffold(
|
||||
Text(
|
||||
text = stringResource(id = R.string.auth_signup_recovery_subtitle),
|
||||
style = ProtonTypography.Default.defaultSmallWeak,
|
||||
textAlign = TextAlign.Center,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = SmallSpacing)
|
||||
@@ -211,35 +258,58 @@ fun RecoveryMethodScaffold(
|
||||
RecoveryTabs(
|
||||
modifier = modifier,
|
||||
tabs = listOf("Email", "Phone"),
|
||||
initialSelectedMethod = currentMethod,
|
||||
onTabSelected = {
|
||||
onTabSelected(RecoveryMethod.enumOf(it))
|
||||
}
|
||||
)
|
||||
|
||||
when (state.recoveryMethod) {
|
||||
RecoveryMethod.Email -> RecoveryMethodFormEmail(
|
||||
loading = isLoading,
|
||||
emailError = emailError,
|
||||
onEmailSubmitted = onRecoverySubmitted
|
||||
)
|
||||
|
||||
RecoveryMethod.Phone -> RecoveryMethodFormPhone(
|
||||
loading = isLoading,
|
||||
data = state.countries ?: emptyList(),
|
||||
emailError = phoneError,
|
||||
onPhoneSubmitted = onRecoverySubmitted
|
||||
)
|
||||
}
|
||||
RecoveryMethodsForms(
|
||||
currentMethod = currentMethod,
|
||||
isLoading = isLoading,
|
||||
emailError = emailError,
|
||||
phoneError = phoneError,
|
||||
selectedCountry = selectedCountry,
|
||||
onRecoverySubmitted = onRecoverySubmitted,
|
||||
onCountryPicker = onCountryPicker
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecoveryMethodsForms(
|
||||
currentMethod: RecoveryMethod,
|
||||
isLoading: Boolean,
|
||||
emailError: String?,
|
||||
phoneError: String?,
|
||||
selectedCountry: Country?,
|
||||
onRecoverySubmitted: (CreateRecoveryAction) -> Unit,
|
||||
onCountryPicker: () -> Unit
|
||||
) {
|
||||
when (currentMethod) {
|
||||
RecoveryMethod.Email -> RecoveryMethodFormEmail(
|
||||
loading = isLoading,
|
||||
emailError = emailError,
|
||||
onEmailSubmitted = onRecoverySubmitted
|
||||
)
|
||||
|
||||
RecoveryMethod.Phone -> RecoveryMethodFormPhone(
|
||||
loading = isLoading,
|
||||
selectedCountry = selectedCountry,
|
||||
emailError = phoneError,
|
||||
onPhoneSubmitted = onRecoverySubmitted,
|
||||
onCountryPicker = onCountryPicker
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecoveryMethodFormEmail(
|
||||
loading: Boolean = false,
|
||||
emailError: String? = null,
|
||||
onEmailSubmitted: (CreateRecoveryAction.SubmitEmail) -> Unit = {}
|
||||
onEmailSubmitted: (SubmitRecoveryEmail) -> Unit = {}
|
||||
) {
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -250,7 +320,7 @@ fun RecoveryMethodFormEmail(
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val frameDetails = emailPayloadController.flush()
|
||||
onEmailSubmitted(CreateRecoveryAction.SubmitEmail(email, frameDetails))
|
||||
onEmailSubmitted(SubmitRecoveryEmail(email = email, recoveryFrameDetails = frameDetails))
|
||||
}
|
||||
|
||||
ProtonOutlinedTextFieldWithError(
|
||||
@@ -295,10 +365,11 @@ fun RecoveryMethodFormEmail(
|
||||
fun RecoveryMethodFormPhone(
|
||||
emailError: String? = null,
|
||||
loading: Boolean = false,
|
||||
data: List<Country> = emptyList(),
|
||||
onPhoneSubmitted: (CreateRecoveryAction.SubmitPhone) -> Unit
|
||||
selectedCountry: Country?,
|
||||
onPhoneSubmitted: (SubmitRecoveryPhone) -> Unit,
|
||||
onCountryPicker: () -> Unit
|
||||
) {
|
||||
var callingCode by rememberSaveable { mutableStateOf("") }
|
||||
var callingCode by rememberSaveable { mutableStateOf(selectedCountry?.callingCode?.toString() ?: "") }
|
||||
var phoneNumber by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -307,9 +378,19 @@ fun RecoveryMethodFormPhone(
|
||||
val phonePayloadController = remember { PayloadController() }
|
||||
val phoneTextCopies = remember { MutableStateFlow("") }
|
||||
|
||||
LaunchedEffect(selectedCountry) {
|
||||
callingCode = selectedCountry?.callingCode?.toString() ?: ""
|
||||
}
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val frameDetails = phonePayloadController.flush()
|
||||
onPhoneSubmitted(CreateRecoveryAction.SubmitPhone(callingCode, phoneNumber, frameDetails))
|
||||
onPhoneSubmitted(
|
||||
SubmitRecoveryPhone(
|
||||
callingCode = callingCode,
|
||||
phoneNumber = phoneNumber,
|
||||
recoveryFrameDetails = frameDetails
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -324,9 +405,8 @@ fun RecoveryMethodFormPhone(
|
||||
modifier = Modifier
|
||||
.padding(top = LargeSpacing)
|
||||
.height(TextFieldDefaults.MinHeight),
|
||||
isLoading = loading,
|
||||
data = data,
|
||||
onInputChanged = { callingCode = it.callingCode.toString() } // int?
|
||||
country = selectedCountry,
|
||||
onNavigateToCountryPicker = onCountryPicker
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,9 +458,10 @@ fun RecoveryMethodFormPhone(
|
||||
fun RecoveryTabs(
|
||||
modifier: Modifier = Modifier,
|
||||
tabs: List<String>,
|
||||
initialSelectedMethod: RecoveryMethod = RecoveryMethod.Email,
|
||||
onTabSelected: (Int) -> Unit = {}
|
||||
) {
|
||||
var selectedIndex by remember { mutableIntStateOf(0) }
|
||||
var selectedIndex by remember { mutableIntStateOf(initialSelectedMethod.value) }
|
||||
|
||||
TabRow(
|
||||
modifier = modifier
|
||||
@@ -401,7 +482,7 @@ fun RecoveryTabs(
|
||||
selected = selectedIndex == index,
|
||||
onClick = {
|
||||
selectedIndex = index
|
||||
onTabSelected(selectedIndex)
|
||||
onTabSelected(index)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
@@ -414,6 +495,29 @@ fun RecoveryTabs(
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreateRecoveryState.country(): Country? = when (this) {
|
||||
is OnCountryPicked -> country
|
||||
is CountryPickerFailed -> country
|
||||
is Idle -> defaultCountry
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun CreateRecoveryState.currentMethod() = when (this) {
|
||||
is Idle -> recoveryMethod
|
||||
is WantCountryPicker -> recoveryMethod
|
||||
is OnCountryPicked -> recoveryMethod
|
||||
is Creating -> recoveryMethod
|
||||
is Error -> recoveryMethod
|
||||
is Success -> recoveryMethod
|
||||
is Email -> RecoveryMethod.Email
|
||||
is Phone -> RecoveryMethod.Phone
|
||||
is WantSkip -> recoveryMethod
|
||||
is SkipSuccess -> recoveryMethod
|
||||
is SkipFailed -> recoveryMethod
|
||||
is CountryPickerFailed -> recoveryMethod
|
||||
Closed -> RecoveryMethod.Email // default
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@@ -438,12 +542,13 @@ internal fun RecoveryMethodScreenPreview() {
|
||||
internal fun CreateRecoveryPhoneScreenPreview() {
|
||||
ProtonTheme {
|
||||
CreateRecoveryScreen(
|
||||
state = CreateRecoveryState.Idle(
|
||||
RecoveryMethod.Phone,
|
||||
state = Idle(
|
||||
recoveryMethod = RecoveryMethod.Phone,
|
||||
listOf(
|
||||
Country("CH", 41, "Switzerland"),
|
||||
Country("US", 1, "US")
|
||||
)
|
||||
),
|
||||
defaultCountry = Country("CH", 41, "Switzerland")
|
||||
)
|
||||
)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.RecoverySkipped
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.WantSkipDialogClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.SkipFailed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.SkipSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.WantSkip
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.compose.component.ProtonAlertDialog
|
||||
import me.proton.core.compose.component.ProtonAlertDialogButton
|
||||
import me.proton.core.compose.component.ProtonAlertDialogText
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
|
||||
@Composable
|
||||
fun CreateRecoverySkipDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = { },
|
||||
onSkip: (String) -> Unit = { },
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val currentState = state
|
||||
LaunchedEffect(currentState) {
|
||||
when (currentState) {
|
||||
is SkipSuccess -> onSkip(currentState.route)
|
||||
is SkipFailed -> onCloseClicked()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is WantSkip -> {
|
||||
CreateRecoverySkipDialog(
|
||||
modifier = modifier,
|
||||
onCloseClicked = { viewModel.perform(WantSkipDialogClosed) },
|
||||
onSkipClicked = { viewModel.perform(RecoverySkipped) }
|
||||
)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRecoverySkipDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = { },
|
||||
onSkipClicked: () -> Unit = { }
|
||||
) {
|
||||
ProtonAlertDialog(
|
||||
modifier = modifier,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = false,
|
||||
dismissOnClickOutside = false,
|
||||
securePolicy = SecureFlagPolicy.Inherit
|
||||
),
|
||||
title = stringResource(R.string.auth_signup_skip_recovery_title),
|
||||
text = { ProtonAlertDialogText(stringResource(R.string.auth_signup_skip_recovery_description)) },
|
||||
onDismissRequest = { },
|
||||
confirmButton = {
|
||||
Column {
|
||||
ProtonAlertDialogButton(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
title = stringResource(R.string.auth_signup_skip_recovery),
|
||||
onClick = onSkipClicked,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Column {
|
||||
ProtonAlertDialogButton(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
title = stringResource(R.string.auth_signup_set_recovery),
|
||||
onClick = onCloseClicked,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
|
||||
@Composable
|
||||
internal fun ChangePasswordDialogPreview() {
|
||||
ProtonTheme {
|
||||
CreateRecoverySkipDialog()
|
||||
}
|
||||
}
|
||||
+693
@@ -0,0 +1,693 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("UseComposableActions")
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material.Divider
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
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.rememberCoroutineScope
|
||||
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.focus.FocusState
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.android.core.auth.presentation.challenge.SIGNUP_CHALLENGE_FLOW_NAME
|
||||
import me.proton.android.core.auth.presentation.challenge.SIGNUP_CHALLENGE_USERNAME_FRAME
|
||||
import me.proton.android.core.auth.presentation.challenge.TextChange
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.CreateExternalAccount
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.CreateInternalAccount
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.CreateUsernameClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.LoadData
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.Perform
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.challenge.presentation.compose.LocalClipManager
|
||||
import me.proton.core.challenge.presentation.compose.LocalClipManager.OnClipChangedDisposableEffect
|
||||
import me.proton.core.challenge.presentation.compose.PayloadController
|
||||
import me.proton.core.challenge.presentation.compose.payload
|
||||
import me.proton.core.compose.component.ProtonCloseButton
|
||||
import me.proton.core.compose.component.ProtonOutlinedTextFieldWithError
|
||||
import me.proton.core.compose.component.ProtonSolidButton
|
||||
import me.proton.core.compose.component.ProtonTextButton
|
||||
import me.proton.core.compose.component.appbar.ProtonTopAppBar
|
||||
import me.proton.core.compose.theme.LocalColors
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonDimens.DefaultSpacing
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultSmallWeak
|
||||
|
||||
internal const val USERNAME_FIELD_TAG = "USERNAME_FIELD_TAG"
|
||||
internal const val EMAIL_FIELD_TAG = "EMAIL_FIELD_TAG"
|
||||
internal const val PHONE_FIELD_TAG = "PHONE_FIELD_TAG"
|
||||
|
||||
@Composable
|
||||
fun CreateUsernameScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onBackClicked: () -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (String) -> Unit = {},
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
viewModel.perform(CreateUsernameClosed(back = true))
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is CreateUsernameState.Closed -> onBackClicked()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CreateUsernameScreen(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onUsernameSubmitted = { viewModel.perform(it) },
|
||||
onCreateExternalClicked = { viewModel.perform(it) },
|
||||
onCreateInternalClicked = { viewModel.perform(it) },
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = { signupState -> onSuccess(signupState) },
|
||||
onLoad = { viewModel.perform(LoadData(it)) },
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateUsernameScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onUsernameSubmitted: (Perform) -> Unit = {},
|
||||
onCreateExternalClicked: (CreateExternalAccount) -> Unit = {},
|
||||
onCreateInternalClicked: (CreateInternalAccount) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: (String) -> Unit = {},
|
||||
onLoad: (AccountType) -> Unit = { },
|
||||
state: SignUpState
|
||||
) {
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is CreateUsernameState.Idle -> {}
|
||||
is CreateUsernameState.Error -> onErrorMessage(state.message)
|
||||
is CreateUsernameState.Success -> onSuccess(state.route)
|
||||
is CreateUsernameState.Load -> onLoad(state.accountType)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
UsernameScreenScaffold(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onUsernameSubmitted = onUsernameSubmitted,
|
||||
onCreateExternalClicked = onCreateExternalClicked,
|
||||
onCreateInternalClicked = onCreateInternalClicked,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameScreenScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onUsernameSubmitted: (Perform) -> Unit = {},
|
||||
onCreateExternalClicked: (CreateExternalAccount) -> Unit,
|
||||
onCreateInternalClicked: (CreateInternalAccount) -> Unit,
|
||||
@DrawableRes protonLogo: Int = R.drawable.ic_logo_proton,
|
||||
@StringRes titleText: Int = R.string.auth_signup_title,
|
||||
state: SignUpState
|
||||
) {
|
||||
val isLoading = (state as? CreateUsernameState)?.isLoading ?: false
|
||||
val accountType = (state as? CreateUsernameState)?.accountType ?: AccountType.Internal // default
|
||||
var domains by rememberSaveable { mutableStateOf<List<Domain>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state is CreateUsernameState.LoadingComplete) {
|
||||
domains = state.domains ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
ProtonTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
ProtonCloseButton(onCloseClicked = onCloseClicked)
|
||||
},
|
||||
backgroundColor = LocalColors.current.backgroundNorm
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.SmallSpacing)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
ScreenHeader(
|
||||
logoResource = protonLogo,
|
||||
titleTextResource = titleText
|
||||
)
|
||||
|
||||
when (accountType) {
|
||||
AccountType.Internal -> {
|
||||
val validationError = when (state) {
|
||||
is CreateUsernameState.ValidationError.InternalUsernameEmpty ->
|
||||
stringResource(R.string.auth_signup_validation_username)
|
||||
|
||||
is CreateUsernameState.ValidationError.Other ->
|
||||
state.message ?: stringResource(R.string.auth_signup_validation_username_input_invalid)
|
||||
|
||||
else -> null
|
||||
}
|
||||
InternalAccountForm(
|
||||
enabled = !isLoading,
|
||||
isLoading = isLoading,
|
||||
onUsernameSubmitted = onUsernameSubmitted,
|
||||
onCreateExternalClicked = onCreateExternalClicked,
|
||||
validationError = validationError,
|
||||
domains = domains
|
||||
)
|
||||
}
|
||||
|
||||
AccountType.External -> {
|
||||
val validationError = when (state) {
|
||||
is CreateUsernameState.ValidationError.EmailEmpty ->
|
||||
stringResource(R.string.auth_signup_validation_email)
|
||||
|
||||
is CreateUsernameState.ValidationError.Other ->
|
||||
state.message ?: stringResource(R.string.auth_signup_validation_email_input_invalid)
|
||||
|
||||
else -> null
|
||||
}
|
||||
ExternalAccountForm(
|
||||
enabled = !isLoading,
|
||||
onExternalEmailSubmitted = onUsernameSubmitted,
|
||||
onCreateInternalClicked = onCreateInternalClicked,
|
||||
validationError = validationError
|
||||
)
|
||||
}
|
||||
|
||||
AccountType.Username -> {
|
||||
val validationError = when (state) {
|
||||
is CreateUsernameState.ValidationError.UsernameEmpty ->
|
||||
stringResource(R.string.auth_signup_validation_username)
|
||||
|
||||
is CreateUsernameState.ValidationError.Other ->
|
||||
state.message ?: stringResource(R.string.auth_signup_validation_username_input_invalid)
|
||||
|
||||
else -> null
|
||||
}
|
||||
UsernameForm(
|
||||
enabled = !isLoading,
|
||||
isLoading = isLoading,
|
||||
onUsernameSubmitted = onUsernameSubmitted,
|
||||
validationError = validationError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenHeader(@DrawableRes logoResource: Int, @StringRes titleTextResource: Int) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.height(64.dp)
|
||||
.fillMaxWidth(),
|
||||
painter = painterResource(logoResource),
|
||||
contentDescription = null,
|
||||
alignment = Alignment.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(titleTextResource),
|
||||
style = ProtonTypography.Default.headline,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsernameForm(
|
||||
enabled: Boolean,
|
||||
isLoading: Boolean,
|
||||
onUsernameSubmitted: (Perform) -> Unit,
|
||||
validationError: String?,
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_subtitle
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
val usernameChanges = remember { MutableStateFlow(TextChange()) }
|
||||
val usernameHasFocus = remember { mutableStateOf(false) }
|
||||
val usernamePayloadController = remember { PayloadController() }
|
||||
val usernameTextCopies = remember { MutableStateFlow("") }
|
||||
|
||||
LocalClipManager.current?.OnClipChangedDisposableEffect {
|
||||
if (usernameHasFocus.value) usernameTextCopies.value = it
|
||||
}
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val usernameFrameDetails = usernamePayloadController.flush()
|
||||
onUsernameSubmitted(
|
||||
Perform(
|
||||
value = username,
|
||||
domain = null,
|
||||
accountType = AccountType.Username,
|
||||
usernameFrameDetails = usernameFrameDetails
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
FormSubtitle(subtitleText)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(DefaultSpacing)
|
||||
) {
|
||||
UsernameTextField(
|
||||
username = username,
|
||||
onUsernameChanged = {
|
||||
usernameChanges.value = usernameChanges.value.roll(it)
|
||||
username = it
|
||||
},
|
||||
enabled = enabled,
|
||||
errorText = validationError,
|
||||
onFocusChanged = { usernameHasFocus.value = it.hasFocus },
|
||||
usernameChanges = usernameChanges,
|
||||
usernameTextCopies = usernameTextCopies,
|
||||
usernamePayloadController = usernamePayloadController
|
||||
)
|
||||
|
||||
NextButton(
|
||||
enabled = enabled,
|
||||
isLoading = isLoading,
|
||||
onClick = ::onSubmit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalAccountForm(
|
||||
enabled: Boolean,
|
||||
isLoading: Boolean,
|
||||
onUsernameSubmitted: (Perform) -> Unit,
|
||||
onCreateExternalClicked: (CreateExternalAccount) -> Unit,
|
||||
validationError: String?,
|
||||
domains: List<Domain>,
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_subtitle
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var domain by rememberSaveable { mutableStateOf("") }
|
||||
val usernameChanges = remember { MutableStateFlow(TextChange()) }
|
||||
val usernameHasFocus = remember { mutableStateOf(false) }
|
||||
val usernamePayloadController = remember { PayloadController() }
|
||||
val usernameTextCopies = remember { MutableStateFlow("") }
|
||||
|
||||
LocalClipManager.current?.OnClipChangedDisposableEffect {
|
||||
if (usernameHasFocus.value) usernameTextCopies.value = it
|
||||
}
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val usernameFrameDetails = usernamePayloadController.flush()
|
||||
onUsernameSubmitted(
|
||||
Perform(
|
||||
value = username,
|
||||
domain = domain,
|
||||
accountType = AccountType.Internal,
|
||||
usernameFrameDetails = usernameFrameDetails
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
FormSubtitle(subtitleText)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(DefaultSpacing)
|
||||
) {
|
||||
UsernameTextField(
|
||||
username = username,
|
||||
onUsernameChanged = {
|
||||
usernameChanges.value = usernameChanges.value.roll(it)
|
||||
username = it
|
||||
},
|
||||
enabled = enabled,
|
||||
errorText = validationError,
|
||||
onFocusChanged = { usernameHasFocus.value = it.hasFocus },
|
||||
usernameChanges = usernameChanges,
|
||||
usernameTextCopies = usernameTextCopies,
|
||||
usernamePayloadController = usernamePayloadController
|
||||
)
|
||||
|
||||
DomainDropDown(
|
||||
isLoading = isLoading,
|
||||
data = domains,
|
||||
onInputChanged = { domain = it ?: "" }
|
||||
)
|
||||
|
||||
NextButton(
|
||||
enabled = enabled,
|
||||
isLoading = isLoading,
|
||||
onClick = ::onSubmit
|
||||
)
|
||||
|
||||
FormDivider()
|
||||
|
||||
AlternateAccountOption(
|
||||
onClick = { onCreateExternalClicked(CreateExternalAccount) },
|
||||
textRes = R.string.auth_signup_use_current_email
|
||||
)
|
||||
|
||||
FormFootnote(R.string.auth_signup_internal_footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExternalAccountForm(
|
||||
enabled: Boolean,
|
||||
onExternalEmailSubmitted: (Perform) -> Unit,
|
||||
onCreateInternalClicked: (CreateInternalAccount) -> Unit,
|
||||
validationError: String?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
val emailChanges = remember { MutableStateFlow(TextChange()) }
|
||||
val emailHasFocus = remember { mutableStateOf(false) }
|
||||
val emailPayloadController = remember { PayloadController() }
|
||||
val emailTextCopies = remember { MutableStateFlow("") }
|
||||
|
||||
LocalClipManager.current?.OnClipChangedDisposableEffect {
|
||||
if (emailHasFocus.value) emailTextCopies.value = it
|
||||
}
|
||||
|
||||
fun onSubmit() = scope.launch {
|
||||
val emailFrameDetails = emailPayloadController.flush()
|
||||
onExternalEmailSubmitted(
|
||||
Perform(
|
||||
value = email,
|
||||
domain = null,
|
||||
accountType = AccountType.External,
|
||||
usernameFrameDetails = emailFrameDetails
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(DefaultSpacing)
|
||||
) {
|
||||
EmailTextField(
|
||||
email = email,
|
||||
onEmailChanged = {
|
||||
emailChanges.value = emailChanges.value.roll(it)
|
||||
email = it
|
||||
},
|
||||
enabled = enabled,
|
||||
errorText = validationError,
|
||||
onFocusChanged = { emailHasFocus.value = it.hasFocus },
|
||||
emailChanges = emailChanges,
|
||||
emailTextCopies = emailTextCopies,
|
||||
emailPayloadController = emailPayloadController
|
||||
)
|
||||
|
||||
NextButton(
|
||||
enabled = enabled,
|
||||
isLoading = !enabled,
|
||||
onClick = ::onSubmit
|
||||
)
|
||||
|
||||
FormDivider()
|
||||
|
||||
AlternateAccountOption(
|
||||
onClick = { onCreateInternalClicked(CreateInternalAccount) },
|
||||
textRes = R.string.auth_signup_get_encrypted_email
|
||||
)
|
||||
|
||||
FormFootnote(R.string.auth_signup_external_footnote)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsernameTextField(
|
||||
username: String,
|
||||
onUsernameChanged: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
errorText: String?,
|
||||
onFocusChanged: (FocusState) -> Unit,
|
||||
usernameChanges: MutableStateFlow<TextChange>,
|
||||
usernameTextCopies: MutableStateFlow<String>,
|
||||
usernamePayloadController: PayloadController
|
||||
) {
|
||||
ProtonOutlinedTextFieldWithError(
|
||||
text = username,
|
||||
onValueChanged = onUsernameChanged,
|
||||
enabled = enabled,
|
||||
errorText = errorText,
|
||||
label = { Text(text = stringResource(id = R.string.auth_signup_email_username)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.onFocusChanged(onFocusChanged)
|
||||
.fillMaxWidth()
|
||||
.padding(top = DefaultSpacing)
|
||||
.payload(
|
||||
flow = SIGNUP_CHALLENGE_FLOW_NAME,
|
||||
frame = SIGNUP_CHALLENGE_USERNAME_FRAME,
|
||||
onTextChanged = usernameChanges.map { it.toPair() },
|
||||
onTextCopied = usernameTextCopies,
|
||||
onFrameUpdated = {},
|
||||
payloadController = usernamePayloadController
|
||||
)
|
||||
.testTag(USERNAME_FIELD_TAG)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmailTextField(
|
||||
email: String,
|
||||
onEmailChanged: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
errorText: String?,
|
||||
onFocusChanged: (FocusState) -> Unit,
|
||||
emailChanges: MutableStateFlow<TextChange>,
|
||||
emailTextCopies: MutableStateFlow<String>,
|
||||
emailPayloadController: PayloadController
|
||||
) {
|
||||
ProtonOutlinedTextFieldWithError(
|
||||
text = email,
|
||||
onValueChanged = onEmailChanged,
|
||||
enabled = enabled,
|
||||
errorText = errorText,
|
||||
label = { Text(text = stringResource(id = R.string.auth_email)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.onFocusChanged(onFocusChanged)
|
||||
.fillMaxWidth()
|
||||
.padding(top = DefaultSpacing)
|
||||
.payload(
|
||||
flow = SIGNUP_CHALLENGE_FLOW_NAME,
|
||||
frame = SIGNUP_CHALLENGE_USERNAME_FRAME,
|
||||
onTextChanged = emailChanges.map { it.toPair() },
|
||||
onTextCopied = emailTextCopies,
|
||||
onFrameUpdated = {},
|
||||
payloadController = emailPayloadController
|
||||
)
|
||||
.testTag(EMAIL_FIELD_TAG)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormSubtitle(@StringRes textRes: Int) {
|
||||
Text(
|
||||
text = stringResource(textRes),
|
||||
style = ProtonTypography.Default.defaultSmallWeak,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = ProtonDimens.SmallSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextButton(
|
||||
enabled: Boolean,
|
||||
isLoading: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
enabled = enabled,
|
||||
loading = isLoading,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(R.string.auth_signup_next))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormDivider() {
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
|
||||
color = LocalColors.current.separatorNorm
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlternateAccountOption(onClick: () -> Unit, @StringRes textRes: Int) {
|
||||
ProtonTextButton(
|
||||
contained = false,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(textRes))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormFootnote(@StringRes textRes: Int) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(id = textRes),
|
||||
style = ProtonTypography.Default.defaultSmallWeak
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateUsernameScreenPreview() {
|
||||
ProtonTheme {
|
||||
CreateUsernameScreen(
|
||||
state = CreateUsernameState.Load(AccountType.Internal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateInternalPreview() {
|
||||
ProtonTheme {
|
||||
InternalAccountForm(
|
||||
enabled = true,
|
||||
isLoading = false,
|
||||
onUsernameSubmitted = {},
|
||||
onCreateExternalClicked = {},
|
||||
validationError = null,
|
||||
domains = listOf("protonmail.com", "protonmail.ch")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateExternalPreview() {
|
||||
ProtonTheme {
|
||||
ExternalAccountForm(
|
||||
enabled = true,
|
||||
onExternalEmailSubmitted = {},
|
||||
onCreateInternalClicked = {},
|
||||
validationError = "Email is empty"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun CreateUsernamePreview() {
|
||||
ProtonTheme {
|
||||
UsernameForm(
|
||||
enabled = true,
|
||||
isLoading = false,
|
||||
validationError = null,
|
||||
onUsernameSubmitted = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
@file:OptIn(ExperimentalMaterialApi::class)
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -51,13 +51,14 @@ typealias Domain = String
|
||||
fun DomainDropDown(
|
||||
isLoading: Boolean = false,
|
||||
data: List<Domain> = emptyList(),
|
||||
onInputChanged: (Domain) -> Unit = {}
|
||||
onInputChanged: (Domain?) -> Unit = {}
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var selected by remember { mutableStateOf<Domain?>(null) }
|
||||
|
||||
if (selected == null) {
|
||||
selected = data.firstOrNull()
|
||||
onInputChanged(selected)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import ch.protonmail.android.design.compose.theme.ProtonTheme
|
||||
import me.proton.core.presentation.R
|
||||
|
||||
@Composable
|
||||
fun NavigationBackButton(modifier: Modifier = Modifier, onBackClicked: () -> Unit = {}) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
onClick = onBackClicked
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow_back),
|
||||
tint = ProtonTheme.colors.iconNorm,
|
||||
contentDescription = stringResource(id = R.string.presentation_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ProtonCloseButtonPreview() {
|
||||
me.proton.core.compose.theme.ProtonTheme {
|
||||
NavigationBackButton()
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpAction.FinalizeSignup
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.LoginSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SignUpError
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.compose.component.ProtonSolidButton
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultSmallWeak
|
||||
|
||||
@Composable
|
||||
fun SignUpCongratsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onStartUsingApp: (String) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
@DrawableRes congratsLogo: Int = R.drawable.ic_congratulations,
|
||||
@StringRes titleText: Int = R.string.auth_signup_congratulations_title,
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_congratulations_subtitle,
|
||||
@StringRes buttonText: Int = R.string.auth_signup_start_using_proton,
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val currentState = state
|
||||
LaunchedEffect(currentState) {
|
||||
when (currentState) {
|
||||
is LoginSuccess -> onStartUsingApp(currentState.userId)
|
||||
is SignUpError -> onErrorMessage(currentState.message)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
SignUpCongratsScreen(
|
||||
modifier = modifier,
|
||||
congratsLogo = congratsLogo,
|
||||
titleText = titleText,
|
||||
subtitleText = subtitleText,
|
||||
buttonText = buttonText,
|
||||
onStartClicked = { viewModel.perform(FinalizeSignup) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignUpCongratsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onStartClicked: () -> Unit = {},
|
||||
@DrawableRes congratsLogo: Int = R.drawable.ic_congratulations,
|
||||
@StringRes titleText: Int = R.string.auth_signup_congratulations_title,
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_congratulations_subtitle,
|
||||
@StringRes buttonText: Int = R.string.auth_signup_start_using_proton
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ProtonDimens.DefaultSpacing),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.width(160.dp)
|
||||
.height(160.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
painter = painterResource(congratsLogo),
|
||||
contentDescription = null,
|
||||
alignment = Alignment.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(titleText),
|
||||
style = ProtonTypography.Default.headline,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(subtitleText),
|
||||
style = ProtonTypography.Default.defaultSmallWeak,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = ProtonDimens.SmallSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
onClick = onStartClicked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = ProtonDimens.LargeSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight)
|
||||
) {
|
||||
Text(text = stringResource(buttonText))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light mode", showBackground = true)
|
||||
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "Small screen height", heightDp = SMALL_SCREEN_HEIGHT)
|
||||
@Preview(name = "Foldable", device = Devices.PIXEL_FOLD)
|
||||
@Preview(name = "Tablet", device = Devices.PIXEL_C)
|
||||
@Preview(name = "Horizontal", widthDp = 800, heightDp = 360)
|
||||
@Composable
|
||||
internal fun SignUpCongratsPreview() {
|
||||
ProtonTheme {
|
||||
SignUpCongratsScreen()
|
||||
}
|
||||
}
|
||||
+55
-4
@@ -16,7 +16,7 @@
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.StringRes
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -39,23 +40,71 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import me.proton.android.core.auth.presentation.R
|
||||
import me.proton.android.core.auth.presentation.addaccount.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpAction
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SignUpError
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SignUpSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SigningUp
|
||||
import me.proton.android.core.auth.presentation.signup.viewmodel.SignUpViewModel
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
import me.proton.core.compose.theme.defaultSmallWeak
|
||||
|
||||
@Composable
|
||||
fun SignUpLoading(
|
||||
fun SignUpLoadingScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: () -> Unit = {},
|
||||
viewModel: SignUpViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is CreateRecoveryState.SkipSuccess,
|
||||
is CreateRecoveryState.Success -> {
|
||||
viewModel.perform(SignUpAction.CreateUser)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
SignUpLoadingScreen(
|
||||
modifier = modifier,
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = onSuccess,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignUpLoadingScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@StringRes titleText: Int = R.string.auth_signup_your_account_is_being_setup,
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_please_wait
|
||||
@StringRes subtitleText: Int = R.string.auth_signup_please_wait,
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: () -> Unit = {},
|
||||
state: SignUpState
|
||||
) {
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is SignUpSuccess -> onSuccess()
|
||||
is SignUpError -> onErrorMessage(state.message)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
@@ -104,6 +153,8 @@ fun SignUpLoading(
|
||||
@Composable
|
||||
internal fun SignUpLoadingPreview() {
|
||||
ProtonTheme {
|
||||
SignUpLoading()
|
||||
SignUpLoadingScreen(
|
||||
state = SigningUp
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup
|
||||
package me.proton.android.core.auth.presentation.signup.ui
|
||||
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.viewmodel
|
||||
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordAction.CreatePasswordClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordAction.LoadData
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordAction.Perform
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Closed
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Creating
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Error
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Idle
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.Success
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.ValidationError.PasswordEmpty
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState.ValidationError.ConfirmPasswordMissMatch
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.mapToNavigationRoute
|
||||
import uniffi.proton_account_uniffi.SignupException
|
||||
import uniffi.proton_account_uniffi.SignupFlow
|
||||
import uniffi.proton_account_uniffi.SignupFlowSubmitPasswordResult
|
||||
|
||||
/**
|
||||
* Handler responsible for password-related actions during signup process.
|
||||
*/
|
||||
class PasswordHandler private constructor(
|
||||
private val getFlow: suspend () -> SignupFlow
|
||||
) : ErrorHandler {
|
||||
|
||||
fun handleAction(action: CreatePasswordAction) = when (action) {
|
||||
is LoadData -> flowOf(Idle)
|
||||
is Perform -> handlePasswordSubmit(
|
||||
password = action.password,
|
||||
confirmPassword = action.confirmPassword
|
||||
)
|
||||
|
||||
is CreatePasswordClosed -> handleClose(action.back)
|
||||
}
|
||||
|
||||
private fun handlePasswordSubmit(password: String, confirmPassword: String) = flow {
|
||||
emit(Creating)
|
||||
|
||||
when (val result = getFlow().submitPassword(password, confirmPassword)) {
|
||||
is SignupFlowSubmitPasswordResult.Error -> {
|
||||
val state = when (result.v1) {
|
||||
is SignupException.PasswordEmpty -> PasswordEmpty
|
||||
|
||||
is SignupException.PasswordsNotMatching -> ConfirmPasswordMissMatch
|
||||
|
||||
else -> Error(message = result.v1.getErrorMessage())
|
||||
}
|
||||
emit(state)
|
||||
}
|
||||
|
||||
is SignupFlowSubmitPasswordResult.Ok -> {
|
||||
val route = result.v1.mapToNavigationRoute()
|
||||
emit(Success(route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClose(back: Boolean) = flow {
|
||||
if (back) {
|
||||
getFlow().stepBack()
|
||||
}
|
||||
emit(Closed)
|
||||
}
|
||||
|
||||
override fun handleError(throwable: Throwable): SignUpState = Error(message = throwable.message)
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(getFlow: suspend () -> SignupFlow) = PasswordHandler(getFlow)
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.CreateRecoveryClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.CountryPicked
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.CountryPickerClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.PickCountry
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.RecoverySkipped
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.WantSkipDialogClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.DialogAction.WantSkipRecovery
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.SelectRecoveryMethod
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.SubmitRecoveryEmail
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction.SubmitRecoveryPhone
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Closed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.CountryPickerFailed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Creating
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Error
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Idle
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.OnCountryPicked
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.SkipFailed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.SkipSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.Success
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.ValidationError.Email
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.ValidationError.Phone
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.WantCountryPicker
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState.WantSkip
|
||||
import me.proton.android.core.auth.presentation.signup.RecoveryMethod
|
||||
import me.proton.android.core.auth.presentation.signup.mapToNavigationRoute
|
||||
import me.proton.android.core.auth.presentation.signup.ui.Country
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
import uniffi.proton_account_uniffi.SignupFlow
|
||||
import uniffi.proton_account_uniffi.SignupFlowAvailableCountriesResult
|
||||
import uniffi.proton_account_uniffi.SignupFlowSkipRecoveryResult
|
||||
import uniffi.proton_account_uniffi.SignupFlowSubmitRecoveryEmailResult
|
||||
import uniffi.proton_account_uniffi.SignupFlowSubmitRecoveryPhoneResult
|
||||
import uniffi.proton_account_uniffi.Country as RustCountry
|
||||
|
||||
/**
|
||||
* Handler responsible for account recovery actions during signup process.
|
||||
*/
|
||||
class RecoveryHandler private constructor(
|
||||
private val getFlow: suspend () -> SignupFlow,
|
||||
private val coroutineDispatcher: CoroutineDispatcher
|
||||
) : ErrorHandler {
|
||||
|
||||
@Volatile
|
||||
private var cachedCountries: List<Country>? = null
|
||||
|
||||
@Volatile
|
||||
private var defaultCountry: Country? = null
|
||||
|
||||
@Volatile
|
||||
private var selectedRecoveryMethod: RecoveryMethod = RecoveryMethod.Email
|
||||
|
||||
@Volatile
|
||||
private var selectedCountry: Country? = null
|
||||
|
||||
fun handleAction(action: CreateRecoveryAction) = when (action) {
|
||||
is SelectRecoveryMethod ->
|
||||
handleSelectRecoveryMethod(action.recoveryMethod, action.locale)
|
||||
|
||||
is SubmitRecoveryEmail -> handleSubmitRecoveryEmail(
|
||||
email = action.email,
|
||||
recoveryFrameDetails = action.recoveryFrameDetails
|
||||
)
|
||||
|
||||
is SubmitRecoveryPhone -> handleSubmitRecoveryPhone(
|
||||
callingCode = action.callingCode,
|
||||
phoneNumber = action.phoneNumber,
|
||||
recoveryFrameDetails = action.recoveryFrameDetails
|
||||
)
|
||||
|
||||
is PickCountry -> handleCountryPicker()
|
||||
is CountryPicked -> handleCountryPicked(action.country)
|
||||
is CountryPickerClosed -> handleCountryDialogClose()
|
||||
|
||||
is WantSkipRecovery -> handleWantSkipRecovery()
|
||||
is RecoverySkipped -> handleRecoverySkipped()
|
||||
is WantSkipDialogClosed -> handleSkipDialogClose()
|
||||
|
||||
is CreateRecoveryClosed -> handleClose(action.back)
|
||||
}
|
||||
|
||||
private fun handleSelectRecoveryMethod(recoveryMethod: RecoveryMethod, localeFilter: String) = flow {
|
||||
if (recoveryMethod == RecoveryMethod.Phone) {
|
||||
if (cachedCountries.isNullOrEmpty()) {
|
||||
when (val result = getFlow().availableCountries(localeFilter)) {
|
||||
is SignupFlowAvailableCountriesResult.Error -> {}
|
||||
is SignupFlowAvailableCountriesResult.Ok -> {
|
||||
cachedCountries = result.v1.countries.map { it.toCountry() }
|
||||
defaultCountry = result.v1.defaultCountry?.toCountry()
|
||||
}
|
||||
}
|
||||
|
||||
selectedCountry = defaultCountry
|
||||
}
|
||||
}
|
||||
selectedRecoveryMethod = recoveryMethod
|
||||
emit(Idle(selectedRecoveryMethod, cachedCountries, selectedCountry))
|
||||
}
|
||||
|
||||
@Suppress("ForbiddenComment")
|
||||
private fun handleSubmitRecoveryEmail(email: String, recoveryFrameDetails: ChallengeFrameDetails) = flow {
|
||||
if (email.isBlank()) {
|
||||
emit(WantSkip(selectedRecoveryMethod))
|
||||
return@flow
|
||||
}
|
||||
|
||||
emit(Creating(selectedRecoveryMethod))
|
||||
// TODO: add challenge-fingerprinting
|
||||
|
||||
when (val result = getFlow().submitRecoveryEmail(email)) {
|
||||
is SignupFlowSubmitRecoveryEmailResult.Error -> {
|
||||
emit(Email(message = result.v1.getErrorMessage()))
|
||||
}
|
||||
|
||||
is SignupFlowSubmitRecoveryEmailResult.Ok -> {
|
||||
val route = result.v1.mapToNavigationRoute()
|
||||
emit(Success(selectedRecoveryMethod, email, route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ForbiddenComment")
|
||||
private fun handleSubmitRecoveryPhone(
|
||||
callingCode: String,
|
||||
phoneNumber: String,
|
||||
recoveryFrameDetails: ChallengeFrameDetails
|
||||
) = flow {
|
||||
if (phoneNumber.isBlank()) {
|
||||
emit(WantSkip(selectedRecoveryMethod))
|
||||
return@flow
|
||||
}
|
||||
|
||||
emit(Creating(selectedRecoveryMethod))
|
||||
// TODO: add challenge-fingerpriting
|
||||
|
||||
val fullPhoneNumber = "$callingCode$phoneNumber"
|
||||
when (val result = getFlow().submitRecoveryPhone(fullPhoneNumber)) {
|
||||
is SignupFlowSubmitRecoveryPhoneResult.Error -> {
|
||||
emit(Phone(message = result.v1.getErrorMessage()))
|
||||
}
|
||||
|
||||
is SignupFlowSubmitRecoveryPhoneResult.Ok -> {
|
||||
val route = result.v1.mapToNavigationRoute()
|
||||
emit(Success(selectedRecoveryMethod, fullPhoneNumber, route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCountryPicker() = flow {
|
||||
emit(WantCountryPicker(selectedRecoveryMethod, cachedCountries ?: emptyList()))
|
||||
}
|
||||
|
||||
private fun handleCountryPicked(country: Country) = flow {
|
||||
selectedCountry = country
|
||||
emit(OnCountryPicked(selectedRecoveryMethod, country))
|
||||
}
|
||||
|
||||
private fun handleWantSkipRecovery() = flow {
|
||||
emit(WantSkip(selectedRecoveryMethod))
|
||||
}
|
||||
|
||||
private fun handleRecoverySkipped() = flow {
|
||||
when (val result = getFlow().skipRecovery()) {
|
||||
is SignupFlowSkipRecoveryResult.Error -> {
|
||||
emit(
|
||||
Error(
|
||||
recoveryMethod = selectedRecoveryMethod,
|
||||
message = result.v1.getErrorMessage()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is SignupFlowSkipRecoveryResult.Ok -> {
|
||||
val route = result.v1.mapToNavigationRoute()
|
||||
emit(SkipSuccess(recoveryMethod = selectedRecoveryMethod, route = route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSkipDialogClose() = flow {
|
||||
emit(SkipFailed(recoveryMethod = selectedRecoveryMethod))
|
||||
}
|
||||
|
||||
private fun handleCountryDialogClose() = flow {
|
||||
emit(
|
||||
CountryPickerFailed(
|
||||
recoveryMethod = selectedRecoveryMethod,
|
||||
country = selectedCountry
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleClose(back: Boolean) = flow {
|
||||
if (back) {
|
||||
getFlow().stepBack()
|
||||
}
|
||||
emit(Closed)
|
||||
}
|
||||
|
||||
override fun handleError(throwable: Throwable) =
|
||||
Error(recoveryMethod = selectedRecoveryMethod, message = throwable.message)
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(getFlow: suspend () -> SignupFlow, coroutineDispatcher: CoroutineDispatcher): RecoveryHandler =
|
||||
RecoveryHandler(getFlow, coroutineDispatcher)
|
||||
}
|
||||
}
|
||||
|
||||
fun RustCountry.toCountry() = Country(
|
||||
countryCode = countryCode,
|
||||
callingCode = phoneCode.toInt(),
|
||||
name = countryEn
|
||||
)
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import me.proton.android.core.auth.presentation.IODispatcher
|
||||
import me.proton.android.core.auth.presentation.LogTag
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreatePasswordState
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreateRecoveryState
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpAction
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpAction.CreateUser
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpAction.FinalizeSignup
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.LoginSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SignUpError
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SignUpSuccess
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState.SigningUp
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.compose.viewmodel.BaseViewModel
|
||||
import me.proton.core.presentation.savedstate.state
|
||||
import me.proton.core.util.kotlin.CoreLogger
|
||||
import uniffi.proton_account_uniffi.SignupException
|
||||
import uniffi.proton_account_uniffi.SignupFlowCompleteResult
|
||||
import uniffi.proton_account_uniffi.SignupFlowCreateResult
|
||||
import uniffi.proton_mail_uniffi.MailSession
|
||||
import uniffi.proton_mail_uniffi.MailSessionGetAccountResult
|
||||
import uniffi.proton_mail_uniffi.MailSessionGetAccountSessionsResult
|
||||
import uniffi.proton_mail_uniffi.MailSessionNewSignupFlowResult
|
||||
import uniffi.proton_mail_uniffi.StoredAccount
|
||||
import uniffi.proton_mail_uniffi.StoredSession
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
@HiltViewModel
|
||||
class SignUpViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
requiredAccountType: AccountType,
|
||||
private val sessionInterface: MailSession,
|
||||
@IODispatcher private val coroutineDispatcher: CoroutineDispatcher
|
||||
) : BaseViewModel<SignUpAction, SignUpState>(
|
||||
initialAction = CreateUsernameAction.LoadData(
|
||||
savedStateHandle.get<AccountType>("accountType") ?: requiredAccountType
|
||||
),
|
||||
initialState = CreateUsernameState.Load(savedStateHandle.get<AccountType>("accountType") ?: requiredAccountType),
|
||||
sharingStarted = SharingStarted.Lazily
|
||||
) {
|
||||
|
||||
private var currentAccountType: AccountType by savedStateHandle.state(requiredAccountType)
|
||||
private val signUpFlowDeferred = viewModelScope.async {
|
||||
sessionInterface.newSignupFlow()
|
||||
}
|
||||
private val usernameHandler = UsernameHandler.create(
|
||||
getFlow = { getSignUpFlow() },
|
||||
getCurrentAccountType = { currentAccountType },
|
||||
updateAccountType = { type -> currentAccountType = type },
|
||||
coroutineDispatcher = coroutineDispatcher
|
||||
)
|
||||
|
||||
private val passwordHandler = PasswordHandler.create(
|
||||
getFlow = { getSignUpFlow() }
|
||||
)
|
||||
|
||||
private val recoveryHandler = RecoveryHandler.create(
|
||||
getFlow = { getSignUpFlow() },
|
||||
coroutineDispatcher = coroutineDispatcher
|
||||
)
|
||||
|
||||
private suspend fun getSignUpFlow() = (signUpFlowDeferred.await() as MailSessionNewSignupFlowResult.Ok).v1
|
||||
|
||||
override fun onAction(action: SignUpAction) = when (action) {
|
||||
is CreateUsernameAction -> usernameHandler.handleAction(action)
|
||||
is CreatePasswordAction -> passwordHandler.handleAction(action)
|
||||
is CreateRecoveryAction -> recoveryHandler.handleAction(action)
|
||||
is CreateUser -> handleCreateUser()
|
||||
is FinalizeSignup -> finalizeSignUp()
|
||||
else -> emptyFlow()
|
||||
}
|
||||
|
||||
override suspend fun FlowCollector<SignUpState>.onError(throwable: Throwable) {
|
||||
val currentState = state.value
|
||||
val errorState = when (currentState) {
|
||||
is CreateUsernameState -> usernameHandler.handleError(throwable)
|
||||
is CreatePasswordState -> passwordHandler.handleError(throwable)
|
||||
is CreateRecoveryState -> recoveryHandler.handleError(throwable)
|
||||
else -> SignUpError(throwable.message)
|
||||
}
|
||||
emit(errorState)
|
||||
}
|
||||
|
||||
private fun handleCreateUser() = flow {
|
||||
emit(SigningUp)
|
||||
when (val result = getSignUpFlow().create()) {
|
||||
is SignupFlowCreateResult.Error -> emitAll(result.v1.onSignUpError())
|
||||
is SignupFlowCreateResult.Ok -> emit(SignUpSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeSignUp() = flow {
|
||||
when (val result = getSignUpFlow().complete()) {
|
||||
is SignupFlowCompleteResult.Error -> {
|
||||
emitAll(result.v1.onSignUpError())
|
||||
}
|
||||
|
||||
is SignupFlowCompleteResult.Ok -> {
|
||||
val userId = result.v1.userId
|
||||
getSession(getAccount(userId))?.firstOrNull()
|
||||
emit(LoginSuccess(userId))
|
||||
clearUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SignupException.onSignUpError() = flow {
|
||||
getSignUpFlow().stepBack()
|
||||
emit(SignUpError(message = getErrorMessage()))
|
||||
}
|
||||
|
||||
private suspend fun getSession(account: StoredAccount?): List<StoredSession>? {
|
||||
if (account == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return when (val result = sessionInterface.getAccountSessions(account)) {
|
||||
is MailSessionGetAccountSessionsResult.Error -> {
|
||||
CoreLogger.e(LogTag.SIGNUP, "Failed to get account sessions: ${result.v1}")
|
||||
null
|
||||
}
|
||||
|
||||
is MailSessionGetAccountSessionsResult.Ok -> result.v1
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAccount(userId: String): StoredAccount? =
|
||||
when (val result = sessionInterface.getAccount(userId)) {
|
||||
is MailSessionGetAccountResult.Error -> {
|
||||
CoreLogger.e(LogTag.SIGNUP, "Failed to get account: ${result.v1}")
|
||||
null
|
||||
}
|
||||
|
||||
is MailSessionGetAccountResult.Ok -> result.v1
|
||||
}
|
||||
|
||||
private suspend fun clearUp() {
|
||||
try {
|
||||
getSignUpFlow().destroy()
|
||||
} catch (e: Exception) {
|
||||
CoreLogger.e(LogTag.SIGNUP, "Error destroying signup flow: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorHandler {
|
||||
|
||||
fun handleError(throwable: Throwable): SignUpState
|
||||
}
|
||||
|
||||
fun SignupException.getErrorMessage(): String? {
|
||||
return when (this) {
|
||||
is SignupException.Api -> v1.takeIf { it.isNotEmpty() } ?: message
|
||||
is SignupException.Crypto -> v1.takeIf { it.isNotEmpty() } ?: message
|
||||
else -> message
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Proton Technologies AG
|
||||
* This file is part of Proton Technologies AG and Proton Mail.
|
||||
*
|
||||
* Proton Mail is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Proton Mail is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Proton Mail. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.android.core.auth.presentation.signup.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.CreateExternalAccount
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.CreateInternalAccount
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.CreateUsernameClosed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.LoadData
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameAction.Perform
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.Closed
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.Creating
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.Error
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.Load
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.LoadingComplete
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.Success
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.ValidationError.EmailEmpty
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.ValidationError.InternalUsernameEmpty
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.ValidationError.Other
|
||||
import me.proton.android.core.auth.presentation.signup.CreateUsernameState.ValidationError.UsernameEmpty
|
||||
import me.proton.android.core.auth.presentation.signup.SignUpState
|
||||
import me.proton.android.core.auth.presentation.signup.ValidationField
|
||||
import me.proton.android.core.auth.presentation.signup.mapToNavigationRoute
|
||||
import me.proton.core.account.domain.entity.AccountType
|
||||
import me.proton.core.challenge.domain.entity.ChallengeFrameDetails
|
||||
import uniffi.proton_account_uniffi.SignupException
|
||||
import uniffi.proton_account_uniffi.SignupFlow
|
||||
import uniffi.proton_account_uniffi.SignupFlowAvailableDomainsResult
|
||||
import uniffi.proton_account_uniffi.SignupFlowSubmitExternalUsernameResult
|
||||
import uniffi.proton_account_uniffi.SignupFlowSubmitInternalUsernameResult
|
||||
|
||||
/**
|
||||
* Handler responsible for username-related actions during signup process.
|
||||
*/
|
||||
class UsernameHandler private constructor(
|
||||
private val getFlow: suspend () -> SignupFlow,
|
||||
private val getCurrentAccountType: () -> AccountType,
|
||||
private val updateAccountType: (AccountType) -> Unit,
|
||||
private val coroutineDispatcher: CoroutineDispatcher
|
||||
) : ErrorHandler {
|
||||
|
||||
fun handleAction(action: CreateUsernameAction) = when (action) {
|
||||
is LoadData -> handleUsernameLoad(action.accountType)
|
||||
is CreateExternalAccount -> handleCreateExternalAccount()
|
||||
is CreateInternalAccount -> handleCreateInternalAccount()
|
||||
is Perform -> handleUsernameSubmit(
|
||||
accountType = action.accountType,
|
||||
username = action.value,
|
||||
domain = action.domain,
|
||||
usernameFrameDetails = action.usernameFrameDetails
|
||||
)
|
||||
|
||||
is CreateUsernameClosed -> handleClose(action.back)
|
||||
}
|
||||
|
||||
private fun handleUsernameLoad(accountType: AccountType) = flow {
|
||||
emit(Load(accountType = accountType, isLoading = true))
|
||||
emitAll(
|
||||
when (accountType) {
|
||||
AccountType.Internal -> handleCreateInternalAccount()
|
||||
AccountType.External -> handleCreateExternalAccount()
|
||||
AccountType.Username -> handleCreateUsernameAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleCreateUsernameAccount() = flow {
|
||||
updateAccountType(AccountType.External)
|
||||
emit(LoadingComplete(AccountType.Username))
|
||||
}
|
||||
|
||||
private fun handleCreateInternalAccount() = flow {
|
||||
updateAccountType(AccountType.Internal)
|
||||
when (val result = getFlow().availableDomains()) {
|
||||
is SignupFlowAvailableDomainsResult.Error -> Error(
|
||||
accountType = getCurrentAccountType(),
|
||||
isLoading = false,
|
||||
message = result.v1.getErrorMessage()
|
||||
)
|
||||
is SignupFlowAvailableDomainsResult.Ok -> emit(LoadingComplete(AccountType.Internal, domains = result.v1))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreateExternalAccount() = flow {
|
||||
updateAccountType(AccountType.External)
|
||||
emit(LoadingComplete(AccountType.External))
|
||||
}
|
||||
|
||||
@Suppress("ForbiddenComment")
|
||||
private fun handleUsernameSubmit(
|
||||
accountType: AccountType,
|
||||
username: String,
|
||||
domain: String?,
|
||||
usernameFrameDetails: ChallengeFrameDetails
|
||||
) = flow {
|
||||
emit(Creating(accountType, isLoading = true))
|
||||
|
||||
val result = when (accountType) {
|
||||
AccountType.Username, AccountType.Internal -> { // currently no difference between username and internal
|
||||
requireNotNull(domain) { "Domain must be set for Internal Account type." }
|
||||
if (username.isEmpty()) {
|
||||
emit(InternalUsernameEmpty)
|
||||
return@flow
|
||||
}
|
||||
handleInternalUsernameSubmission(accountType, username, domain)
|
||||
}
|
||||
|
||||
AccountType.External -> {
|
||||
if (username.isEmpty()) {
|
||||
emit(EmailEmpty)
|
||||
return@flow
|
||||
}
|
||||
handleExternalUsernameSubmission(accountType, username)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add challenge-fingerprinting for
|
||||
emit(result)
|
||||
}
|
||||
|
||||
private suspend fun handleInternalUsernameSubmission(
|
||||
accountType: AccountType,
|
||||
username: String,
|
||||
domain: String
|
||||
): CreateUsernameState {
|
||||
return when (val result = getFlow().submitInternalUsername(username, domain)) {
|
||||
is SignupFlowSubmitInternalUsernameResult.Error -> {
|
||||
when (result.v1) {
|
||||
is SignupException.UsernameUnavailable ->
|
||||
Other(
|
||||
accountType = accountType,
|
||||
field = ValidationField.USERNAME,
|
||||
message = result.v1.getErrorMessage()
|
||||
)
|
||||
|
||||
else -> Error(
|
||||
accountType = accountType,
|
||||
isLoading = false,
|
||||
message = result.v1.getErrorMessage()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SignupFlowSubmitInternalUsernameResult.Ok ->
|
||||
Success(
|
||||
accountType = accountType,
|
||||
username = username,
|
||||
domain = domain,
|
||||
route = result.v1.mapToNavigationRoute()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleExternalUsernameSubmission(accountType: AccountType, email: String): CreateUsernameState {
|
||||
return when (val result = getFlow().submitExternalUsername(email)) {
|
||||
is SignupFlowSubmitExternalUsernameResult.Error -> {
|
||||
when (result.v1) {
|
||||
is SignupException.UsernameUnavailable ->
|
||||
Other(
|
||||
accountType = accountType,
|
||||
field = ValidationField.EMAIL,
|
||||
message = result.v1.getErrorMessage()
|
||||
)
|
||||
|
||||
is SignupException.UsernameEmpty -> UsernameEmpty
|
||||
|
||||
else -> Error(
|
||||
accountType = accountType,
|
||||
message = result.v1.getErrorMessage()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is SignupFlowSubmitExternalUsernameResult.Ok ->
|
||||
Success(
|
||||
accountType = accountType,
|
||||
username = email,
|
||||
route = result.v1.mapToNavigationRoute()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClose(back: Boolean) = flow {
|
||||
if (back) {
|
||||
getFlow().stepBack()
|
||||
}
|
||||
emit(Closed(getCurrentAccountType()))
|
||||
}
|
||||
|
||||
override fun handleError(throwable: Throwable): SignUpState =
|
||||
Error(accountType = getCurrentAccountType(), message = throwable.message)
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(
|
||||
getFlow: suspend () -> SignupFlow,
|
||||
getCurrentAccountType: () -> AccountType,
|
||||
updateAccountType: (AccountType) -> Unit,
|
||||
coroutineDispatcher: CoroutineDispatcher
|
||||
): UsernameHandler = UsernameHandler(
|
||||
getFlow, getCurrentAccountType, updateAccountType, coroutineDispatcher
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M10.67 5.33H0v21.33h10.67V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 5.33H21.33v21.33H32V5.33zM16 19.71V16h2.78v1.39a3.37 3.37 0 0 1-1.61 1.86A4.88 4.88 0 0 1 16 19.71zm0-6.49h-2.78V16H16v-2.78z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M18.32 16.93c0 0.24 0 1-1.15 1.7-0.366 0.233-0.76 0.42-1.17 0.56a5.45 5.45 0 0 1-1.17-0.56c-1.15-0.73-1.15-1.46-1.15-1.7v-3.25h4.64v3.25zm-0.93-4.64a0.93 0.93 0 0 0-0.93-0.93 0.91 0.91 0 0 0-0.46 0.13 0.94 0.94 0 0 0-0.46-0.13 0.93 0.93 0 0 0-0.93 0.93h-2.32v4.64a3.76 3.76 0 0 0 2.45 3.24 1 1 0 0 0-0.13 0.47 0.922 0.922 0 0 0 0.93 0.92 0.93 0.93 0 0 0 0.46-0.12 0.9 0.9 0 0 0 0.46 0.12 0.92 0.92 0 0 0 0.93-0.92 0.998 0.998 0 0 0-0.13-0.47 3.76 3.76 0 0 0 2.45-3.24v-4.64h-2.32z" android:fillColor="#FF9811"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M32 19.56H0v7.11h32v-7.11z" android:fillColor="#000"/>
|
||||
<path android:pathData="M10.67 5.33H0v21.33h10.67V5.33z" android:fillColor="#A2001D"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 5.33H20.64v21.33H32V5.33z" android:fillColor="#496E2D"/>
|
||||
<path android:pathData="M11.36 5.33H0v21.33h11.36V5.33z" android:fillColor="#000"/>
|
||||
<path android:pathData="M16 18.32a2.32 2.32 0 1 1 0-4.639 2.32 2.32 0 0 1 0 4.639zm0-6.03c-0.735 0-1.454 0.248-2.064 0.657A3.71 3.71 0 1 0 19.71 16 3.71 3.71 0 0 0 16 12.29z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M16 14.61a0.93 0.93 0 0 0-0.93 0.93v1.39h1.86v-1.39A0.93 0.93 0 0 0 16 14.61z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#000"/>
|
||||
<path android:pathData="M5.55 13.87a10.42 10.42 0 0 0 0 4.26L16 18.78l10.45-0.65a10.422 10.422 0 0 0 0-4.26" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M26.45 18.13H5.55a10.66 10.66 0 0 0 20.9 0z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M20.64 13.87h-9.28l1.9-0.9-1.01-1.83 2.06 0.39 0.26-2.07L16 10.98l1.43-1.52 0.26 2.07 2.06-0.39-1.01 1.83 1.9 0.9z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M16 26.67L0 5.33v21.34h16zm16 0V5.33L16 26.67h16z" android:fillColor="#A2001D"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M26.73 18.49c0.338-0.45 0.52-0.997 0.52-1.56v-4.64A2.34 2.34 0 0 1 24 11.83a2.34 2.34 0 0 1-3.25 0.46v4.64c0 0.56 0.18 1.107 0.51 1.56h5.47z" android:fillColor="#F3F3F3"/>
|
||||
<path android:pathData="M25.77 16.07A3.73 3.73 0 0 0 26 14.92c0-0.42-0.55-0.76-0.55-0.76s-0.55 0.34-0.55 0.76c0.03 0.395 0.12 0.783 0.27 1.15l-0.32 0.71c0.388 0.16 0.822 0.16 1.21 0l-0.29-0.71zm-2.15-2.32c-0.39 0.056-0.768 0.175-1.12 0.35-0.37 0.21-0.39 0.85-0.39 0.85s0.57 0.31 0.94 0.1a4.14 4.14 0 0 0 0.86-0.8l0.78-0.09a1.599 1.599 0 0 0-0.2-0.58 1.787 1.787 0 0 0-0.4-0.46l-0.47 0.63zm-0.93 3.02c0.247 0.306 0.537 0.576 0.86 0.8 0.37 0.21 0.94-0.1 0.94-0.1s0-0.64-0.39-0.85a3.7 3.7 0 0 0-1.1-0.35l-0.46-0.63a1.651 1.651 0 0 0-0.41 0.46c-0.1 0.18-0.167 0.377-0.2 0.58l0.76 0.09z" android:fillColor="#FF9811"/>
|
||||
<path android:pathData="M21.15 18.32A5.12 5.12 0 0 0 24 20.17a5.12 5.12 0 0 0 2.85-1.85h-5.7z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M16 11.67H9V16h1v-3.06L14.59 16H16l-5-3.33h1.41L16 15.06v-0.47l-2.88-1.92H16v-1zm-5-3l5-3.34h-1.41L10 8.39V5.33H9v4.34h7v-1h-2.88L16 6.75V6.28l-3.59 2.39H11zM6 5.33v3.06L1.41 5.33H0l5 3.34H3.59L0 6.28v0.47l2.88 1.92H0v1h7V5.33H6zm-6 6.34v1h2.88L0 14.59v0.47l3.59-2.39H5L0 16h1.41L6 12.94V16h1v-4.33H0z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M9 5.33H7v4.34H0v2h7V16h2v-4.33h7v-2H9V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M5 8.67L0 5.33v0.95l3.59 2.39H5zm6 0h1.41L16 6.28V5.33l-5 3.34zm-7.41 4L0 15.06V16l5-3.33H3.59zm7.41 0L16 16v-0.94l-3.59-2.39H11z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M22 13.25h-3.83a1.43 1.43 0 0 0 0.38-1A1.39 1.39 0 0 0 16 11.48a1.392 1.392 0 0 0-2.032-0.323 1.389 1.389 0 0 0-0.128 2.053H10a1.91 1.91 0 0 0 1.92 1.85h-0.06A1.849 1.849 0 0 0 13.68 17c0.001 0.319 0.084 0.632 0.24 0.91l-1.54 1.54 1.18 1.18 1.68-1.67h0.2l-1 2.29L16 23l1.57-1.7-1-2.29h0.2l1.68 1.67 1.18-1.18-1.54-1.54a1.87 1.87 0 0 0 0.24-0.91 1.85 1.85 0 0 0 1.85-1.86h-0.06A1.91 1.91 0 0 0 22 13.25z" android:fillColor="#000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 19.56H0v7.11h32v-7.11z" android:fillColor="#FF9811"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#000"/>
|
||||
<path android:pathData="M32 5.33H0V16h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M14.54 13.82l0.9 0.65-0.34 1.07 0.9-0.66 0.9 0.66-0.34-1.07 0.9-0.66-1.12 0.01L16 12.75l-0.35 1.07h-1.11z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M18.67 11.38A5.371 5.371 0 0 0 16 10.67v1.39a3.941 3.941 0 0 1 1.472 7.584A3.94 3.94 0 0 1 14 19.41a3.84 3.84 0 0 1-1.32-1.24l-1.16 0.77a5.33 5.33 0 1 0 7.12-7.56h0.03z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M12.92 15.07A1.391 1.391 0 0 0 13.48 17L18 19.43A1.131 1.131 0 0 0 18.41 21l1.22 0.67a1.17 1.17 0 0 0 1.58-0.46L21.87 20l-8.95-4.93z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33zm0 14.23H0v7.11h32v-7.11z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M18.55 16l-1.04 0.49 0.55 1.01-1.13-0.22-0.14 1.15L16 17.59l-0.79 0.84-0.14-1.15-1.13 0.22 0.55-1.01L13.45 16l1.04-0.49-0.55-1.01 1.13 0.22 0.14-1.15 0.79 0.84 0.79-0.84 0.14 1.15 1.13-0.22-0.55 1.01L18.55 16z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<group>
|
||||
<clip-path android:pathData="M0 0h32v32H0z"/>
|
||||
<path android:pathData="M32 5.25H0v21.33h32V5.25z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 6.78L3.73 16.14 32 25.5v1.25L0 16.14 32 5.53v1.25z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 23.39v2.11L3.73 16.14 32 6.78V8.9" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M30.83 15.84H29a1.44 1.44 0 0 0-0.09-1.95 1.45 1.45 0 0 0 0-2.06 1.49 1.49 0 0 0 0-2.09L20.69 18a1.43 1.43 0 0 0 2 0l0.16-0.14 3.93-0.36v1.69h1.34v-1.82l2-0.18 0.71-1.35z" android:fillColor="#A2001D"/>
|
||||
<path android:pathData="M20.81 19.75l-1.33-0.66 1.33-0.67h8.69v1.33h-8.69z" android:fillColor="#FFDA44"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33zm0 14.23H0v7.11h32v-7.11z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M11.83 18.62l0.68 1.43 1.55-0.35-0.69 1.42 1.24 0.99-1.55 0.35v1.59l-1.23-1-1.24 1v-1.59l-1.55-0.35 1.25-0.99L9.6 19.7l1.54 0.35 0.69-1.43zm12.36 1.78l0.33 0.68 0.74-0.17-0.33 0.68 0.59 0.47-0.74 0.17v0.76l-0.59-0.48-0.59 0.48v-0.76l-0.74-0.17 0.6-0.47-0.33-0.68 0.73 0.17 0.33-0.68zm-3.04-7.25l0.33 0.69 0.74-0.18-0.33 0.69 0.59 0.47-0.74 0.16v0.76l-0.59-0.47-0.59 0.47 0.01-0.76-0.74-0.16 0.59-0.47-0.33-0.69 0.74 0.18 0.32-0.69zm3.04-4.14l0.33 0.69 0.74-0.17-0.33 0.68 0.59 0.47-0.74 0.16v0.76l-0.59-0.47-0.59 0.47v-0.76l-0.74-0.16 0.6-0.47-0.33-0.68 0.73 0.17 0.33-0.69zm2.66 3.11l0.32 0.68 0.74-0.17-0.33 0.68 0.59 0.47-0.74 0.17 0.01 0.76-0.59-0.48-0.59 0.48v-0.76l-0.74-0.17 0.59-0.47-0.33-0.68 0.74 0.17 0.33-0.68zm-1.9 3.62l0.25 0.79h0.84l-0.68 0.49 0.26 0.79-0.67-0.49-0.68 0.49 0.26-0.79-0.67-0.49h0.83l0.26-0.79zM16 5.33v1.91l-2.82 1.57H16v3.71h-3.69L16 14.57V16h-1.67l-4.59-2.55V16H6.26v-3.04L0.8 16H0v-1.91l2.82-1.57H0V8.81h3.69L0 6.76V5.33h1.67l4.59 2.55V5.33h3.48v3.04l5.46-3.04H16z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M9 5.33H7v4.34H0v2h7V16h2v-4.33h7v-2H9V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M9.74 12.52L16 16v-0.98l-4.49-2.5H9.74z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M9.74 12.52L16 16v-0.98l-4.49-2.5H9.74z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M9.74 12.52L16 16v-0.98l-4.49-2.5H9.74zm-5.25 0L0 15.02V16l6.26-3.48H4.49z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M6.26 8.81L0 5.33v0.99l4.49 2.49h1.77z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M6.26 8.81L0 5.33v0.99l4.49 2.49h1.77z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M6.26 8.81L0 5.33v0.99l4.49 2.49h1.77zm5.25 0L16 6.32V5.33L9.74 8.81h1.77z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M3.03 10.44L0.95 9.52 3.03 8.6l0.92-2.09 0.92 2.09 2.09 0.92-2.09 0.92-0.92 2.08-0.92-2.08z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M3.95 7.66l0.57 1.29 1.29 0.57-1.29 0.57-0.57 1.28-0.57-1.28L2.1 9.52l1.28-0.57 0.57-1.29z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 18.78H0v1.39h32v-1.39zm0 2.79H0v1.39h32v-1.39z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M32 19.56H0v7.11h32v-7.11z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M16.2 18.6a2.602 2.602 0 0 1-1.976-4.28 2.6 2.6 0 0 1 3.216-0.61 3.2 3.2 0 1 0 0 4.58 2.59 2.59 0 0 1-1.24 0.31z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M18.2 14.2l0.34 0.97 0.93-0.44-0.44 0.92L20 16l-0.97 0.34 0.44 0.93-0.93-0.44-0.34 0.97-0.34-0.97-0.93 0.44 0.44-0.93L16.4 16l0.97-0.35-0.44-0.92 0.93 0.44 0.34-0.97z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M6.25 5.33h21.33v21.34" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M2.29 6.29l0.92-0.67 0.93 0.67-0.31-0.96H2.6L2.29 6.29z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M5.52 6.48l-0.35-1.1-0.36 1.1H3.66l0.93 0.67-0.35 1.09 0.93-0.67 0.93 0.67-0.36-1.09 0.93-0.67H5.52z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M7.47 8.43L7.12 7.34 6.76 8.43H5.62l0.93 0.68-0.36 1.09 0.93-0.68 0.93 0.68-0.36-1.09 0.93-0.68H7.47z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M9.43 10.38L9.07 9.29l-0.35 1.09H7.57l0.93 0.68-0.35 1.09 0.92-0.67 0.93 0.67-0.35-1.09 0.93-0.68H9.43z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M11.38 12.34l-0.35-1.1-0.36 1.1H9.53l0.92 0.67-0.35 1.09 0.93-0.67 0.93 0.67-0.36-1.09 0.93-0.67h-1.15z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M13.34 14.29l-0.36-1.09-0.35 1.09h-1.15l0.93 0.68-0.36 1.09 0.93-0.68 0.93 0.68-0.36-1.1 0.93-0.67h-1.14z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M15.29 16.25l-0.36-1.1-0.35 1.1h-1.15l0.93 0.67-0.35 1.09 0.93-0.67 0.92 0.67-0.35-1.09 0.93-0.68-1.15 0.01z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M17.24 18.2l-0.35-1.09-0.36 1.09h-1.15l0.93 0.67-0.35 1.09 0.93-0.67 0.93 0.67-0.36-1.09 0.93-0.67h-1.15z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M19.2 20.15l-0.36-1.09-0.35 1.09h-1.15l0.93 0.68-0.36 1.09 0.93-0.68 0.93 0.68-0.35-1.09 0.92-0.68H19.2z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M21.15 22.11l-0.36-1.1-0.35 1.1h-1.15l0.93 0.67-0.35 1.09 0.93-0.67 0.92 0.67-0.35-1.09 0.93-0.67h-1.15z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M23.1 24.06l-0.35-1.09-0.36 1.09h-1.14l0.92 0.67-0.35 1.1 0.93-0.68 0.93 0.67-0.36-1.09 0.93-0.67H23.1z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M24.7 24.92l-0.35 1.09H23.2l0.9 0.66h1.2l0.9-0.66h-1.14l-0.36-1.09z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M10.67 5.33H0v21.33h10.67V5.33zm21.33 0H21.33v21.33H32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M19.25 11.83l0.62 0.31-0.62-0.31-0.63-0.32a11.81 11.81 0 0 0-1 4.26H16.7v-3.94L16 10.9l-0.7 0.93v3.94h-0.94a11.81 11.81 0 0 0-1-4.26l-1.25 0.63c0.6 1.36 0.904 2.833 0.89 4.32v0.7h2.3v3.94h1.4v-3.94H19v-0.7c0.004-1.058 0.148-2.11 0.43-3.13 0.109-0.408 0.253-0.806 0.43-1.19l-0.61-0.31z" android:fillColor="#000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M32,5.33H0V26.66H32V5.33Z"
|
||||
android:fillColor="#2C6850"/>
|
||||
<path
|
||||
android:pathData="M13.817,20.638C16.378,20.56 18.392,18.421 18.315,15.86C18.237,13.298 16.098,11.285 13.537,11.362C10.975,11.439 8.962,13.578 9.039,16.14C9.116,18.701 11.255,20.715 13.817,20.638Z"
|
||||
android:fillColor="#E04148"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M10.67 5.33H0v21.33h10.67V5.33z" android:fillColor="#000"/>
|
||||
<path android:pathData="M32 5.33H21.33v21.33H32V5.33z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M32 5.33H0V16h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M16 12.29l0.8 2.48h2.61L17.3 16.3l0.81 2.48L16 17.25l-2.11 1.53 0.81-2.48-2.11-1.53h2.6L16 12.29z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#496E2D"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 19.55H0v7.11h32v-7.11z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<group>
|
||||
<clip-path android:pathData="M0 0h32v32H0z"/>
|
||||
<path android:pathData="M32 5.38H0.14v21.24H32V5.38z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M13.09 9.69l-3.14 1.58 3.14 1.57-3.14 1.58L13.09 16l-3.14 1.58 3.14 1.58-3.14 1.58 3.14 1.58-3.14 1.58 3.14 1.58-2.28 1.14H0V5.38h10.81l2.28 1.15-3.14 1.58 3.14 1.58z" android:fillColor="#F0F0F0"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M13.49 16L0 25V7.01L13.49 16zM32 7.01V25l-13.49-9L32 7.01z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M32 7.01L18.51 16 32 24.99v1.68h-2.51l-13.49-9-13.49 9H0v-1.68L13.49 16 0 7.01V5.33h2.51l13.49 9 13.49-9H32v1.68z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 7.01L18.51 16 32 24.99v1.68h-2.51l-13.49-9-13.49 9H0v-1.68L13.49 16 0 7.01V5.33h2.51l13.49 9 13.49-9H32v1.68z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M16 20.64a4.64 4.64 0 1 0 0-9.28 4.64 4.64 0 0 0 0 9.28z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M16 12.75l0.4 0.7h0.8l-0.4 0.69 0.4 0.7h-0.8l-0.4 0.7-0.4-0.7h-0.81l0.41-0.7-0.41-0.69h0.81l0.4-0.7zM13.96 16l0.4 0.7h0.8l-0.4 0.69 0.4 0.7h-0.8l-0.4 0.69-0.4-0.69h-0.81l0.4-0.7-0.4-0.69h0.81l0.4-0.7zm4.08 0l0.4 0.7h0.81l-0.41 0.69 0.41 0.7h-0.81l-0.4 0.69-0.4-0.69h-0.8l0.4-0.7-0.4-0.69h0.8l0.4-0.7z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M32 5.33H12.29V16H32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 16H12.29v10.67H32V16z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M24.38 13.91h-3.51a1.16 1.16 0 0 0-1.16-1.16l-0.93 1.86 1 2.78h1.12a1.16 1.16 0 0 0 1.16-1.16 1.16 1.16 0 0 0 1.16-1.16 1.2 1.2 0 0 0 1.16-1.16zm-16.76 0h3.51a1.16 1.16 0 0 1 1.16-1.16l0.93 1.86-1 2.78H11.1a1.16 1.16 0 0 1-1.16-1.16 1.16 1.16 0 0 1-1.16-1.16 1.2 1.2 0 0 1-1.16-1.16z" android:fillColor="#ACABB1"/>
|
||||
<path android:pathData="M19.71 20.17v0.47h-7.42v-0.47h-1.85v1.86h1.85v0.46h7.42v-0.46h1.86v-1.86h-1.86z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M12.29 12.75v4.64c0 2.84 3.71 3.71 3.71 3.71s3.71-0.87 3.71-3.71v-4.64L16 12.29l-3.71 0.46z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M19.71 14.61h-7.42v2.78h7.42v-2.78z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M17.86 10.43v0.7l-0.47 0.23-0.46-0.46V9.51h-1.86v1.39l-0.46 0.46-0.47-0.23v-0.7h-1.85v2.32h7.42v-2.32h-1.85z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,21 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M16 5.33H0V16h16V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M9 5.33H7v4.34H0v2h7V16h2v-4.33h7v-2H9V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M0 5.33v0.95l3.59 2.39H5L0 5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M0 6.75v1.92h2.88L0 6.75zm6-1.42v3.06L1.41 5.33H6z" android:fillColor="#2E52B2"/>
|
||||
<path android:pathData="M16 5.33v0.95l-3.59 2.39H11l5-3.34z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M16 6.75v1.92h-2.88L16 6.75zm-6-1.42v3.06l4.59-3.06H10z" android:fillColor="#2E52B2"/>
|
||||
<path android:pathData="M0 5.33v0.95l3.59 2.39H5L0 5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M0 6.75v1.92h2.88L0 6.75zm6-1.42v3.06L1.41 5.33H6z" android:fillColor="#2E52B2"/>
|
||||
<path android:pathData="M16 5.33v0.95l-3.59 2.39H11l5-3.34z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M16 6.75v1.92h-2.88L16 6.75zm-6-1.42v3.06l4.59-3.06H10z" android:fillColor="#2E52B2"/>
|
||||
<path android:pathData="M0 16v-0.94l3.59-2.39H5L0 16z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M0 14.59v-1.92h2.88L0 14.59zM6 16v-3.06L1.41 16H6z" android:fillColor="#2E52B2"/>
|
||||
<path android:pathData="M16 16v-0.94l-3.59-2.39H11L16 16z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M16 14.59v-1.92h-2.88L16 14.59zM10 16v-3.06L14.59 16H10z" android:fillColor="#2E52B2"/>
|
||||
<path android:pathData="M20.75 11.83v5.1c0 2.48 6.5 2.48 6.5 0v-5.1h-6.5z" android:fillColor="#F3F3F3"/>
|
||||
<path android:pathData="M20.75 16.93c0 2.48 3.25 3.24 3.25 3.24s3.25-0.76 3.25-3.24h-6.5z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M24 14.89l-1.51 0.65v1.39L24 17.86l1.51-0.93v-1.39L24 14.89z" android:fillColor="#A2001D"/>
|
||||
<path android:pathData="M25.5 14.14h-3.01v1.39h3.01v-1.39z" android:fillColor="#338AF3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M0 12.44l32 12.95v-2.92l-14.92-5.9L0 9.53v2.91z" android:fillColor="#000"/>
|
||||
<path android:pathData="M32 22.47v-2.91L0 6.61v2.92" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M19 14.84a3 3 0 1 1-5.64-1.43A3.67 3.67 0 0 0 12.29 16a3.71 3.71 0 0 0 7.42 0 3.67 3.67 0 0 0-1.06-2.59c0.234 0.44 0.354 0.932 0.35 1.43z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M16.7 11.36h-1.39v6.96h1.39v-6.96z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M18.32 12.75h-4.64a0.6 0.6 0 0 0 0.6 0.58 0.58 0.58 0 0 0 0.58 0.58 0.58 0.58 0 0 0 0.58 0.58h1.16a0.58 0.58 0 0 0 0.58-0.58 0.58 0.58 0 0 0 0.58-0.58 0.6 0.6 0 0 0 0.56-0.58zm0.92 6.03h-6.49v1.39h6.49v-1.39z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M20.17 19.25h-2.78v1.39h2.78v-1.39zm-5.56 0h-2.78v1.39h2.78v-1.39z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 19.55H0v7.11h32v-7.11z" android:fillColor="#6DA544"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M0 26.67h32V5.33L0 26.67z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M15.65 5.33H0v10.44L15.65 5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M8.54 17a1.86 1.86 0 1 1 1.86-1.86A1.851 1.851 0 0 1 8.54 17zm3.21-1.36l0.92-0.5-0.92-0.5A3.25 3.25 0 0 0 9 11.93L8.54 11l-0.49 0.92a3.25 3.25 0 0 0-2.71 2.71l-0.92 0.5 0.92 0.5a3.24 3.24 0 0 0 2.71 2.71l0.49 0.92 0.5-0.92a3.24 3.24 0 0 0 2.71-2.7z" android:fillColor="#000"/>
|
||||
<path android:pathData="M8.54 13.75l0.41 0.7h0.8l-0.4 0.69 0.4 0.7h-0.8l-0.41 0.69-0.4-0.69h-0.8l0.4-0.7-0.4-0.69h0.8l0.4-0.7z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M16 10.11L24 16l-8 5.89L8 16l8-5.89z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M19.37 16.103a3.37 3.37 0 1 0-6.736-0.21 3.37 3.37 0 0 0 6.736 0.21z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M14.32 15.79a5.913 5.913 0 0 0-1.69 0.25 3.37 3.37 0 0 0 6.13 1.89 5.691 5.691 0 0 0-4.44-2.14zm4.99 0.85a3.37 3.37 0 0 0-6.4-1.97 7 7 0 0 1 6.4 1.97z" android:fillColor="#0052B4"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33zm0 14.23H0v7.11h32v-7.11z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M16 16L0 26.67V5.33L16 16z" android:fillColor="#000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FF9811"/>
|
||||
<path android:pathData="M32 5.33H0v21.34" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M22 11.36V9.51h-3.52l-0.27 0.27a3.08 3.08 0 0 0-0.69 2.92c0.11 0.76 0.13 1.05-0.16 1.33-0.29 0.28-0.57 0.28-1.34 0.17a3.06 3.06 0 0 0-2.91 0.68 3.09 3.09 0 0 0-0.69 2.92c0.11 0.76 0.12 1.05-0.16 1.34-0.28 0.29-0.58 0.27-1.34 0.16a5.527 5.527 0 0 0-0.93-0.09v1.85c0.231 0.011 0.461 0.034 0.69 0.07 0.33 0.06 0.665 0.09 1 0.09A2.84 2.84 0 0 0 12.76 21v1.48h2.78V21.1h-1.39v-0.93h0.93v-1.39h-0.74a6.156 6.156 0 0 0-0.08-1.25c-0.11-0.76-0.13-1.05 0.16-1.34 0.29-0.29 0.57-0.27 1.34-0.16a3.74 3.74 0 0 0 2.1-0.12v1.48h2.78V16h-1.39v-0.93h0.93v-1.39h-0.74a6.156 6.156 0 0 0-0.08-1.25 2.249 2.249 0 0 1 0-1.07H22z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 20.17H0v6.49h32v-6.49zm0-14.84H0v6.53h32V5.33z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M32 13.18H0v5.6h32v-5.6z" android:fillColor="#000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.35H0.03v21.31H32V5.35z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M0 19.71h31.96V5.34H0" android:fillColor="#A2001D"/>
|
||||
<path android:pathData="M6.25 19.71V5.34H0v21.32h31.96H6.25v-6.95z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M4.29 15.23l-1.16-2.1 1.16-2.07 1.15 2.07-1.15 2.1zm-2.32 0l-1.16-2.1 1.16-2.07 1.16 2.07-1.16 2.1zm2.32 5.71l-1.16-2.1 1.16-2.07 1.15 2.07-1.15 2.1zm-2.32 0l-1.16-2.1 1.16-2.07 1.16 2.07-1.16 2.1zm2.32 5.72l-1.16-2.11 1.16-2.06 1.15 2.06-1.15 2.11zm-2.32 0l-1.16-2.11 1.16-2.06 1.16 2.06-1.16 2.11zM4.29 9.51l-1.16-2.1 1.16-2.07 1.15 2.07-1.15 2.1zm-2.32 0l-1.16-2.1 1.16-2.07 1.16 2.07-1.16 2.1z" android:fillColor="#A2001D"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M16 21.1a5.1 5.1 0 1 0 0-10.2 5.1 5.1 0 0 0 0 10.2z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M16 20.17a4.17 4.17 0 1 0 0-8.34 4.17 4.17 0 0 0 0 8.34z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M16 18.78a2.78 2.78 0 1 0 0-5.56 2.78 2.78 0 0 0 0 5.56z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M16 15.3L14.49 16v1.16L16 18.09l1.51-0.93V16L16 15.3z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M17.5 14.61h-3.01V16h3.01v-1.39z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 5.33H0v1.85h32V5.33zm0 19.48H0v1.86h32v-1.86z" android:fillColor="#A2001D"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M10.67 5.33H0v21.33h10.67V5.33zm21.33 0H21.33v21.33H32V5.33zM18 17.48l2-1-1-0.5v-1l-2 1 1-2h-1l-1-1.5-1 1.5h-1l1 2-2-1v1l-1 0.5 2 1-0.5 1h2v1.5h1v-1.5h2l-0.5-1z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M32 9.51V5.33h-6.27L0 22.48v4.19h6.27L32 9.51z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 5.33v2.51L3.76 26.67H0v-2.51L28.24 5.33H32z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M11.02 8.58l0.57 1.77h1.87l-1.51 1.1 0.57 1.77-1.5-1.1-1.51 1.1 0.58-1.77-1.51-1.1h1.86l0.58-1.77z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M13.22 16H0v5.33h13.22V16zM32 16H18.78v5.33H32V16z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M0 5.33v5.34h13.22V5.33h5.56v5.34H32V5.33H0z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 10.67H18.78V16H32v-5.33zm-18.78 0H0V16h13.22v-5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M18.78 26.67h-5.56v-5.34H0v5.34h32v-5.34H18.78v5.34zM7.48 6.96l0.29 0.88H8.7L7.95 8.39l0.28 0.89-0.75-0.55-0.75 0.55 0.28-0.89-0.75-0.55h0.93l0.29-0.88z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M27.73 5.33L6.4 26.67H0V5.33h27.73z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M31.26 5.33L9.92 26.67H0.74L22.07 5.33h9.19z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M22.26 13.91h-4.17V9.74h-4.18v4.17H9.74v4.18h4.17v4.17h4.18v-4.17h4.17v-4.18z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 5.33H20.64v21.33H32V5.33z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M11.36 5.33H0v21.33h11.36V5.33z" android:fillColor="#FF9811"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M24 9.79l0.33 1.02h1.07l-0.87 0.63 0.33 1.01L24 11.83l-0.86 0.62 0.32-1.01-0.86-0.63h1.07L24 9.79zm-4.39 1.82l0.95 0.49 0.76-0.76-0.17 1.06 0.95 0.48-1.05 0.17-0.17 1.05-0.48-0.95-1.06 0.17 0.76-0.76-0.49-0.95zM17.79 16l1.02-0.33V14.6l0.63 0.87 1.01-0.33L19.83 16l0.62 0.86-1.01-0.33-0.63 0.87v-1.07L17.79 16zm1.82 4.39l0.49-0.95-0.76-0.76 1.06 0.17 0.48-0.95 0.17 1.05 1.05 0.17-0.95 0.48 0.17 1.06-0.76-0.76-0.95 0.49zM24 22.2l-0.33-1.01H22.6l0.86-0.63-0.32-1.01 0.86 0.62 0.86-0.62-0.33 1.01 0.87 0.63h-1.07L24 22.2zm4.39-1.81l-0.95-0.49-0.76 0.76 0.17-1.06-0.95-0.48 1.05-0.17 0.17-1.05 0.48 0.95 1.06-0.17-0.76 0.76 0.49 0.95zM30.21 16l-1.02 0.33v1.07l-0.63-0.87-1.01 0.33 0.63-0.86-0.63-0.86 1.01 0.33 0.63-0.87v1.07L30.21 16zm-1.82-4.39l-0.49 0.95 0.76 0.76-1.06-0.17-0.48 0.95-0.17-1.05-1.05-0.17 0.95-0.48-0.17-1.06 0.76 0.76 0.95-0.49zM6 16H1.41L6 12.94V16zm-6-4.33v1h2.88L0 14.59v0.47l3.59-2.39H5L0 16h7v-4.33H0zm10-6.34h4.59L10 8.39V5.33zm1 3.34l5-3.34H9v4.34h7v-1h-2.88L16 6.75V6.28l-3.59 2.39H11zm-1 4.27L14.59 16H10v-3.06zM9 16h7l-5-3.33h1.41L16 15.06v-0.47l-2.88-1.92H16v-1H9V16zM6 8.39L1.41 5.33H6v3.06zm1-3.06H0l5 3.34H3.59L0 6.28v0.47l2.88 1.92H0v1h7V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M9 5.33H7v4.34H0v2h7V16h2v-4.33h7v-2H9V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M5 8.67L0 5.33v0.95l3.59 2.39H5zm6 0h1.41L16 6.28V5.33l-5 3.34zm-7.41 4L0 15.06V16l5-3.33H3.59zm7.41 0L16 16v-0.94l-3.59-2.39H11z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 5.33H16V16h16V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M16 5.33H0V16h16V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M8 6.86l0.94 2.91H12l-2.47 1.79 0.94 2.91L8 12.68l-2.47 1.79 0.94-2.91L4 9.77h3.06L8 6.86z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M10.67 5.33H0v21.33h10.67V5.33z" android:fillColor="#496E2D"/>
|
||||
<path android:pathData="M32 5.33H21.33v21.33H32V5.33zm-16 7.7l0.74 2.27h2.38l-1.93 1.4 0.74 2.27-1.93-1.4-1.93 1.4 0.74-2.27-1.93-1.4h2.38L16 13.03z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M11.18 11.85l0.92 2.82h2.96l-2.4 1.74 0.92 2.82-2.4-1.75-2.4 1.75 0.92-2.82-2.4-1.74h2.97l0.91-2.82zm5.77 9.38l-0.89-0.66-0.9 0.66 0.34-1.06-0.9-0.66h1.11l0.35-1.05 0.34 1.05h1.11l-0.9 0.66 0.34 1.06zM19.3 18h-1.11l-0.34 1.05L17.5 18h-1.11l0.9-0.66-0.34-1.05 0.9 0.65 0.9-0.65-0.35 1.05 0.9 0.66zm0-4l-0.9 0.66 0.35 1.05-0.9-0.65-0.9 0.65 0.34-1.05-0.9-0.66h1.11l0.35-1.05 0.34 1.05h1.11zm-2.35-3.23l-0.34 1.06 0.9 0.65H16.4l-0.35 1.06-0.34-1.06H14.6l0.9-0.65-0.34-1.06 0.89 0.66 0.9-0.66z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 21.44H0v5.22h32v-5.22z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 16H0v5.44h32V16z" android:fillColor="#0052B4"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 13.22H0v5.56h32v-5.56z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 22.03H0v4.64h32v-4.64zm0-16.7H0v4.64h32V5.33z" android:fillColor="#0052B4"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M32 5.33H0V9.6h32V5.33zm0 8.54H0v4.27h32v-4.27zm0 8.53H0v4.27h32V22.4z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M16 16L0 26.67V5.33L16 16z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M5.22 13.22l0.69 2.12h2.23l-1.81 1.32 0.7 2.12-1.81-1.31-1.81 1.31 0.69-2.12-1.81-1.32h2.24l0.69-2.12z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 20.17H0v1.39h32v-1.39z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 18.78H0v1.39h32v-1.39zm0 2.78H0v1.39h32v-1.39z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M11.27 16.33l0.19 0.58h0.61l-0.49 0.36 0.19 0.57-0.5-0.35-0.49 0.35 0.19-0.57-0.49-0.36h0.61l0.18-0.58zm0 7.57l0.19 0.57h0.61l-0.49 0.36 0.19 0.58-0.5-0.36-0.49 0.36 0.19-0.58-0.49-0.36h0.61l0.18-0.57zm-3.52-2.56l0.19 0.57h0.61l-0.5 0.36 0.19 0.58-0.49-0.36-0.49 0.36 0.19-0.58-0.49-0.36h0.6l0.19-0.57zm1.35-4.25l0.19 0.57h0.6L9.4 18.02l0.19 0.58-0.49-0.36-0.49 0.36 0.18-0.58-0.49-0.36h0.61l0.19-0.57zm-2.14 2.38h0.6l0.19-0.58 0.19 0.58h0.61l-0.5 0.35 0.19 0.58-0.49-0.35-0.49 0.35 0.19-0.58-0.49-0.35zm1.64 5.07l0.19-0.58-0.49-0.35h0.61l0.19-0.58 0.18 0.58h0.61L9.4 23.96l0.19 0.58-0.49-0.36-0.5 0.36zm6.19-3.2l-0.18 0.57H14l0.49 0.36-0.19 0.58 0.49-0.36 0.5 0.36-0.19-0.58 0.49-0.36h-0.61l-0.19-0.57zm-1.34-4.25l-0.19 0.57h-0.61l0.49 0.36-0.18 0.58 0.49-0.36 0.49 0.36-0.19-0.58 0.49-0.36h-0.6l-0.19-0.57zm2.14 2.38h-0.61l-0.19-0.58-0.18 0.58H14l0.49 0.35-0.19 0.58 0.49-0.35 0.5 0.35-0.19-0.58 0.49-0.35zm-1.65 5.07l-0.19-0.58 0.49-0.35h-0.6l-0.19-0.58-0.19 0.58h-0.61l0.49 0.35-0.18 0.58 0.49-0.36 0.49 0.36z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 20.33H0v2h32v-2z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M9.11 9.28l0.58 1.77h1.86l-1.51 1.09 0.58 1.77-1.51-1.09-1.51 1.09 0.58-1.77-1.51-1.09h1.87l0.57-1.77zm-3.2-1.86l0.35 1.06h1.12L6.47 9.14l0.35 1.06-0.91-0.65-0.9 0.65 0.34-1.06-0.9-0.66h1.12l0.34-1.06z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#FCFCFC"/>
|
||||
<path android:pathData="M22 14.62h-1.38a4.62 4.62 0 1 1-9.24 0H10a6 6 0 0 0 4.36 5.77 1.56 1.56 0 0 0 0.16 1.68L16 20.86l1.51 1.21a1.52 1.52 0 0 0 0.15-1.69A6 6 0 0 0 22 14.62z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M12.31 14.16a2.25 2.25 0 0 0 2.3 2.3l0.47 0.47H16s0.46-1.39 1.38-1.39a0.9 0.9 0 0 1 0.93-0.92h1.38s-0.46-1.85 1.85-3.23l-0.93-0.46s-3.23 2.3-5.53 1.84v0.92h-0.93l-0.46-0.46-1.38 0.93z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 16v10.67H0L13.45 16H32z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 5.33V16H13.45L0 5.33h32z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 5.33H0v7.11h32V5.33z" android:fillColor="#000"/>
|
||||
<path android:pathData="M32 19.56H0v7.11h32v-7.11z" android:fillColor="#FFDA44"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#6DA544"/>
|
||||
<path android:pathData="M32 5.33v10.42L0 16.01V5.33h32z" android:fillColor="#338AF3"/>
|
||||
<path android:pathData="M16 16L0 26.67V5.33L16 16z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M5.91 13.22l0.69 2.12h2.24l-1.81 1.32 0.69 2.12-1.81-1.31-1.8 1.31 0.69-2.12-1.81-1.32h2.23l0.69-2.12z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M12 5.33H8V14H0v4h8v8.67h4V18h20v-4H12V5.33z" android:fillColor="#F0F0F0"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#496E2D"/>
|
||||
<path android:pathData="M0 14v4h32v-4H0z" android:fillColor="#000"/>
|
||||
<path android:pathData="M32 14H0v1.33h32V14z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M32 16.67H0V18h32v-1.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M18 5.33h-4v21.33h4V5.33z" android:fillColor="#000"/>
|
||||
<path android:pathData="M15.33 5.33H14v21.33h1.33V5.33z" android:fillColor="#FFDA44"/>
|
||||
<path android:pathData="M18 5.33h-1.33v21.33H18V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M16 21.1a5.1 5.1 0 1 0 0-10.2 5.1 5.1 0 0 0 0 10.2z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M17.16 16.57c-0.38-0.76-0.87-1.53-0.87-1.53v-0.56a0.571 0.571 0 0 0-0.57-0.57 0.56 0.56 0 0 0-0.56 0.52 0.45 0.45 0 0 0-0.18 0.85 0.64 0.64 0 0 1 0.38-0.36 0.241 0.241 0 0 0 0.09 0.06h0.07c-0.137 0.377-0.228 0.77-0.27 1.17A1.6 1.6 0 0 0 16 17.7l-0.38 0.38h0.76v-0.76l0.38 0.38a0.91 0.91 0 0 0 0.4-1.13zM16 11.83l0.17 0.53h0.56l-0.45 0.33 0.17 0.53L16 12.89l-0.45 0.33 0.17-0.53-0.45-0.33h0.56L16 11.83zm-2.45 0.79L14 12.95l0.45-0.33-0.17 0.53 0.45 0.33h-0.56L14 14.02l-0.17-0.54h-0.56l0.45-0.33-0.17-0.53zm-1.52 2.09h0.56l0.17-0.53 0.17 0.53h0.56l-0.45 0.33 0.17 0.53-0.45-0.33-0.45 0.33 0.17-0.53-0.45-0.33zm0 2.58l0.45-0.33-0.17-0.53 0.45 0.33 0.45-0.33-0.17 0.53 0.45 0.33h-0.56l-0.17 0.53-0.17-0.53h-0.56zm1.52 2.09l0.17-0.53-0.45-0.33h0.56L14 17.99l0.17 0.53h0.56l-0.45 0.33 0.17 0.53L14 19.05l-0.45 0.33zm2.45 0.8l-0.17-0.54h-0.56l0.45-0.32-0.17-0.54 0.45 0.33 0.45-0.33-0.17 0.54 0.45 0.32h-0.56L16 20.18zm2.45-0.8L18 19.05l-0.45 0.33 0.17-0.53-0.45-0.33h0.56L18 17.99l0.17 0.53h0.56l-0.45 0.33 0.17 0.53zm1.52-2.09h-0.56l-0.17 0.53-0.17-0.53h-0.56l0.45-0.33-0.17-0.53 0.45 0.33 0.45-0.33-0.17 0.53 0.45 0.33zm0-2.58l-0.45 0.33 0.17 0.53-0.45-0.33-0.45 0.33 0.17-0.53-0.45-0.33h0.56l0.17-0.53 0.17 0.53h0.56zm-1.52-2.09l-0.17 0.53 0.45 0.33h-0.56L18 14.02l-0.17-0.54h-0.56l0.45-0.33-0.17-0.53 0.45 0.33 0.45-0.33z" android:fillColor="#496E2D"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!--~ Copyright (c) 2021 Proton Technologies AG ~ This file is part of Proton Technologies AG and ProtonCore. ~ ~ ProtonCore is free software: you can redistribute it and/or modify ~ it under the terms of the GNU General Public License as published by ~ the Free Software Foundation, either version 3 of the License, or ~ (at your option) any later version. ~ ~ ProtonCore is distributed in the hope that it will be useful, ~ but WITHOUT ANY WARRANTY; without even the implied warranty of ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ~ GNU General Public License for more details. ~ ~ You should have received a copy of the GNU General Public License ~ along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M32 5.33H0v21.33h32V5.33z" android:fillColor="#F0F0F0"/>
|
||||
<path android:pathData="M13.22 18.78H0v7.88h13.22v-7.88z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M13.22 5.33H0v7.88h13.22V5.33z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M32 5.33H18.78v7.88H32V5.33z" android:fillColor="#D80027"/>
|
||||
<path android:pathData="M32 18.78H18.78v7.88H32v-7.88z" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M18.78 16a2.78 2.78 0 1 1-5.56 0c0-1.54 2.78-2.78 2.78-2.78s2.78 1.24 2.78 2.78z" android:fillColor="#496E2D"/>
|
||||
<path android:pathData="M13.22 16a2.78 2.78 0 0 1 5.56 0" android:fillColor="#0052B4"/>
|
||||
<path android:pathData="M14.43 14.61v1.74a1.57 1.57 0 1 0 3.14 0v-1.74h-3.14z" android:fillColor="#D80027"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user