twoFa -> validation naming; fixes for preview for screens (separating view model from ui); some improvements & fixes

This commit is contained in:
2024-07-13 22:45:49 +03:00
parent dfdc48b682
commit 733627f935
98 changed files with 1611 additions and 1637 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,12 @@
package com.meloda.app.fast.auth.validation
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.responses.SendSmsResponse
import kotlinx.coroutines.flow.Flow
interface AuthUseCase {
fun sendSms(
validationSid: String
): Flow<State<SendSmsResponse>>
}
@@ -0,0 +1,27 @@
package com.meloda.app.fast.auth.validation
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.auth.AuthRepository
import com.meloda.app.fast.model.api.responses.SendSmsResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class AuthUseCaseImpl(
private val authRepository: AuthRepository
) : AuthUseCase {
// TODO: 05/05/2024, Danil Nikolaev: implement
override fun sendSms(validationSid: String): Flow<State<SendSmsResponse>> = flow {
// emit(State.Loading)
//
// val newState = authRepository.sendSms(validationSid)
// .fold(
// onSuccess = { response -> State.Success(response) },
// onNetworkFailure = { State.Error.ConnectionError },
// onUnknownFailure = { State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
}
}
@@ -0,0 +1,199 @@
package com.meloda.app.fast.auth.validation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.auth.validation.model.ValidationType
import com.meloda.app.fast.auth.validation.navigation.Validation
import com.meloda.app.fast.auth.validation.validation.ValidationValidator
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.createTimerFlow
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.extensions.updateValue
import com.meloda.app.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 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 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
screenState.setValue { old ->
old.copy(
isSmsButtonVisible = arguments.canResendSms,
codeError = arguments.wrongCodeError,
validationText = getValidationText(ValidationType.parse(arguments.validationType)),
phoneMask = arguments.phoneMask
)
}
}
override fun onCodeInputChanged(newCode: String) {
screenState.updateValue(
screenState.value.copy(
code = newCode.trim(),
codeError = null
)
)
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.updateValue(
screenState.value.copy(
codeError = if (isValid) null
else "Field must not be empty"
)
)
return isValid
}
private fun sendValidationCode() {
authUseCase.sendSms(validationSid.orEmpty())
.listenValue { state ->
state.processState(
error = { error ->
},
success = { response ->
val newValidationType = response.validationType
val newCanResendSms = response.validationResend == "sms"
screenState.setValue { old ->
old.copy(
isSmsButtonVisible = newCanResendSms,
validationText = getValidationText(
ValidationType.parse(newValidationType.orEmpty())
)
)
}
startTickTimer(response.delay)
}
)
if (state.isLoading()) {
screenState.emit(screenState.value.copy(isSmsButtonVisible = false))
}
}
}
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)
}
private fun getValidationText(validationType: ValidationType): UiText {
return when (validationType) {
ValidationType.Sms -> {
UiText.Simple("SMS with the code is sent to ${screenState.value.phoneMask}")
}
ValidationType.App -> {
UiText.Simple("Enter the code from the code generator application")
}
is ValidationType.Other -> UiText.Simple(validationType.type)
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.app.fast.auth.validation.di
import com.meloda.app.fast.auth.validation.AuthUseCase
import com.meloda.app.fast.auth.validation.AuthUseCaseImpl
import com.meloda.app.fast.auth.validation.ValidationViewModel
import com.meloda.app.fast.auth.validation.ValidationViewModelImpl
import com.meloda.app.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,16 @@
package com.meloda.app.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,
val wrongCodeError: String?,
) : Parcelable
@@ -0,0 +1,27 @@
package com.meloda.app.fast.auth.validation.model
import com.meloda.app.fast.common.UiText
data class ValidationScreenState(
val code: String?,
val codeError: String?,
val isSmsButtonVisible: Boolean,
val delayTime: Int,
val phoneMask: String,
// TODO: 13/07/2024, Danil Nikolaev: check wtf is this
val validationText: UiText,
) {
companion object {
val EMPTY = ValidationScreenState(
code = null,
codeError = null,
isSmsButtonVisible = false,
delayTime = 0,
phoneMask = "",
validationText = UiText.Simple("")
)
}
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.auth.validation.model
sealed class ValidationType(val value: String) {
data object Sms : ValidationType(TYPE_SMS)
data object App : ValidationType(TYPE_TWO_FA_APP)
data class Other(val type: String) : ValidationType(type)
companion object {
private const val TYPE_SMS = "sms"
private const val TYPE_TWO_FA_APP = "2fa_app"
fun parse(validationType: String): ValidationType {
return when (validationType) {
TYPE_SMS -> Sms
TYPE_TWO_FA_APP -> App
else -> Other(validationType)
}
}
}
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.validation.model
sealed class ValidationValidationResult {
data object Empty : ValidationValidationResult()
data object Valid : ValidationValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,46 @@
package com.meloda.app.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 com.meloda.app.fast.auth.validation.model.ValidationArguments
import com.meloda.app.fast.auth.validation.presentation.ValidationRoute
import com.meloda.app.fast.common.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,281 @@
package com.meloda.app.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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.auth.validation.ValidationViewModel
import com.meloda.app.fast.auth.validation.ValidationViewModelImpl
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
import com.meloda.app.fast.designsystem.getString
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.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()
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.code
if (code == null) {
onBack()
} else {
onResult(code)
}
}
}
ValidationScreen(
screenState = screenState,
onBack = onBack,
onCodeInputChanged = viewModel::onCodeInputChanged,
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
onRequestSmsButtonClicked = viewModel::onRequestSmsButtonClicked,
onDoneButtonClicked = viewModel::onDoneButtonClicked
)
}
@Composable
fun ValidationScreen(
screenState: ValidationScreenState = ValidationScreenState.EMPTY,
onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {},
onTextFieldDoneAction: () -> Unit = {},
onRequestSmsButtonClicked: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
) {
val focusManager = LocalFocusManager.current
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
var showExitAlert by rememberSaveable {
mutableStateOf(false)
}
LaunchedEffect(confirmedExit) {
if (confirmedExit) {
onBack()
}
}
BackHandler(enabled = !confirmedExit) {
if (!confirmedExit) {
showExitAlert = true
}
}
if (showExitAlert) {
MaterialDialog(
onDismissAction = { showExitAlert = false },
title = UiText.Simple("Confirmation"),
text = UiText.Simple("Are you sure? Authorization process will be cancelled."),
confirmText = UiText.Resource(UiR.string.yes),
confirmAction = {
confirmedExit = true
},
cancelText = UiText.Resource(UiR.string.no)
)
}
var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) }
val codeError = screenState.codeError
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 = screenState.validationText.getString().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 (codeError != null) {
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 = codeError != null
)
AnimatedVisibility(visible = codeError != null) {
TextFieldErrorText(text = codeError.orEmpty())
}
}
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))
}
}
}
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.validation.validation
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.auth.validation.model.ValidationValidationResult
class ValidationValidator {
fun validate(screenState: ValidationScreenState): ValidationValidationResult {
return when {
screenState.code.isNullOrEmpty() -> ValidationValidationResult.Empty
else -> ValidationValidationResult.Valid
}
}
}