gradle build convention

This commit is contained in:
2024-08-11 14:53:32 +03:00
parent f050a19df9
commit 648850f1c8
108 changed files with 517 additions and 1089 deletions
@@ -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
)
}
}
@@ -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
}
@@ -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)
}
@@ -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"
)
)
}
@@ -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
)
}
}
@@ -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
@@ -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
@@ -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
}
}
@@ -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
@@ -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 = ""
)
}
}
@@ -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))
}
@@ -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
}
@@ -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
@@ -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")
}
}
@@ -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
}
@@ -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)
}
@@ -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
)
}
@@ -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
}
}
}