feat(auth): Add Signup flow and UI.

This commit is contained in:
dkadrikj
2025-04-07 11:12:27 +02:00
parent d8bed97b08
commit 3c06490b2f
290 changed files with 4877 additions and 1397 deletions
+35
View File
@@ -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>
+2
View File
@@ -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
}
}
@@ -48,7 +48,7 @@ import org.junit.Test
@SmokeTest
@HiltAndroidTest
@UninstallModules(ServerProofModule::class)
internal class MessageLoadingTests : MockedNetworkTest() {
internal class MessageLoadTests : MockedNetworkTest() {
@JvmField
@BindValue
+2 -1
View File
@@ -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)
@@ -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
}
}
@@ -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
}
@@ -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"
}
@@ -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
}
}
@@ -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() }
)
}
}
@@ -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))
@@ -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"
)
)
)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
}
}
@@ -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)
}
}
@@ -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)
)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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
}
@@ -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
)
}
}
@@ -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)
}
@@ -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
}
@@ -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
@@ -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)
}
}
@@ -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
@@ -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 = {}
)
}
}
@@ -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
)
}
@@ -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()
}
}
@@ -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")
)
)
}
@@ -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()
}
}
@@ -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 = {}
)
}
}
@@ -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(
@@ -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()
}
}
@@ -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()
}
}
@@ -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
)
}
}
@@ -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
@@ -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)
}
}
@@ -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
)
@@ -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
}
}
@@ -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