gradle build convention
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
package dev.meloda.fast.auth.login
|
||||
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import dev.meloda.fast.auth.login.presentation.LoginScreen
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LoginScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun singInButton_isClickable() {
|
||||
composeTestRule.setContent {
|
||||
LoginScreen()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(testTag = "sing_in_fab").assertHasClickAction()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.meloda.fast.auth.login
|
||||
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import dev.meloda.fast.auth.login.presentation.LogoScreen
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LogoScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun goNextButton_isClickable() {
|
||||
composeTestRule.setContent {
|
||||
LogoScreen()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(testTag = "go_next_fab").assertHasClickAction()
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,16 @@ import androidx.navigation.navigation
|
||||
import dev.meloda.fast.auth.captcha.navigation.captchaScreen
|
||||
import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha
|
||||
import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult
|
||||
import dev.meloda.fast.auth.login.navigation.Logo
|
||||
import dev.meloda.fast.auth.login.navigation.loginScreen
|
||||
import dev.meloda.fast.auth.login.navigation.navigateToLogin
|
||||
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
|
||||
import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned
|
||||
import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute
|
||||
import dev.meloda.fast.auth.validation.model.ValidationArguments
|
||||
import dev.meloda.fast.auth.validation.navigation.navigateToValidation
|
||||
import dev.meloda.fast.auth.validation.navigation.setValidationResult
|
||||
import dev.meloda.fast.auth.validation.navigation.validationScreen
|
||||
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
|
||||
import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned
|
||||
import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute
|
||||
import dev.meloda.fast.auth.login.navigation.Logo
|
||||
import dev.meloda.fast.auth.login.navigation.loginScreen
|
||||
import dev.meloda.fast.auth.login.navigation.navigateToLogin
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URLEncoder
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.meloda.fast.auth.captcha
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
|
||||
import dev.meloda.fast.auth.captcha.navigation.Captcha
|
||||
import dev.meloda.fast.auth.captcha.validation.CaptchaValidator
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
import dev.meloda.fast.common.extensions.updateValue
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.net.URLDecoder
|
||||
|
||||
interface CaptchaViewModel {
|
||||
val screenState: StateFlow<CaptchaScreenState>
|
||||
val isNeedToOpenLogin: StateFlow<Boolean>
|
||||
|
||||
fun onCodeInputChanged(newCode: String)
|
||||
|
||||
fun onTextFieldDoneAction()
|
||||
fun onDoneButtonClicked()
|
||||
|
||||
fun onNavigatedToLogin()
|
||||
}
|
||||
|
||||
class CaptchaViewModelImpl(
|
||||
private val validator: CaptchaValidator,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : CaptchaViewModel, ViewModel() {
|
||||
|
||||
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
|
||||
override val isNeedToOpenLogin = MutableStateFlow(false)
|
||||
|
||||
|
||||
init {
|
||||
val captchaImage = Captcha.from(savedStateHandle).captchaImageUrl
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(captchaImageUrl = URLDecoder.decode(captchaImage, "utf-8"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCodeInputChanged(newCode: String) {
|
||||
val newState = screenState.value.copy(code = newCode.trim())
|
||||
screenState.update { newState }
|
||||
processValidation()
|
||||
}
|
||||
|
||||
override fun onTextFieldDoneAction() {
|
||||
onDoneButtonClicked()
|
||||
}
|
||||
|
||||
override fun onDoneButtonClicked() {
|
||||
if (!processValidation()) return
|
||||
|
||||
isNeedToOpenLogin.update { true }
|
||||
}
|
||||
|
||||
override fun onNavigatedToLogin() {
|
||||
screenState.updateValue(CaptchaScreenState.EMPTY)
|
||||
isNeedToOpenLogin.update { false }
|
||||
}
|
||||
|
||||
private fun processValidation(): Boolean {
|
||||
val isValid = validator.validate(screenState.value).isValid()
|
||||
screenState.updateValue(screenState.value.copy(codeError = !isValid))
|
||||
return isValid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.meloda.fast.auth.captcha.di
|
||||
|
||||
import dev.meloda.fast.auth.captcha.CaptchaViewModel
|
||||
import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl
|
||||
import dev.meloda.fast.auth.captcha.validation.CaptchaValidator
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val captchaModule = module {
|
||||
singleOf(::CaptchaValidator)
|
||||
viewModelOf(::CaptchaViewModelImpl) bind CaptchaViewModel::class
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.meloda.fast.auth.captcha.model
|
||||
|
||||
data class CaptchaScreenState(
|
||||
val captchaImageUrl: String,
|
||||
val code: String,
|
||||
val codeError: Boolean
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY = CaptchaScreenState(
|
||||
captchaImageUrl = "",
|
||||
code = "",
|
||||
codeError = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.meloda.fast.auth.captcha.model
|
||||
|
||||
sealed class CaptchaValidationResult {
|
||||
data object Empty : CaptchaValidationResult()
|
||||
data object Valid : CaptchaValidationResult()
|
||||
|
||||
fun isValid() = this == Valid
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.meloda.fast.auth.captcha.navigation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import dev.meloda.fast.auth.captcha.presentation.CaptchaRoute
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Captcha(val captchaImageUrl: String) {
|
||||
|
||||
companion object {
|
||||
fun from(savedStateHandle: SavedStateHandle) = savedStateHandle.toRoute<Captcha>()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun NavGraphBuilder.captchaScreen(
|
||||
onBack: () -> Unit,
|
||||
onResult: (String) -> Unit
|
||||
) {
|
||||
composable<Captcha> {
|
||||
CaptchaRoute(
|
||||
onBack = onBack,
|
||||
onResult = onResult
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToCaptcha(captchaImageUrl: String) {
|
||||
this.navigate(Captcha(captchaImageUrl))
|
||||
}
|
||||
|
||||
fun NavController.setCaptchaResult(code: String?) {
|
||||
this.currentBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("captcha_code", code)
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package dev.meloda.fast.auth.captcha.presentation
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Done
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import dev.meloda.fast.auth.captcha.CaptchaViewModel
|
||||
import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl
|
||||
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
|
||||
import dev.meloda.fast.ui.components.ActionInvokeDismiss
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.components.TextFieldErrorText
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun CaptchaRoute(
|
||||
onBack: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(isNeedToOpenLogin) {
|
||||
if (isNeedToOpenLogin) {
|
||||
viewModel.onNavigatedToLogin()
|
||||
onResult(screenState.code)
|
||||
}
|
||||
}
|
||||
|
||||
CaptchaScreen(
|
||||
screenState = screenState,
|
||||
onBack = onBack,
|
||||
onCodeInputChanged = viewModel::onCodeInputChanged,
|
||||
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
|
||||
onDoneButtonClicked = viewModel::onDoneButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CaptchaScreen(
|
||||
screenState: CaptchaScreenState = CaptchaScreenState.EMPTY,
|
||||
onBack: () -> Unit = {},
|
||||
onCodeInputChanged: (String) -> Unit = {},
|
||||
onTextFieldDoneAction: () -> Unit = {},
|
||||
onDoneButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
var confirmedExit by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var showExitAlert by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
LaunchedEffect(confirmedExit) {
|
||||
if (confirmedExit) {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = !confirmedExit) {
|
||||
if (!confirmedExit) {
|
||||
showExitAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
if (showExitAlert) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { showExitAlert = false },
|
||||
title = stringResource(id = UiR.string.warning_confirmation),
|
||||
text = stringResource(id = UiR.string.captcha_exit_warning),
|
||||
confirmAction = { confirmedExit = true },
|
||||
confirmText = stringResource(id = UiR.string.yes),
|
||||
cancelText = stringResource(id = UiR.string.no),
|
||||
actionInvokeDismiss = ActionInvokeDismiss.Always
|
||||
)
|
||||
}
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(30.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onBack,
|
||||
text = {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = "Close icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Captcha",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(38.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "To proceed with your action, enter a code from the picture",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.weight(0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
val imageModifier = Modifier
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.height(48.dp)
|
||||
.width(130.dp)
|
||||
|
||||
if (LocalView.current.isInEditMode) {
|
||||
Image(
|
||||
painter = painterResource(id = UiR.drawable.test_captcha),
|
||||
contentDescription = "Captcha image",
|
||||
modifier = imageModifier
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.captchaImageUrl,
|
||||
contentDescription = "Captcha image",
|
||||
contentScale = ContentScale.FillBounds,
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
|
||||
var code by remember { mutableStateOf(TextFieldValue(screenState.code)) }
|
||||
val showError = screenState.codeError
|
||||
|
||||
TextField(
|
||||
value = code,
|
||||
onValueChange = { newText ->
|
||||
code = newText
|
||||
onCodeInputChanged(newText.text)
|
||||
},
|
||||
label = { Text(text = "Code") },
|
||||
placeholder = { Text(text = "Code") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_qr_code_24),
|
||||
contentDescription = "QR code icon",
|
||||
tint = if (showError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
onTextFieldDoneAction()
|
||||
}
|
||||
),
|
||||
isError = showError
|
||||
)
|
||||
|
||||
AnimatedVisibility(visible = showError) {
|
||||
TextFieldErrorText(text = "Field must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = onDoneButtonClicked,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Done,
|
||||
contentDescription = "Done icon",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun CaptchaScreenPreview() {
|
||||
CaptchaScreen(
|
||||
screenState = CaptchaScreenState.EMPTY.copy(
|
||||
code = "zcuecz"
|
||||
)
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.meloda.fast.auth.captcha.validation
|
||||
|
||||
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
|
||||
import dev.meloda.fast.auth.captcha.model.CaptchaValidationResult
|
||||
|
||||
class CaptchaValidator {
|
||||
|
||||
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
|
||||
return when {
|
||||
screenState.code.trim().isEmpty() -> CaptchaValidationResult.Empty
|
||||
else -> CaptchaValidationResult.Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package dev.meloda.fast.auth.login
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.meloda.fast.auth.login.model.CaptchaArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginError
|
||||
import dev.meloda.fast.auth.login.model.LoginScreenState
|
||||
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationResult
|
||||
import dev.meloda.fast.auth.login.validation.LoginValidator
|
||||
import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.UserConfig
|
||||
import dev.meloda.fast.common.VkConstants
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
import dev.meloda.fast.common.extensions.updateValue
|
||||
import dev.meloda.fast.common.model.LongPollState
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.data.api.users.UsersUseCase
|
||||
import dev.meloda.fast.data.db.AccountsRepository
|
||||
import dev.meloda.fast.data.processState
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.model.database.AccountEntity
|
||||
import dev.meloda.fast.network.OAuthErrorDomain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface LoginViewModel {
|
||||
val screenState: StateFlow<LoginScreenState>
|
||||
val loginError: StateFlow<LoginError?>
|
||||
|
||||
val validationCode: StateFlow<String?>
|
||||
val validationArguments: StateFlow<LoginValidationArguments?>
|
||||
val captchaCode: StateFlow<String?>
|
||||
val captchaArguments: StateFlow<CaptchaArguments?>
|
||||
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
|
||||
val isNeedToOpenMain: StateFlow<Boolean>
|
||||
val isNeedToShowFastSignInAlert: StateFlow<Boolean>
|
||||
|
||||
fun onPasswordVisibilityButtonClicked()
|
||||
|
||||
fun onLoginInputChanged(newLogin: String)
|
||||
fun onPasswordInputChanged(newPassword: String)
|
||||
|
||||
fun onSignInButtonClicked()
|
||||
|
||||
fun onErrorDialogDismissed()
|
||||
|
||||
fun onNavigatedToMain()
|
||||
fun onNavigatedToUserBanned()
|
||||
fun onNavigatedToCaptcha()
|
||||
fun onNavigatedToValidation()
|
||||
|
||||
fun onValidationCodeReceived(code: String)
|
||||
fun onCaptchaCodeReceived(code: String)
|
||||
|
||||
fun onLogoLongClicked()
|
||||
|
||||
fun onFastLogInAlertDismissed()
|
||||
fun onFastLogInAlertConfirmClicked(token: String)
|
||||
}
|
||||
|
||||
class LoginViewModelImpl(
|
||||
private val oAuthUseCase: dev.meloda.fast.auth.login.OAuthUseCase,
|
||||
private val usersUseCase: UsersUseCase,
|
||||
private val accountsRepository: AccountsRepository,
|
||||
private val loginValidator: LoginValidator,
|
||||
private val longPollController: LongPollController
|
||||
) : ViewModel(), dev.meloda.fast.auth.login.LoginViewModel {
|
||||
|
||||
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
|
||||
override val loginError = MutableStateFlow<LoginError?>(null)
|
||||
|
||||
override val validationCode = MutableStateFlow<String?>(null)
|
||||
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
|
||||
override val captchaCode = MutableStateFlow<String?>(null)
|
||||
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
|
||||
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
|
||||
override val isNeedToOpenMain = MutableStateFlow(false)
|
||||
override val isNeedToShowFastSignInAlert = MutableStateFlow(false)
|
||||
|
||||
private val validationState: StateFlow<List<LoginValidationResult>> =
|
||||
screenState.map(loginValidator::validate)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty))
|
||||
|
||||
override fun onPasswordVisibilityButtonClicked() {
|
||||
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
|
||||
}
|
||||
|
||||
override fun onLoginInputChanged(newLogin: String) {
|
||||
val newState = screenState.value.copy(
|
||||
login = newLogin.trim(),
|
||||
loginError = false
|
||||
)
|
||||
screenState.updateValue(newState)
|
||||
}
|
||||
|
||||
override fun onPasswordInputChanged(newPassword: String) {
|
||||
val newState = screenState.value.copy(
|
||||
password = newPassword.trim(),
|
||||
passwordError = false
|
||||
)
|
||||
screenState.updateValue(newState)
|
||||
}
|
||||
|
||||
override fun onSignInButtonClicked() {
|
||||
if (screenState.value.isLoading) return
|
||||
login()
|
||||
}
|
||||
|
||||
override fun onErrorDialogDismissed() {
|
||||
loginError.update { null }
|
||||
}
|
||||
|
||||
override fun onNavigatedToMain() {
|
||||
isNeedToOpenMain.update { false }
|
||||
}
|
||||
|
||||
override fun onNavigatedToUserBanned() {
|
||||
userBannedArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onNavigatedToCaptcha() {
|
||||
captchaArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onNavigatedToValidation() {
|
||||
validationArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onValidationCodeReceived(code: String) {
|
||||
validationCode.update { code }
|
||||
|
||||
login()
|
||||
}
|
||||
|
||||
override fun onCaptchaCodeReceived(code: String) {
|
||||
captchaCode.update { code }
|
||||
|
||||
login()
|
||||
}
|
||||
|
||||
override fun onLogoLongClicked() {
|
||||
isNeedToShowFastSignInAlert.update { true }
|
||||
}
|
||||
|
||||
override fun onFastLogInAlertDismissed() {
|
||||
isNeedToShowFastSignInAlert.update { false }
|
||||
}
|
||||
|
||||
override fun onFastLogInAlertConfirmClicked(token: String) {
|
||||
var currentAccount = AccountEntity(
|
||||
userId = -1,
|
||||
accessToken = token,
|
||||
fastToken = null,
|
||||
trustedHash = null
|
||||
).also { account ->
|
||||
UserConfig.currentUserId = account.userId
|
||||
UserConfig.userId = account.userId
|
||||
UserConfig.accessToken = account.accessToken
|
||||
UserConfig.fastToken = account.fastToken
|
||||
UserConfig.trustedHash = account.trustedHash
|
||||
}
|
||||
|
||||
usersUseCase.get(
|
||||
userIds = null,
|
||||
fields = VkConstants.USER_FIELDS,
|
||||
nomCase = null
|
||||
).listenValue { state ->
|
||||
state.processState(
|
||||
error = { error ->
|
||||
UserConfig.currentUserId = -1
|
||||
UserConfig.userId = -1
|
||||
UserConfig.accessToken = ""
|
||||
|
||||
// TODO: 19/07/2024, Danil Nikolaev: show error?
|
||||
},
|
||||
success = { response ->
|
||||
val actualUserId = response.first().id
|
||||
|
||||
currentAccount = currentAccount.copy(userId = actualUserId)
|
||||
|
||||
UserConfig.userId = actualUserId
|
||||
UserConfig.currentUserId = actualUserId
|
||||
|
||||
startLongPoll()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountsRepository.storeAccounts(listOf(currentAccount))
|
||||
delay(350)
|
||||
isNeedToOpenMain.update { true }
|
||||
}
|
||||
}
|
||||
)
|
||||
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun login(forceSms: Boolean = false) {
|
||||
val currentState = screenState.value.copy()
|
||||
|
||||
Log.d(
|
||||
"LoginViewModel",
|
||||
"auth: login: ${currentState.login}; " +
|
||||
"password: ${currentState.password}; " +
|
||||
"2fa code: ${validationCode.value}; " +
|
||||
"captcha code: ${captchaCode.value}"
|
||||
)
|
||||
|
||||
processValidation()
|
||||
if (!validationState.value.contains(LoginValidationResult.Valid)) return
|
||||
|
||||
oAuthUseCase.auth(
|
||||
login = currentState.login,
|
||||
password = currentState.password,
|
||||
forceSms = forceSms,
|
||||
validationCode = validationCode.value,
|
||||
captchaSid = captchaArguments.value?.captchaSid,
|
||||
captchaKey = captchaCode.value
|
||||
).listenValue { state ->
|
||||
state.processState(
|
||||
error = { error ->
|
||||
Log.d("LoginViewModelImpl", "login: error: $error")
|
||||
|
||||
validationCode.update { null }
|
||||
captchaCode.update { null }
|
||||
|
||||
parseError(error)
|
||||
},
|
||||
success = { response ->
|
||||
val userId = response.userId
|
||||
val accessToken = response.accessToken
|
||||
|
||||
if (userId == null || accessToken == null) {
|
||||
loginError.update { LoginError.Unknown }
|
||||
return@processState
|
||||
}
|
||||
|
||||
usersUseCase.get(
|
||||
userIds = listOf(userId),
|
||||
fields = VkConstants.USER_FIELDS,
|
||||
nomCase = null
|
||||
)
|
||||
|
||||
val currentAccount = AccountEntity(
|
||||
userId = userId,
|
||||
accessToken = accessToken,
|
||||
fastToken = null,
|
||||
trustedHash = response.validationHash
|
||||
).also { account ->
|
||||
UserConfig.currentUserId = account.userId
|
||||
UserConfig.userId = account.userId
|
||||
UserConfig.accessToken = account.accessToken
|
||||
UserConfig.fastToken = account.fastToken
|
||||
UserConfig.trustedHash = account.trustedHash
|
||||
}
|
||||
|
||||
startLongPoll()
|
||||
|
||||
accountsRepository.storeAccounts(listOf(currentAccount))
|
||||
|
||||
captchaArguments.update { null }
|
||||
captchaCode.update { null }
|
||||
|
||||
validationArguments.update { null }
|
||||
validationCode.update { null }
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
login = "",
|
||||
password = "",
|
||||
)
|
||||
}
|
||||
|
||||
isNeedToOpenMain.update { true }
|
||||
}
|
||||
)
|
||||
screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseError(stateError: State.Error): Boolean {
|
||||
return when (stateError) {
|
||||
is State.Error.OAuthError -> {
|
||||
when (val error = stateError.error) {
|
||||
is OAuthErrorDomain.ValidationRequiredError -> {
|
||||
val arguments = LoginValidationArguments(
|
||||
validationSid = error.validationSid,
|
||||
redirectUri = error.redirectUri,
|
||||
phoneMask = error.phoneMask,
|
||||
validationType = error.validationType.value,
|
||||
canResendSms = error.validationResend == "sms"
|
||||
)
|
||||
validationArguments.update { arguments }
|
||||
}
|
||||
|
||||
is OAuthErrorDomain.CaptchaRequiredError -> {
|
||||
val arguments = CaptchaArguments(
|
||||
captchaSid = error.captchaSid,
|
||||
captchaImageUrl = error.captchaImageUrl
|
||||
)
|
||||
captchaArguments.update { arguments }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.InvalidCredentialsError -> {
|
||||
loginError.update { LoginError.WrongCredentials }
|
||||
}
|
||||
|
||||
is OAuthErrorDomain.UserBannedError -> {
|
||||
val arguments = LoginUserBannedArguments(
|
||||
name = error.memberName,
|
||||
message = error.message,
|
||||
restoreUrl = error.restoreUrl,
|
||||
accessToken = error.accessToken
|
||||
)
|
||||
userBannedArguments.update { arguments }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.WrongValidationCode -> {
|
||||
loginError.update { LoginError.WrongValidationCode }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.WrongValidationCodeFormat -> {
|
||||
loginError.update { LoginError.WrongValidationCodeFormat }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.TooManyTriesError -> {
|
||||
loginError.update { LoginError.TooManyTries }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.UnknownError -> {
|
||||
loginError.update { LoginError.Unknown }
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun processValidation() {
|
||||
validationState.value.forEach { result ->
|
||||
when (result) {
|
||||
LoginValidationResult.LoginEmpty -> {
|
||||
screenState.updateValue(screenState.value.copy(loginError = true))
|
||||
}
|
||||
|
||||
LoginValidationResult.PasswordEmpty -> {
|
||||
screenState.updateValue(screenState.value.copy(passwordError = true))
|
||||
}
|
||||
|
||||
LoginValidationResult.Empty -> Unit
|
||||
LoginValidationResult.Valid -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLongPoll() {
|
||||
longPollController.setStateToApply(
|
||||
if (AppSettings.Debug.longPollInBackground) {
|
||||
LongPollState.Background
|
||||
} else {
|
||||
LongPollState.InApp
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.meloda.fast.auth.login
|
||||
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.auth.login.model.AuthInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface OAuthUseCase {
|
||||
|
||||
fun auth(
|
||||
login: String,
|
||||
password: String,
|
||||
forceSms: Boolean,
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?
|
||||
): Flow<State<AuthInfo>>
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package dev.meloda.fast.auth.login
|
||||
|
||||
import dev.meloda.fast.auth.login.model.AuthInfo
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.data.api.oauth.OAuthRepository
|
||||
import dev.meloda.fast.network.OAuthErrorDomain
|
||||
import dev.meloda.fast.network.ValidationType
|
||||
import dev.meloda.fast.network.VkOAuthError
|
||||
import dev.meloda.fast.network.VkOAuthErrorType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
class OAuthUseCaseImpl(
|
||||
private val oAuthRepository: OAuthRepository
|
||||
) : OAuthUseCase {
|
||||
|
||||
override fun auth(
|
||||
login: String,
|
||||
password: String,
|
||||
forceSms: Boolean,
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?
|
||||
): Flow<State<AuthInfo>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val response = oAuthRepository.auth(
|
||||
login = login,
|
||||
password = password,
|
||||
validationCode = validationCode,
|
||||
captchaSid = captchaSid,
|
||||
captchaKey = captchaKey,
|
||||
forceSms = forceSms
|
||||
)
|
||||
|
||||
val error = response.error?.let(VkOAuthError::parse)
|
||||
val errorType = response.errorType?.let(VkOAuthErrorType::parse)
|
||||
|
||||
val newState = when (error) {
|
||||
null -> {
|
||||
State.Success(
|
||||
AuthInfo(
|
||||
userId = response.userId,
|
||||
accessToken = response.accessToken,
|
||||
validationHash = response.validationHash
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VkOAuthError.FLOOD_CONTROL -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
|
||||
}
|
||||
|
||||
VkOAuthError.NEED_VALIDATION -> {
|
||||
if (response.banInfo != null) {
|
||||
val info = requireNotNull(response.banInfo)
|
||||
|
||||
State.Error.OAuthError(
|
||||
OAuthErrorDomain.UserBannedError(
|
||||
memberName = info.memberName,
|
||||
message = info.message,
|
||||
accessToken = info.accessToken,
|
||||
restoreUrl = info.restoreUrl
|
||||
)
|
||||
)
|
||||
} else {
|
||||
State.Error.OAuthError(
|
||||
OAuthErrorDomain.ValidationRequiredError(
|
||||
description = response.errorDescription.orEmpty(),
|
||||
validationType = response.validationType.orEmpty()
|
||||
.let(ValidationType::parse),
|
||||
validationSid = response.validationSid.orEmpty(),
|
||||
phoneMask = response.phoneMask.orEmpty(),
|
||||
redirectUri = response.redirectUri.orEmpty(),
|
||||
validationResend = response.validationResend,
|
||||
restoreIfCannotGetCode = response.restoreIfCannotGetCode
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VkOAuthError.NEED_CAPTCHA -> {
|
||||
State.Error.OAuthError(
|
||||
OAuthErrorDomain.CaptchaRequiredError(
|
||||
captchaSid = response.captchaSid.orEmpty(),
|
||||
captchaImageUrl = response.captchaImage.orEmpty()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VkOAuthError.INVALID_CLIENT -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
|
||||
}
|
||||
|
||||
VkOAuthError.INVALID_REQUEST -> {
|
||||
when (errorType) {
|
||||
null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError)
|
||||
|
||||
VkOAuthErrorType.WRONG_OTP -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
|
||||
}
|
||||
|
||||
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
|
||||
}
|
||||
|
||||
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
|
||||
}
|
||||
|
||||
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VkOAuthError.UNKNOWN -> {
|
||||
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
emit(newState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.meloda.fast.auth.login.di
|
||||
|
||||
import dev.meloda.fast.auth.login.LoginViewModel
|
||||
import dev.meloda.fast.auth.login.LoginViewModelImpl
|
||||
import dev.meloda.fast.auth.login.OAuthUseCase
|
||||
import dev.meloda.fast.auth.login.OAuthUseCaseImpl
|
||||
import dev.meloda.fast.auth.login.validation.LoginValidator
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val loginModule = module {
|
||||
singleOf(::LoginValidator)
|
||||
viewModelOf(::LoginViewModelImpl) bind dev.meloda.fast.auth.login.LoginViewModel::class
|
||||
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
data class AuthInfo(
|
||||
val userId: Int?,
|
||||
val accessToken: String?,
|
||||
val validationHash: String?
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class CaptchaArguments(
|
||||
val captchaSid: String,
|
||||
val captchaImageUrl: String
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class LoginError {
|
||||
data object Unknown : LoginError()
|
||||
data object WrongCredentials : LoginError()
|
||||
data object TooManyTries : LoginError()
|
||||
data object WrongValidationCode : LoginError()
|
||||
data object WrongValidationCodeFormat : LoginError()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class LoginScreenState(
|
||||
val login: String,
|
||||
val password: String,
|
||||
val isLoading: Boolean,
|
||||
val loginError: Boolean,
|
||||
val passwordError: Boolean,
|
||||
val passwordVisible: Boolean,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY = LoginScreenState(
|
||||
login = "",
|
||||
password = "",
|
||||
isLoading = false,
|
||||
loginError = false,
|
||||
passwordError = false,
|
||||
passwordVisible = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class LoginUserBannedArguments(
|
||||
val name: String,
|
||||
val message: String,
|
||||
val restoreUrl: String,
|
||||
val accessToken: String
|
||||
) : Parcelable
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class LoginValidationArguments(
|
||||
val validationSid: String,
|
||||
val redirectUri: String,
|
||||
val phoneMask: String,
|
||||
val validationType: String,
|
||||
val canResendSms: Boolean
|
||||
) : Parcelable
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
sealed class LoginValidationResult {
|
||||
|
||||
data object LoginEmpty : LoginValidationResult()
|
||||
|
||||
data object PasswordEmpty : LoginValidationResult()
|
||||
|
||||
data object Empty : LoginValidationResult()
|
||||
|
||||
data object Valid : LoginValidationResult()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.meloda.fast.auth.login.navigation
|
||||
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.common.extensions.navigation.sharedViewModel
|
||||
import dev.meloda.fast.auth.login.LoginViewModel
|
||||
import dev.meloda.fast.auth.login.LoginViewModelImpl
|
||||
import dev.meloda.fast.auth.login.model.CaptchaArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
|
||||
import dev.meloda.fast.auth.login.presentation.LoginRoute
|
||||
import dev.meloda.fast.auth.login.presentation.LogoRoute
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
object Login
|
||||
|
||||
@Serializable
|
||||
object Logo
|
||||
|
||||
fun NavGraphBuilder.loginScreen(
|
||||
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
|
||||
onNavigateToValidation: (LoginValidationArguments) -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
|
||||
onNavigateToCredentials: () -> Unit,
|
||||
navController: NavController
|
||||
) {
|
||||
composable<Login> { backStackEntry ->
|
||||
val viewModel: dev.meloda.fast.auth.login.LoginViewModel =
|
||||
backStackEntry.sharedViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>(navController = navController)
|
||||
|
||||
val validationCode = backStackEntry.getValidationResult()
|
||||
val captchaCode = backStackEntry.getCaptchaResult()
|
||||
|
||||
LoginRoute(
|
||||
onNavigateToUserBanned = onNavigateToUserBanned,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
onNavigateToCaptcha = onNavigateToCaptcha,
|
||||
onNavigateToValidation = onNavigateToValidation,
|
||||
validationCode = validationCode,
|
||||
captchaCode = captchaCode,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable<Logo> {
|
||||
LogoRoute(
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
onGoNextButtonClicked = onNavigateToCredentials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToLogin() {
|
||||
this.navigate(route = Login)
|
||||
}
|
||||
|
||||
fun NavBackStackEntry.getValidationResult(): String? {
|
||||
return savedStateHandle["validation_code"]
|
||||
}
|
||||
|
||||
fun NavBackStackEntry.getCaptchaResult(): String? {
|
||||
return savedStateHandle["captcha_code"]
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package dev.meloda.fast.auth.login.presentation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.auth.login.LoginViewModel
|
||||
import dev.meloda.fast.auth.login.LoginViewModelImpl
|
||||
import dev.meloda.fast.auth.login.model.CaptchaArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginError
|
||||
import dev.meloda.fast.auth.login.model.LoginScreenState
|
||||
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationArguments
|
||||
import dev.meloda.fast.ui.basic.autoFillRequestHandler
|
||||
import dev.meloda.fast.ui.basic.connectNode
|
||||
import dev.meloda.fast.ui.basic.defaultFocusChangeAutoFill
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.components.TextFieldErrorText
|
||||
import dev.meloda.fast.ui.theme.LocalSizeConfig
|
||||
import dev.meloda.fast.ui.util.handleEnterKey
|
||||
import dev.meloda.fast.ui.util.handleTabKey
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun LoginRoute(
|
||||
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
|
||||
onNavigateToValidation: (LoginValidationArguments) -> Unit,
|
||||
validationCode: String?,
|
||||
captchaCode: String?,
|
||||
viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
|
||||
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
|
||||
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
|
||||
val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
|
||||
val loginError by viewModel.loginError.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(isNeedToOpenMain) {
|
||||
if (isNeedToOpenMain) {
|
||||
viewModel.onNavigatedToMain()
|
||||
onNavigateToMain()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(userBannedArguments) {
|
||||
userBannedArguments?.let { arguments ->
|
||||
viewModel.onNavigatedToUserBanned()
|
||||
onNavigateToUserBanned(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(captchaArguments) {
|
||||
captchaArguments?.let { arguments ->
|
||||
viewModel.onNavigatedToCaptcha()
|
||||
onNavigateToCaptcha(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(validationArguments) {
|
||||
validationArguments?.let { arguments ->
|
||||
viewModel.onNavigatedToValidation()
|
||||
onNavigateToValidation(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(validationCode) {
|
||||
if (validationCode != null) {
|
||||
viewModel.onValidationCodeReceived(validationCode)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(captchaCode) {
|
||||
if (captchaCode != null) {
|
||||
viewModel.onCaptchaCodeReceived(captchaCode)
|
||||
}
|
||||
}
|
||||
|
||||
LoginScreen(
|
||||
screenState = screenState,
|
||||
onLoginAutoFilled = viewModel::onLoginInputChanged,
|
||||
onPasswordAutoFilled = viewModel::onPasswordInputChanged,
|
||||
onLoginInputChanged = viewModel::onLoginInputChanged,
|
||||
onPasswordInputChanged = viewModel::onPasswordInputChanged,
|
||||
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
|
||||
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
|
||||
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
|
||||
onSignInButtonClicked = viewModel::onSignInButtonClicked
|
||||
)
|
||||
|
||||
HandleError(
|
||||
onDismiss = viewModel::onErrorDialogDismissed,
|
||||
error = loginError
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
screenState: LoginScreenState = LoginScreenState.EMPTY,
|
||||
onLoginAutoFilled: (String) -> Unit = {},
|
||||
onPasswordAutoFilled: (String) -> Unit = {},
|
||||
onLoginInputChanged: (String) -> Unit = {},
|
||||
onPasswordInputChanged: (String) -> Unit = {},
|
||||
onPasswordFieldEnterKeyClicked: () -> Unit = {},
|
||||
onPasswordVisibilityButtonClicked: () -> Unit = {},
|
||||
onPasswordFieldGoAction: () -> Unit = {},
|
||||
onSignInButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
val currentSize = LocalSizeConfig.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
|
||||
|
||||
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
|
||||
val showLoginError = screenState.loginError
|
||||
|
||||
val autoFillEmailHandler = autoFillRequestHandler(
|
||||
autofillTypes = listOf(AutofillType.EmailAddress),
|
||||
onFill = { value ->
|
||||
loginText = TextFieldValue(text = value, selection = TextRange(value.length))
|
||||
onLoginAutoFilled(value)
|
||||
}
|
||||
)
|
||||
|
||||
var passwordText by remember { mutableStateOf(TextFieldValue(screenState.password)) }
|
||||
val showPasswordError = screenState.passwordError
|
||||
|
||||
val autoFillPasswordHandler = autoFillRequestHandler(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { value ->
|
||||
passwordText = TextFieldValue(text = value, selection = TextRange(value.length))
|
||||
onPasswordAutoFilled(value)
|
||||
}
|
||||
)
|
||||
|
||||
val titleStyle = if (currentSize.isWidthSmall) {
|
||||
MaterialTheme.typography.displayMedium
|
||||
} else {
|
||||
MaterialTheme.typography.displayMedium
|
||||
}
|
||||
|
||||
val titleSpacerSize = if (currentSize.isHeightSmall) {
|
||||
24.dp
|
||||
} else {
|
||||
58.dp
|
||||
}
|
||||
|
||||
val bottomPadding = if (currentSize.isHeightSmall) {
|
||||
10.dp
|
||||
} else {
|
||||
30.dp
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(top = 30.dp)
|
||||
.padding(horizontal = 30.dp)
|
||||
.padding(bottom = bottomPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = UiR.string.sign_in_to_vk),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = titleStyle
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(titleSpacerSize))
|
||||
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
.height(58.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.handleEnterKey {
|
||||
passwordFocusable.requestFocus()
|
||||
true
|
||||
}
|
||||
.handleTabKey {
|
||||
passwordFocusable.requestFocus()
|
||||
true
|
||||
}
|
||||
.focusRequester(loginFocusable)
|
||||
.connectNode(handler = autoFillEmailHandler)
|
||||
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler),
|
||||
value = loginText,
|
||||
onValueChange = { newText ->
|
||||
val text = newText.text
|
||||
if (text.isEmpty()) {
|
||||
autoFillEmailHandler.requestVerifyManual()
|
||||
}
|
||||
|
||||
loginText = newText
|
||||
onLoginInputChanged(text)
|
||||
},
|
||||
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
|
||||
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.ic_round_person_24),
|
||||
contentDescription = "Login icon",
|
||||
tint = if (showLoginError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardType = KeyboardType.Email
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
|
||||
isError = showLoginError,
|
||||
singleLine = true
|
||||
)
|
||||
AnimatedVisibility(visible = showLoginError) {
|
||||
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
.height(58.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.handleEnterKey {
|
||||
focusManager.clearFocus()
|
||||
onPasswordFieldEnterKeyClicked()
|
||||
true
|
||||
}
|
||||
.focusRequester(passwordFocusable)
|
||||
.connectNode(handler = autoFillPasswordHandler)
|
||||
.defaultFocusChangeAutoFill(handler = autoFillPasswordHandler),
|
||||
value = passwordText,
|
||||
onValueChange = { newText ->
|
||||
val text = newText.text
|
||||
if (text.isEmpty()) {
|
||||
autoFillPasswordHandler.requestVerifyManual()
|
||||
}
|
||||
|
||||
passwordText = newText
|
||||
onPasswordInputChanged(text)
|
||||
},
|
||||
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
|
||||
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
|
||||
contentDescription = "Password icon",
|
||||
tint = if (showPasswordError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
val imagePainter = painterResource(
|
||||
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
|
||||
else UiR.drawable.round_visibility_24
|
||||
)
|
||||
|
||||
IconButton(onClick = onPasswordVisibilityButtonClicked) {
|
||||
Icon(
|
||||
painter = imagePainter,
|
||||
contentDescription = if (screenState.passwordVisible) "Password visible icon"
|
||||
else "Password invisible icon"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Go,
|
||||
keyboardType = KeyboardType.Password
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
focusManager.clearFocus()
|
||||
onPasswordFieldGoAction()
|
||||
}
|
||||
),
|
||||
isError = showPasswordError,
|
||||
visualTransformation = if (screenState.passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
AnimatedVisibility(visible = showPasswordError) {
|
||||
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = !screenState.isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!screenState.isLoading) {
|
||||
focusManager.clearFocus()
|
||||
onSignInButtonClicked()
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.testTag("sing_in_fab")
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.ic_arrow_end),
|
||||
contentDescription = "Sign in icon",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = screenState.isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HandleError(
|
||||
onDismiss: () -> Unit,
|
||||
error: LoginError?,
|
||||
) {
|
||||
when (error) {
|
||||
null -> Unit
|
||||
|
||||
LoginError.Unknown -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Unknown error",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginError.WrongCredentials -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Wrong login or password.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginError.TooManyTries -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Too many tries. Try in another hour or later.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
LoginError.WrongValidationCode -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Wrong validation code.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginError.WrongValidationCodeFormat -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Wrong validation code format.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package dev.meloda.fast.auth.login.presentation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
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.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.auth.BuildConfig
|
||||
import dev.meloda.fast.ui.components.ActionInvokeDismiss
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.theme.LocalSizeConfig
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun LogoRoute(
|
||||
onNavigateToMain: () -> Unit,
|
||||
onGoNextButtonClicked: () -> Unit,
|
||||
viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
|
||||
val isNeedToShowSignInAlert by viewModel.isNeedToShowFastSignInAlert.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(isNeedToOpenMain) {
|
||||
if (isNeedToOpenMain) {
|
||||
viewModel.onNavigatedToMain()
|
||||
onNavigateToMain()
|
||||
}
|
||||
}
|
||||
|
||||
LogoScreen(
|
||||
isLoading = screenState.isLoading,
|
||||
onLogoLongClicked = viewModel::onLogoLongClicked,
|
||||
onGoNextButtonClicked = onGoNextButtonClicked
|
||||
)
|
||||
|
||||
if (isNeedToShowSignInAlert) {
|
||||
SignInAlert(
|
||||
onDismissRequest = viewModel::onFastLogInAlertDismissed,
|
||||
onConfirmClick = viewModel::onFastLogInAlertConfirmClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LogoScreen(
|
||||
isLoading: Boolean = false,
|
||||
onLogoLongClicked: () -> Unit = {},
|
||||
onGoNextButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
val currentSize = LocalSizeConfig.current
|
||||
|
||||
Scaffold { padding ->
|
||||
val topPadding by animateDpAsState(
|
||||
targetValue = padding.calculateTopPadding(),
|
||||
label = "topPaddingAnimation"
|
||||
)
|
||||
val bottomPadding by animateDpAsState(
|
||||
targetValue = padding.calculateBottomPadding(),
|
||||
label = "bottomPaddingAnimation"
|
||||
)
|
||||
|
||||
val endPadding by animateDpAsState(
|
||||
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr),
|
||||
label = "endPaddingAnimation"
|
||||
)
|
||||
val startPadding by animateDpAsState(
|
||||
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr),
|
||||
label = "startPaddingAnimation"
|
||||
)
|
||||
|
||||
val iconWidth = if (currentSize.isWidthSmall) {
|
||||
110.dp
|
||||
} else {
|
||||
134.dp
|
||||
}
|
||||
|
||||
val appNameTextStyle = if (currentSize.isWidthSmall) {
|
||||
MaterialTheme.typography.displayMedium.copy(fontSize = 40.sp)
|
||||
} else {
|
||||
MaterialTheme.typography.displayMedium
|
||||
}
|
||||
|
||||
val bottomAdditionalPadding = if (currentSize.isHeightSmall) {
|
||||
10.dp
|
||||
} else {
|
||||
30.dp
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = startPadding,
|
||||
top = topPadding,
|
||||
end = endPadding,
|
||||
bottom = bottomPadding
|
||||
)
|
||||
.padding(top = 30.dp)
|
||||
.padding(horizontal = 30.dp)
|
||||
.padding(bottom = bottomAdditionalPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.ic_logo_big),
|
||||
contentDescription = "Application Logo",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.width(iconWidth)
|
||||
.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onLongClick = onLogoLongClicked,
|
||||
onClick = {}
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(46.dp))
|
||||
Text(
|
||||
text = stringResource(id = UiR.string.fast_messenger),
|
||||
style = appNameTextStyle,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !isLoading,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (!isLoading) {
|
||||
onGoNextButtonClicked()
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.testTag("go_next_fab")
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.ic_arrow_end),
|
||||
contentDescription = "Go button",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isLoading,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SignInAlert(
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirmClick: (token: String) -> Unit
|
||||
) {
|
||||
var tokenText by rememberSaveable {
|
||||
mutableStateOf(BuildConfig.debugAccessToken)
|
||||
}
|
||||
|
||||
val maxWidthModifier = Modifier.fillMaxWidth()
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = "Fast authorization",
|
||||
confirmText = stringResource(id = UiR.string.action_authorize),
|
||||
confirmAction = { onConfirmClick(tokenText) },
|
||||
cancelText = stringResource(id = UiR.string.cancel),
|
||||
actionInvokeDismiss = ActionInvokeDismiss.Always
|
||||
) {
|
||||
Column(modifier = maxWidthModifier) {
|
||||
OutlinedTextField(
|
||||
modifier = maxWidthModifier.padding(horizontal = 16.dp),
|
||||
value = tokenText,
|
||||
onValueChange = { tokenText = it },
|
||||
placeholder = { Text(text = "Access token") },
|
||||
label = { Text(text = "Access token") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.meloda.fast.auth.login.validation
|
||||
|
||||
import dev.meloda.fast.common.extensions.addIf
|
||||
import dev.meloda.fast.auth.login.model.LoginScreenState
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationResult
|
||||
|
||||
class LoginValidator {
|
||||
|
||||
fun validate(screenState: LoginScreenState): List<LoginValidationResult> {
|
||||
val resultList = mutableListOf<LoginValidationResult>()
|
||||
|
||||
resultList.addIf(LoginValidationResult.LoginEmpty) {
|
||||
screenState.login.isBlank()
|
||||
}
|
||||
|
||||
resultList.addIf(LoginValidationResult.PasswordEmpty) {
|
||||
screenState.password.isBlank()
|
||||
}
|
||||
|
||||
resultList.addIf(LoginValidationResult.Valid) {
|
||||
resultList.isEmpty()
|
||||
}
|
||||
|
||||
return resultList
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.meloda.fast.auth.userbanned.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class UserBannedArguments(
|
||||
val userName: String,
|
||||
val message: String,
|
||||
val restoreUrl: String,
|
||||
val accessToken: String
|
||||
) : Parcelable
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.meloda.fast.auth.userbanned.model
|
||||
|
||||
data class UserBannedScreenState(
|
||||
val userName: String,
|
||||
val message: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY: UserBannedScreenState = UserBannedScreenState(
|
||||
userName = "",
|
||||
message = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.meloda.fast.auth.userbanned.navigation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
|
||||
import dev.meloda.fast.auth.userbanned.presentation.UserBannedRoute
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
@Serializable
|
||||
data class UserBanned(val arguments: UserBannedArguments)
|
||||
|
||||
val UserBannedNavType = object : NavType<UserBannedArguments>(isNullableAllowed = false) {
|
||||
override fun get(bundle: Bundle, key: String): UserBannedArguments? =
|
||||
BundleCompat.getParcelable(bundle, key, UserBannedArguments::class.java)
|
||||
|
||||
override fun parseValue(value: String): UserBannedArguments = Json.decodeFromString(value)
|
||||
|
||||
override fun serializeAsValue(value: UserBannedArguments): String = Json.encodeToString(value)
|
||||
|
||||
override fun put(bundle: Bundle, key: String, value: UserBannedArguments) {
|
||||
bundle.putParcelable(key, value)
|
||||
}
|
||||
|
||||
override val name: String = "UserBannedArguments"
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.userBannedRoute(onBack: () -> Unit) {
|
||||
composable<UserBanned>(
|
||||
typeMap = mapOf(typeOf<UserBannedArguments>() to UserBannedNavType)
|
||||
) { backStackEntry ->
|
||||
val arguments: UserBannedArguments = backStackEntry.toRoute()
|
||||
|
||||
UserBannedRoute(
|
||||
onBack = onBack,
|
||||
userName = arguments.userName,
|
||||
message = arguments.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToUserBanned(arguments: UserBannedArguments) {
|
||||
this.navigate(UserBanned(arguments))
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package dev.meloda.fast.auth.userbanned.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.auth.userbanned.model.UserBannedScreenState
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserBannedScreenPreview() {
|
||||
UserBannedScreen(
|
||||
screenState = UserBannedScreenState(
|
||||
userName = "Andre Shultz",
|
||||
message = "Bruteforce"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserBannedRoute(
|
||||
onBack: () -> Unit,
|
||||
userName: String,
|
||||
message: String
|
||||
) {
|
||||
val screenState = remember(userName, message) {
|
||||
UserBannedScreenState(
|
||||
userName = userName,
|
||||
message = message
|
||||
)
|
||||
}
|
||||
|
||||
UserBannedScreen(
|
||||
screenState = screenState,
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserBannedScreen(
|
||||
screenState: UserBannedScreenState = UserBannedScreenState.EMPTY,
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(text = stringResource(id = UiR.string.warning))
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = UiR.string.account_temporarily_blocked),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
|
||||
append(stringResource(id = UiR.string.user_name))
|
||||
append(": ")
|
||||
}
|
||||
|
||||
append(screenState.userName)
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
|
||||
append(stringResource(id = UiR.string.blocking_reason_title))
|
||||
append(": ")
|
||||
}
|
||||
append(screenState.message)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.auth.validation
|
||||
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.model.api.responses.ValidatePhoneResponse
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AuthUseCase {
|
||||
|
||||
fun validatePhone(
|
||||
validationSid: String
|
||||
): Flow<State<ValidatePhoneResponse>>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.meloda.fast.auth.validation
|
||||
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.data.api.auth.AuthRepository
|
||||
import dev.meloda.fast.data.mapToState
|
||||
import dev.meloda.fast.model.api.responses.ValidatePhoneResponse
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
class AuthUseCaseImpl(private val repository: AuthRepository) : AuthUseCase {
|
||||
|
||||
override fun validatePhone(validationSid: String): Flow<State<ValidatePhoneResponse>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val newState = repository.validatePhone(validationSid).mapToState()
|
||||
emit(newState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package dev.meloda.fast.auth.validation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.meloda.fast.auth.validation.model.ValidationScreenState
|
||||
import dev.meloda.fast.auth.validation.model.ValidationType
|
||||
import dev.meloda.fast.auth.validation.navigation.Validation
|
||||
import dev.meloda.fast.auth.validation.validation.ValidationValidator
|
||||
import dev.meloda.fast.common.extensions.createTimerFlow
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
import dev.meloda.fast.common.extensions.updateValue
|
||||
import dev.meloda.fast.data.processState
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
interface ValidationViewModel {
|
||||
|
||||
val screenState: StateFlow<ValidationScreenState>
|
||||
|
||||
val validationType: StateFlow<ValidationType?>
|
||||
|
||||
val isNeedToOpenLogin: StateFlow<Boolean>
|
||||
|
||||
fun onCodeInputChanged(newCode: String)
|
||||
|
||||
fun onBackButtonClicked()
|
||||
fun onCancelButtonClicked()
|
||||
fun onRequestSmsButtonClicked()
|
||||
fun onTextFieldDoneAction()
|
||||
fun onDoneButtonClicked()
|
||||
|
||||
fun onNavigatedToLogin()
|
||||
}
|
||||
|
||||
class ValidationViewModelImpl(
|
||||
private val validator: ValidationValidator,
|
||||
private val authUseCase: AuthUseCase,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ValidationViewModel, ViewModel() {
|
||||
|
||||
override val screenState = MutableStateFlow(ValidationScreenState.EMPTY)
|
||||
|
||||
override val validationType = MutableStateFlow<ValidationType?>(null)
|
||||
|
||||
override val isNeedToOpenLogin = MutableStateFlow(false)
|
||||
|
||||
private var validationSid: String? = null
|
||||
private var delayJob: Job? = null
|
||||
|
||||
init {
|
||||
// TODO: 08/07/2024, Danil Nikolaev: use when fixed
|
||||
//savedStateHandle.toRoute<Validation>().arguments
|
||||
|
||||
val arguments = Validation.from(savedStateHandle).arguments
|
||||
|
||||
validationSid = arguments.validationSid
|
||||
|
||||
validationType.setValue {
|
||||
ValidationType.parse(arguments.validationType)
|
||||
}
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
isSmsButtonVisible = arguments.canResendSms,
|
||||
phoneMask = arguments.phoneMask
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCodeInputChanged(newCode: String) {
|
||||
screenState.updateValue(
|
||||
screenState.value.copy(
|
||||
code = newCode.trim(),
|
||||
codeError = false
|
||||
)
|
||||
)
|
||||
|
||||
if (newCode.length == 6) {
|
||||
viewModelScope.launch {
|
||||
delay(250)
|
||||
onDoneButtonClicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackButtonClicked() {
|
||||
onCancelButtonClicked()
|
||||
}
|
||||
|
||||
override fun onCancelButtonClicked() {
|
||||
screenState.setValue { old -> old.copy(code = null) }
|
||||
isNeedToOpenLogin.update { true }
|
||||
}
|
||||
|
||||
override fun onRequestSmsButtonClicked() {
|
||||
sendValidationCode()
|
||||
}
|
||||
|
||||
override fun onTextFieldDoneAction() {
|
||||
onDoneButtonClicked()
|
||||
}
|
||||
|
||||
override fun onDoneButtonClicked() {
|
||||
if (!processValidation()) return
|
||||
|
||||
isNeedToOpenLogin.update { true }
|
||||
}
|
||||
|
||||
override fun onNavigatedToLogin() {
|
||||
screenState.updateValue(ValidationScreenState.EMPTY)
|
||||
isNeedToOpenLogin.update { false }
|
||||
}
|
||||
|
||||
private fun processValidation(): Boolean {
|
||||
val isValid = validator.validate(screenState.value).isValid()
|
||||
|
||||
screenState.setValue { old -> old.copy(codeError = !isValid) }
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
private fun sendValidationCode() {
|
||||
val sid = validationSid ?: return
|
||||
|
||||
authUseCase.validatePhone(sid)
|
||||
.listenValue { state ->
|
||||
state.processState(
|
||||
error = { error ->
|
||||
|
||||
},
|
||||
success = { response ->
|
||||
response.validationType?.let { newValidationType ->
|
||||
validationType.setValue { ValidationType.parse(newValidationType) }
|
||||
}
|
||||
|
||||
val newCanResendSms = response.validationResend == "sms"
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(isSmsButtonVisible = newCanResendSms)
|
||||
}
|
||||
|
||||
startTickTimer(response.delay)
|
||||
}
|
||||
)
|
||||
|
||||
if (state.isLoading()) {
|
||||
screenState.emit(screenState.value.copy(isSmsButtonVisible = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTickTimer(delay: Int?) {
|
||||
if (delay == null || delayJob?.isActive == true) return
|
||||
|
||||
delayJob = createTimerFlow(
|
||||
time = delay,
|
||||
onStartAction = {
|
||||
screenState.updateValue(
|
||||
screenState.value.copy(isSmsButtonVisible = false)
|
||||
)
|
||||
},
|
||||
onTickAction = { remainedTime ->
|
||||
screenState.updateValue(
|
||||
screenState.value.copy(delayTime = remainedTime)
|
||||
)
|
||||
},
|
||||
onTimeoutAction = {
|
||||
screenState.updateValue(
|
||||
screenState.value.copy(
|
||||
isSmsButtonVisible = true
|
||||
)
|
||||
)
|
||||
},
|
||||
).launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.meloda.fast.auth.validation.di
|
||||
|
||||
import dev.meloda.fast.auth.validation.AuthUseCase
|
||||
import dev.meloda.fast.auth.validation.AuthUseCaseImpl
|
||||
import dev.meloda.fast.auth.validation.ValidationViewModel
|
||||
import dev.meloda.fast.auth.validation.ValidationViewModelImpl
|
||||
import dev.meloda.fast.auth.validation.validation.ValidationValidator
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val validationModule = module {
|
||||
singleOf(::ValidationValidator)
|
||||
viewModelOf(::ValidationViewModelImpl) bind ValidationViewModel::class
|
||||
singleOf(::AuthUseCaseImpl) bind AuthUseCase::class
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.meloda.fast.auth.validation.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class ValidationArguments(
|
||||
val validationSid: String,
|
||||
val redirectUri: String,
|
||||
val phoneMask: String,
|
||||
val validationType: String,
|
||||
val canResendSms: Boolean
|
||||
) : Parcelable
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.meloda.fast.auth.validation.model
|
||||
|
||||
data class ValidationScreenState(
|
||||
val code: String?,
|
||||
val codeError: Boolean,
|
||||
val isSmsButtonVisible: Boolean,
|
||||
val delayTime: Int,
|
||||
val phoneMask: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY = ValidationScreenState(
|
||||
code = null,
|
||||
codeError = false,
|
||||
isSmsButtonVisible = false,
|
||||
delayTime = 0,
|
||||
phoneMask = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.meloda.fast.auth.validation.model
|
||||
|
||||
enum class ValidationType(val value: String) {
|
||||
SMS("sms"), APP("2fa_app");
|
||||
|
||||
companion object {
|
||||
fun parse(value: String): ValidationType = entries.firstOrNull { it.value == value }
|
||||
?: throw IllegalArgumentException("Unknown validation type with value: $value")
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.meloda.fast.auth.validation.model
|
||||
|
||||
sealed class ValidationValidationResult {
|
||||
data object Empty : ValidationValidationResult()
|
||||
data object Valid : ValidationValidationResult()
|
||||
|
||||
fun isValid() = this == Valid
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.meloda.fast.auth.validation.navigation
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import dev.meloda.fast.auth.validation.model.ValidationArguments
|
||||
import dev.meloda.fast.auth.validation.presentation.ValidationRoute
|
||||
import dev.meloda.fast.common.extensions.customNavType
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
@Serializable
|
||||
data class Validation(val arguments: ValidationArguments) {
|
||||
companion object {
|
||||
val typeMap = mapOf(typeOf<ValidationArguments>() to customNavType<ValidationArguments>())
|
||||
|
||||
fun from(savedStateHandle: SavedStateHandle) =
|
||||
savedStateHandle.toRoute<Validation>(typeMap)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.validationScreen(
|
||||
onBack: () -> Unit,
|
||||
onResult: (String) -> Unit
|
||||
) {
|
||||
composable<Validation>(typeMap = Validation.typeMap) {
|
||||
ValidationRoute(
|
||||
onBack = onBack,
|
||||
onResult = onResult
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToValidation(arguments: ValidationArguments) {
|
||||
this.navigate(Validation(arguments))
|
||||
}
|
||||
|
||||
fun NavController.setValidationResult(code: String?) {
|
||||
this.currentBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("validation_code", code)
|
||||
}
|
||||
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package dev.meloda.fast.auth.validation.presentation
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Done
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.auth.validation.ValidationViewModel
|
||||
import dev.meloda.fast.auth.validation.ValidationViewModelImpl
|
||||
import dev.meloda.fast.auth.validation.model.ValidationScreenState
|
||||
import dev.meloda.fast.auth.validation.model.ValidationType
|
||||
import dev.meloda.fast.ui.components.ActionInvokeDismiss
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.components.TextFieldErrorText
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun ValidationRoute(
|
||||
onBack: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
viewModel: ValidationViewModel = koinViewModel<ValidationViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
|
||||
val validationType by viewModel.validationType.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(isNeedToOpenLogin) {
|
||||
if (isNeedToOpenLogin) {
|
||||
viewModel.onNavigatedToLogin()
|
||||
|
||||
val code = screenState.code
|
||||
if (code == null) {
|
||||
onBack()
|
||||
} else {
|
||||
onResult(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ValidationScreen(
|
||||
screenState = screenState,
|
||||
validationType = validationType,
|
||||
onBack = onBack,
|
||||
onCodeInputChanged = viewModel::onCodeInputChanged,
|
||||
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
|
||||
onRequestSmsButtonClicked = viewModel::onRequestSmsButtonClicked,
|
||||
onDoneButtonClicked = viewModel::onDoneButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ValidationScreen(
|
||||
screenState: ValidationScreenState = ValidationScreenState.EMPTY,
|
||||
validationType: ValidationType? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onCodeInputChanged: (String) -> Unit = {},
|
||||
onTextFieldDoneAction: () -> Unit = {},
|
||||
onRequestSmsButtonClicked: () -> Unit = {},
|
||||
onDoneButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var confirmedExit by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var showExitAlert by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val validationText by remember(validationType) {
|
||||
mutableStateOf(
|
||||
when (validationType) {
|
||||
ValidationType.SMS -> "SMS with the code is sent to ${screenState.phoneMask}"
|
||||
ValidationType.APP -> "Enter the code from the code generator application"
|
||||
|
||||
null -> ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(confirmedExit) {
|
||||
if (confirmedExit) {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = !confirmedExit) {
|
||||
if (!confirmedExit) {
|
||||
showExitAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
if (showExitAlert) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { showExitAlert = false },
|
||||
title = stringResource(id = UiR.string.warning_confirmation),
|
||||
text = stringResource(id = UiR.string.validation_exit_warning),
|
||||
confirmAction = { confirmedExit = true },
|
||||
confirmText = stringResource(id = UiR.string.yes),
|
||||
cancelText = stringResource(id = UiR.string.no),
|
||||
actionInvokeDismiss = ActionInvokeDismiss.Always
|
||||
)
|
||||
}
|
||||
|
||||
var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) }
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(30.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onBack,
|
||||
text = {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = "Close icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Two-Factor\nAuthentication",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(38.dp))
|
||||
Text(
|
||||
text = validationText.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
val isResendTextVisible by remember {
|
||||
derivedStateOf { screenState.delayTime > 0 }
|
||||
}
|
||||
AnimatedVisibility(visible = isResendTextVisible) {
|
||||
Text(
|
||||
text = "Can resend after ${screenState.delayTime} seconds",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
TextField(
|
||||
value = code,
|
||||
onValueChange = { newText ->
|
||||
if (newText.text.length > 6) return@TextField
|
||||
|
||||
code = newText
|
||||
onCodeInputChanged(newText.text)
|
||||
},
|
||||
label = { Text(text = "Code") },
|
||||
placeholder = { Text(text = "Code") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_qr_code_24),
|
||||
contentDescription = "QR Code icon",
|
||||
tint = if (screenState.codeError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
onTextFieldDoneAction()
|
||||
}
|
||||
),
|
||||
isError = screenState.codeError
|
||||
)
|
||||
|
||||
AnimatedVisibility(screenState.codeError) {
|
||||
TextFieldErrorText(text = "Field must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
val canResendSms = screenState.isSmsButtonVisible
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = canResendSms,
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onRequestSmsButtonClicked,
|
||||
text = {
|
||||
Text(
|
||||
text = "Request SMS",
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_sms_24),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
contentDescription = "SMS icon"
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = onDoneButtonClicked,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Done,
|
||||
contentDescription = "Done icon",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !canResendSms,
|
||||
exit = shrinkHorizontally()
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ValidationScreenPreview() {
|
||||
ValidationScreen(
|
||||
screenState = ValidationScreenState.EMPTY.copy(
|
||||
phoneMask = "+7 (***) ***-**-21",
|
||||
code = "222222"
|
||||
),
|
||||
validationType = ValidationType.SMS
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.meloda.fast.auth.validation.validation
|
||||
|
||||
import dev.meloda.fast.auth.validation.model.ValidationScreenState
|
||||
import dev.meloda.fast.auth.validation.model.ValidationValidationResult
|
||||
|
||||
class ValidationValidator {
|
||||
|
||||
fun validate(screenState: ValidationScreenState): ValidationValidationResult {
|
||||
return when {
|
||||
screenState.code.isNullOrEmpty() -> ValidationValidationResult.Empty
|
||||
else -> ValidationValidationResult.Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user