Upstream changes (#23)

This commit is contained in:
2024-07-11 02:12:32 +03:00
committed by GitHub
parent 8a6378f509
commit 3503ecffab
906 changed files with 23577 additions and 24115 deletions
+1
View File
@@ -0,0 +1 @@
/build
+92
View File
@@ -0,0 +1,92 @@
import com.android.build.api.variant.BuildConfigField
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val sdkPackage: String = getLocalProperty("sdkPackage", "\"\"")
val sdkFingerprint: String = getLocalProperty("sdkFingerprint", "\"\"")
fun getLocalProperty(key: String, defValue: String): String {
return gradleLocalProperties(rootDir, providers).getProperty(key, defValue)
}
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
group = "com.meloda.app.fast.auth"
androidComponents {
onVariants { variant ->
variant.buildConfigFields.apply {
put(
"sdkPackage",
BuildConfigField(
type = "String",
value = sdkPackage,
comment = "sdkPackage for VK"
)
)
put(
"sdkFingerprint",
BuildConfigField(
type = "String",
value = sdkFingerprint,
comment = "sdkFingerprint for VK"
)
)
}
}
}
android {
namespace = "com.meloda.app.fast.auth"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(projects.feature.conversations)
implementation(projects.feature.auth.login)
implementation(projects.feature.auth.captcha)
implementation(projects.feature.auth.twofa)
implementation(projects.feature.auth.userbanned)
implementation(libs.koin.androidx.compose)
implementation(libs.koin.android)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+1
View File
@@ -0,0 +1 @@
/build
+59
View File
@@ -0,0 +1,59 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.meloda.app.fast.captcha"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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,81 @@
package com.meloda.app.fast.auth.captcha
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.model.CaptchaScreenState
import com.meloda.app.fast.auth.captcha.navigation.Captcha
import com.meloda.app.fast.auth.captcha.validation.CaptchaValidator
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.extensions.updateValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
interface CaptchaViewModel {
val screenState: StateFlow<CaptchaScreenState>
fun onCodeInputChanged(newCode: String)
fun onTextFieldDoneClicked()
fun onDoneButtonClicked()
fun setArguments(arguments: CaptchaArguments)
fun onNavigatedToLogin()
}
class CaptchaViewModelImpl(
private val validator: CaptchaValidator,
savedStateHandle: SavedStateHandle
) : CaptchaViewModel, ViewModel() {
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
init {
val arguments = Captcha.from(savedStateHandle).arguments
screenState.setValue { old ->
old.copy(
captchaSid = arguments.captchaSid,
captchaImage = arguments.captchaImage
)
}
}
override fun onCodeInputChanged(newCode: String) {
val newState = screenState.value.copy(captchaCode = newCode.trim())
screenState.update { newState }
processValidation()
}
override fun onTextFieldDoneClicked() {
onDoneButtonClicked()
}
override fun onDoneButtonClicked() {
if (!processValidation()) return
screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true))
}
override fun setArguments(arguments: CaptchaArguments) {
// screenState.updateValue(
// screenState.value.copy(
// captchaSid = arguments.captchaSid,
// captchaImage = arguments.captchaImage
// )
// )
}
override fun onNavigatedToLogin() {
screenState.updateValue(CaptchaScreenState.EMPTY)
}
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 com.meloda.app.fast.auth.captcha.di
import com.meloda.app.fast.auth.captcha.CaptchaViewModel
import com.meloda.app.fast.auth.captcha.CaptchaViewModelImpl
import com.meloda.app.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,12 @@
package com.meloda.app.fast.auth.captcha.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class CaptchaArguments(
val captchaSid: String,
val captchaImage: String
) : Parcelable
@@ -0,0 +1,20 @@
package com.meloda.app.fast.auth.captcha.model
data class CaptchaScreenState(
val captchaSid: String,
val captchaImage: String,
val captchaCode: String,
val codeError: Boolean,
val isNeedToOpenLogin: Boolean
) {
companion object {
val EMPTY = CaptchaScreenState(
captchaSid = "",
captchaImage = "",
captchaCode = "",
codeError = false,
isNeedToOpenLogin = false
)
}
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.captcha.model
sealed class CaptchaValidationResult {
data object Empty : CaptchaValidationResult()
data object Valid : CaptchaValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,48 @@
package com.meloda.app.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 com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.presentation.CaptchaScreen
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class Captcha(val arguments: CaptchaArguments) {
companion object {
val typeMap = mapOf(typeOf<CaptchaArguments>() to customNavType<CaptchaArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<Captcha>(typeMap)
}
}
fun NavGraphBuilder.captchaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha>(
typeMap = Captcha.typeMap
) {
CaptchaScreen(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToCaptcha(arguments: CaptchaArguments) {
this.navigate(Captcha(arguments))
}
fun NavController.setCaptchaResult(code: String) {
this.currentBackStackEntry
?.savedStateHandle
?.set("captchacode", code)
}
@@ -0,0 +1,234 @@
package com.meloda.app.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.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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.meloda.app.fast.auth.captcha.CaptchaViewModel
import com.meloda.app.fast.auth.captcha.CaptchaViewModelImpl
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun CaptchaScreen(
onBack: () -> Unit,
onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
var showExitAlert by rememberSaveable {
mutableStateOf(false)
}
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? Captcha process will be cancelled."),
confirmText = UiText.Resource(UiR.string.yes),
confirmAction = {
confirmedExit = true
},
cancelText = UiText.Resource(UiR.string.no)
)
}
if (screenState.isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.captchaCode)
}
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 = ImageRequest.Builder(LocalContext.current)
.data(screenState.captchaImage)
.crossfade(true)
.build(),
contentDescription = "Captcha image",
contentScale = ContentScale.FillBounds,
modifier = imageModifier
)
}
}
Spacer(modifier = Modifier.height(30.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.captchaCode)) }
val showError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
code = newText
viewModel.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()
viewModel.onTextFieldDoneClicked()
}
),
isError = showError
)
AnimatedVisibility(visible = showError) {
TextFieldErrorText(text = "Field must not be empty")
}
}
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(
imageVector = Icons.Rounded.Done,
contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.captcha.validation
import com.meloda.app.fast.auth.captcha.model.CaptchaScreenState
import com.meloda.app.fast.auth.captcha.model.CaptchaValidationResult
class CaptchaValidator {
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
return when {
screenState.captchaCode.isEmpty() -> CaptchaValidationResult.Empty
else -> CaptchaValidationResult.Valid
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+92
View File
@@ -0,0 +1,92 @@
import com.android.build.api.variant.BuildConfigField
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
val debugUserId: String = getLocalProperty("userId", "\"0\"")
val debugAccessToken: String = getLocalProperty("accessToken", "\"\"")
fun getLocalProperty(key: String, defValue: String): String {
return gradleLocalProperties(rootDir, providers).getProperty(key, defValue)
}
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
androidComponents {
onVariants { variant ->
variant.buildConfigFields.apply {
put(
"debugUserId",
BuildConfigField(
type = "String",
value = debugUserId,
comment = "user id for debugging purposes"
)
)
put(
"debugAccessToken",
BuildConfigField(
type = "String",
value = debugAccessToken,
comment = "access token for debugging purposes"
)
)
}
}
}
android {
namespace = "com.meloda.app.fast.auth.login"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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,376 @@
package com.meloda.fast.auth.login
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.auth.login.BuildConfig
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.VkConstants
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.State
import com.meloda.app.fast.data.api.users.UsersUseCase
import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.LoginValidationResult
import com.meloda.fast.auth.login.validation.LoginValidator
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.launch
interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
fun onPasswordVisibilityButtonClicked()
fun onLoginInputChanged(newLogin: String)
fun onPasswordInputChanged(newPassword: String)
fun onSignInButtonClicked()
fun onErrorDialogDismissed()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToTwoFa()
fun onTwoFaCodeReceived(code: String)
fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked()
}
class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase,
private val usersUseCase: UsersUseCase,
private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator,
) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
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() {
login()
}
override fun onErrorDialogDismissed() {
screenState.setValue { old -> old.copy(error = null) }
}
override fun onNavigatedToMain() {
screenState.setValue { old -> old.copy(isNeedToNavigateToMain = false) }
}
override fun onNavigatedToUserBanned() {
screenState.setValue { old -> old.copy(userBannedArguments = null) }
}
override fun onNavigatedToCaptcha() {
screenState.setValue { old -> old.copy(captchaArguments = null) }
}
override fun onNavigatedToTwoFa() {
screenState.setValue { old -> old.copy(twoFaArguments = null) }
}
override fun onTwoFaCodeReceived(code: String) {
screenState.setValue { old -> old.copy(validationCode = code) }
login()
}
override fun onCaptchaCodeReceived(code: String) {
screenState.setValue { old -> old.copy(captchaCode = code) }
login()
}
override fun onLogoLongClicked() {
val currentAccount = AccountEntity(
userId = BuildConfig.debugUserId.toInt(),
accessToken = BuildConfig.debugAccessToken,
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
}
viewModelScope.launch(Dispatchers.IO) {
accountsRepository.storeAccounts(listOf(currentAccount))
delay(350)
screenState.setValue { old -> old.copy(isNeedToNavigateToMain = true) }
}
}
private fun login(forceSms: Boolean = false) {
val currentState = screenState.value.copy()
Log.d(
"LoginViewModel",
"auth: login: ${currentState.login}; password: ${currentState.password}; code: ${currentState.validationCode}"
)
processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return
oAuthUseCase.auth(
login = currentState.login,
password = currentState.password,
forceSms = forceSms,
twoFaCode = currentState.validationCode,
captchaSid = currentState.captchaArguments?.captchaSid,
captchaKey = currentState.captchaCode
).listenValue { state ->
state.processState(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
parseError(error)
},
success = { response ->
val userId = response.userId
val accessToken = response.accessToken
if (userId == null || accessToken == null) {
// TODO: 11/04/2024, Danil Nikolaev: send unknown error event
return@processState
}
usersUseCase.getUserById(
userId = userId,
fields = VkConstants.USER_FIELDS,
nomCase = null
).listenValue { state ->
state.processState(
error = {},
success = { user -> user?.let { usersUseCase.storeUser(user) } }
)
}
val currentAccount = AccountEntity(
userId = userId,
accessToken = accessToken,
fastToken = null,
trustedHash = response.twoFaHash
).also { account ->
UserConfig.currentUserId = account.userId
UserConfig.userId = account.userId
UserConfig.accessToken = account.accessToken
UserConfig.fastToken = account.fastToken
UserConfig.trustedHash = account.trustedHash
}
accountsRepository.storeAccounts(listOf(currentAccount))
screenState.setValue { old ->
old.copy(
captchaArguments = null,
captchaCode = null,
validationSid = null,
validationCode = null,
twoFaArguments = null,
login = "",
password = "",
isNeedToNavigateToMain = 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 twoFaArguments = LoginTwoFaArguments(
validationSid = error.validationSid,
redirectUri = error.redirectUri,
phoneMask = error.phoneMask,
validationType = error.validationType.value,
canResendSms = error.validationResend == "sms",
wrongCodeError = null
)
screenState.setValue { old -> old.copy(twoFaArguments = twoFaArguments) }
true
}
is OAuthErrorDomain.CaptchaRequiredError -> {
val captchaArguments = CaptchaArguments(
captchaSid = error.captchaSid,
captchaImage = error.captchaImageUrl
)
screenState.setValue { old -> old.copy(captchaArguments = captchaArguments) }
true
}
OAuthErrorDomain.InvalidCredentialsError -> TODO()
is OAuthErrorDomain.UserBannedError -> TODO()
OAuthErrorDomain.WrongTwoFaCode -> TODO()
OAuthErrorDomain.WrongTwoFaCodeFormat -> TODO()
OAuthErrorDomain.UnknownError -> TODO()
}
}
else -> false
}
// return when (val error =
// (stateError as? State.Error.OAuthError<*>)?.error) {
// null -> false
// is CaptchaRequiredError -> {
// val captchaArguments = CaptchaArguments(
// captchaSid = error.captchaSid,
// captchaImage = error.captchaImage,
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenCaptcha = true,
// captchaArguments = captchaArguments
// )
// }
//
// true
// }
//
// is InvalidCredentialsError -> {
// screenState.setValue { old -> old.copy(error = LoginError.WrongCredentials) }
//
// true
// }
//
// is UserBannedError -> {
// val banInfo = error.banInfo
//
// val userBannedArguments = UserBannedArguments(
// name = banInfo.memberName,
// message = banInfo.message,
// restoreUrl = banInfo.restoreUrl,
// accessToken = banInfo.accessToken
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenUserBanned = true,
// userBannedArguments = userBannedArguments
// )
// }
//
// true
// }
//
// is ValidationRequiredError -> {
// val twoFaArguments = TwoFaArguments(
// validationSid = error.validationSid,
// redirectUri = error.redirectUri,
// phoneMask = error.phoneMask,
// validationType = error.validationType,
// canResendSms = error.validationResend == "sms",
// wrongCodeError = null
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = twoFaArguments
// )
// }
//
// true
// }
//
// is WrongTwoFaCode -> {
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = old.twoFaArguments?.copy(
// wrongCodeError = UiText.Simple("Wrong code")
// )
// )
// }
//
// true
// }
//
// is WrongTwoFaCodeFormat -> {
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = old.twoFaArguments?.copy(
// wrongCodeError = UiText.Simple("Wrong code format")
// )
// )
// }
//
// 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
}
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.fast.auth.login
import com.meloda.app.fast.data.State
import com.meloda.fast.auth.login.model.AuthInfo
import kotlinx.coroutines.flow.Flow
interface OAuthUseCase {
fun auth(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>>
}
@@ -0,0 +1,111 @@
package com.meloda.fast.auth.login
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.oauth.OAuthRepository
import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.app.fast.network.ValidationType
import com.meloda.app.fast.network.VkErrorTypes
import com.meloda.app.fast.network.VkOAuthErrors
import com.meloda.fast.auth.login.model.AuthInfo
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,
twoFaCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>> = flow {
emit(State.Loading)
val response = oAuthRepository.auth(
login = login,
password = password,
twoFaCode = twoFaCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
forceSms = forceSms
)
val newState = when (response.error) {
null -> {
State.Success(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
twoFaHash = response.twoFaHash
)
)
}
VkOAuthErrors.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
)
)
}
}
VkOAuthErrors.NEED_CAPTCHA -> {
State.Error.OAuthError(
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
)
)
}
VkOAuthErrors.INVALID_CLIENT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
VkOAuthErrors.INVALID_REQUEST -> {
when (response.errorType) {
VkErrorTypes.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCode)
}
VkErrorTypes.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCodeFormat)
}
else -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
}
}
else -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
}
emit(newState)
}
}
@@ -0,0 +1,17 @@
package com.meloda.fast.auth.login.di
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.OAuthUseCase
import com.meloda.fast.auth.login.OAuthUseCaseImpl
import com.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 LoginViewModel::class
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
}
@@ -0,0 +1,7 @@
package com.meloda.fast.auth.login.model
data class AuthInfo(
val userId: Int?,
val accessToken: String?,
val twoFaHash: String?
)
@@ -0,0 +1,12 @@
package com.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 captchaImage: String
) : Parcelable
@@ -0,0 +1,8 @@
package com.meloda.fast.auth.login.model
data class LoginArguments(val code: String) {
companion object {
val EMPTY: LoginArguments = LoginArguments(code = "")
}
}
@@ -0,0 +1,6 @@
package com.meloda.fast.auth.login.model
sealed interface LoginError {
data object WrongCredentials : LoginError
}
@@ -0,0 +1,44 @@
package com.meloda.fast.auth.login.model
import androidx.compose.runtime.Immutable
// TODO: 04/05/2024, Danil Nikolaev: simplify
@Immutable
data class LoginScreenState(
val login: String,
val password: String,
val captchaCode: String?,
val validationSid: String?,
val validationCode: String?,
val isLoading: Boolean,
val loginError: Boolean,
val passwordError: Boolean,
val passwordVisible: Boolean,
val copiedCode: String?,
val isNeedToNavigateToMain: Boolean,
val twoFaArguments: LoginTwoFaArguments?,
val captchaArguments: CaptchaArguments?,
val userBannedArguments: UserBannedArguments?,
val error: LoginError?,
) {
companion object {
val EMPTY = LoginScreenState(
login = "",
password = "",
captchaCode = null,
validationSid = null,
validationCode = null,
isLoading = false,
loginError = false,
passwordError = false,
passwordVisible = false,
copiedCode = null,
isNeedToNavigateToMain = false,
twoFaArguments = null,
captchaArguments = null,
userBannedArguments = null,
error = null,
)
}
}
@@ -0,0 +1,16 @@
package com.meloda.fast.auth.login.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class LoginTwoFaArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
val validationType: String,
val canResendSms: Boolean,
val wrongCodeError: String?,
) : Parcelable
@@ -0,0 +1,12 @@
package com.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,14 @@
package com.meloda.fast.auth.login.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class UserBannedArguments(
val name: String,
val message: String,
val restoreUrl: String,
val accessToken: String
) : Parcelable
@@ -0,0 +1,56 @@
package com.meloda.fast.auth.login.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.UserBannedArguments
import com.meloda.fast.auth.login.presentation.LoginScreen
import com.meloda.fast.auth.login.presentation.LogoScreen
import kotlinx.serialization.Serializable
@Serializable
object Login
@Serializable
object Logo
fun NavGraphBuilder.loginRoute(
onError: (BaseError) -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (UserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
navController: NavController
) {
composable<Login> {
val viewModel: LoginViewModel =
it.sharedViewModel<LoginViewModelImpl>(navController = navController)
LoginScreen(
onError = onError,
onNavigateToUserBanned = onNavigateToUserBanned,
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToTwoFa = onNavigateToTwoFa,
viewModel = viewModel
)
}
composable<Logo> {
LogoScreen(
onNavigateToMain = onNavigateToMain,
onShowCredentials = onNavigateToCredentials
)
}
}
fun NavController.navigateToLogin() {
this.navigate(route = Login)
}
@@ -0,0 +1,330 @@
package com.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
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.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.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 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.autoFillRequestHandler
import com.meloda.app.fast.designsystem.connectNode
import com.meloda.app.fast.designsystem.defaultFocusChangeAutoFill
import com.meloda.app.fast.designsystem.handleEnterKey
import com.meloda.app.fast.designsystem.handleTabKey
import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.UserBannedArguments
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
onError: (BaseError) -> Unit,
onNavigateToUserBanned: (UserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
if (screenState.isNeedToNavigateToMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
screenState.userBannedArguments?.let { arguments ->
viewModel.onNavigatedToUserBanned()
onNavigateToUserBanned(arguments)
}
screenState.captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments)
}
screenState.twoFaArguments?.let { arguments ->
viewModel.onNavigatedToTwoFa()
onNavigateToTwoFa(arguments)
}
val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
// TODO: 29/06/2024, Danil Nikolaev: remove lambda
val goButtonClickAction = {
if (!screenState.isLoading) {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
}
}
val loginFieldTabClick = {
passwordFocusable.requestFocus()
true
}
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))
viewModel.onLoginInputChanged(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))
viewModel.onPasswordInputChanged(value)
}
)
Scaffold { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(30.dp)
.imePadding()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
) {
Text(
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.height(58.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey(loginFieldTabClick::invoke)
.handleTabKey(loginFieldTabClick::invoke)
.focusRequester(loginFocusable)
.connectNode(handler = autoFillEmailHandler)
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler),
value = loginText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillEmailHandler.requestVerifyManual()
}
loginText = newText
viewModel.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 {
goButtonClickAction.invoke()
true
}
.focusRequester(passwordFocusable)
.connectNode(handler = autoFillPasswordHandler)
.defaultFocusChangeAutoFill(handler = autoFillPasswordHandler),
value = passwordText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillPasswordHandler.requestVerifyManual()
}
passwordText = newText
viewModel.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 = viewModel::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 = { goButtonClickAction.invoke() }
),
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
) {
FloatingActionButton(
onClick = goButtonClickAction::invoke,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("Sign in button")
) {
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()
}
}
}
}
}
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = screenState.error
)
}
@Composable
fun HandleError(
onDismiss: () -> Unit,
error: LoginError?,
) {
when (error) {
LoginError.WrongCredentials -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
text = UiText.Simple("Wrong login or password"),
confirmText = UiText.Resource(UiR.string.ok)
)
}
null -> Unit
}
}
@@ -0,0 +1,110 @@
package com.meloda.fast.auth.login.presentation
import androidx.compose.animation.core.animateDpAsState
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.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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
onNavigateToMain: () -> Unit,
onShowCredentials: () -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
if (screenState.isNeedToNavigateToMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
Scaffold { padding ->
val topPadding by animateDpAsState(targetValue = padding.calculateTopPadding())
val bottomPadding by animateDpAsState(targetValue = padding.calculateBottomPadding())
val endPadding by animateDpAsState(
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr)
)
val startPadding by animateDpAsState(
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr)
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = startPadding,
top = topPadding,
end = endPadding,
bottom = bottomPadding
)
.padding(30.dp)
) {
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.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = viewModel::onLogoLongClicked,
onClick = {}
)
)
Spacer(modifier = Modifier.height(46.dp))
Text(
text = stringResource(id = UiR.string.fast_messenger),
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
FloatingActionButton(
onClick = onShowCredentials,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.BottomCenter)
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Go button",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
@@ -0,0 +1,27 @@
package com.meloda.fast.auth.login.validation
import com.meloda.app.fast.common.extensions.addIf
import com.meloda.fast.auth.login.model.LoginScreenState
import com.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,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,102 @@
package com.meloda.app.fast.auth
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.navigation
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.navigation.captchaRoute
import com.meloda.app.fast.auth.captcha.navigation.navigateToCaptcha
import com.meloda.app.fast.auth.captcha.navigation.setCaptchaResult
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.navigation.navigateToTwoFa
import com.meloda.app.fast.auth.twofa.navigation.setTwoFaResult
import com.meloda.app.fast.auth.twofa.navigation.twoFaRoute
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.userbanned.model.UserBannedArguments
import com.meloda.app.fast.userbanned.navigation.navigateToUserBanned
import com.meloda.app.fast.userbanned.navigation.userBannedRoute
import com.meloda.fast.auth.login.navigation.Logo
import com.meloda.fast.auth.login.navigation.loginRoute
import com.meloda.fast.auth.login.navigation.navigateToLogin
import kotlinx.serialization.Serializable
@Serializable
object AuthGraph
fun NavGraphBuilder.authNavGraph(
onError: (BaseError) -> Unit,
onNavigateToMain: () -> Unit,
navController: NavHostController
) {
navigation<AuthGraph>(
startDestination = Logo
) {
loginRoute(
onError = onError,
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
CaptchaArguments(
arguments.captchaSid,
arguments.captchaImage
)
)
},
onNavigateToTwoFa = { arguments ->
navController.navigateToTwoFa(
TwoFaArguments(
validationSid = arguments.validationSid,
redirectUri = arguments.redirectUri,
phoneMask = arguments.phoneMask,
validationType = arguments.validationType,
canResendSms = arguments.canResendSms,
wrongCodeError = arguments.wrongCodeError
)
)
},
onNavigateToMain = onNavigateToMain,
onNavigateToUserBanned = { arguments ->
navController.navigateToUserBanned(
UserBannedArguments(
name = arguments.name,
message = arguments.message,
restoreUrl = arguments.restoreUrl,
accessToken = arguments.accessToken
)
)
},
onNavigateToCredentials = navController::navigateToLogin,
navController = navController
)
twoFaRoute(
onBack = navController::navigateUp,
onResult = { code ->
navController.popBackStack()
navController.setTwoFaResult(code)
}
)
captchaRoute(
onBack = navController::navigateUp,
onResult = { code ->
navController.popBackStack()
navController.setCaptchaResult(code)
}
)
userBannedRoute(onBack = navController::navigateUp)
}
}
fun NavController.navigateToAuth(clearBackStack: Boolean = false) {
val navController = this
this.navigate(AuthGraph) {
if (clearBackStack) {
popUpTo(navController.graph.id) {
inclusive = true
}
}
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth
import com.meloda.app.fast.auth.captcha.di.captchaModule
import com.meloda.app.fast.auth.twofa.di.twoFaModule
import com.meloda.fast.auth.login.di.loginModule
import org.koin.dsl.module
val authModule = module {
includes(
loginModule,
twoFaModule,
captchaModule,
)
}
+1
View File
@@ -0,0 +1 @@
/build
+60
View File
@@ -0,0 +1,60 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.meloda.app.fast.twofa"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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.twofa
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.twofa
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,212 @@
package com.meloda.app.fast.auth.twofa
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.model.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationType
import com.meloda.app.fast.auth.twofa.navigation.TwoFa
import com.meloda.app.fast.auth.twofa.validation.TwoFaValidator
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.launch
interface TwoFaViewModel {
val screenState: StateFlow<TwoFaScreenState>
fun onCodeInputChanged(newCode: String)
fun onBackButtonClicked()
fun onCancelButtonClicked()
fun onRequestSmsButtonClicked()
fun onTextFieldDoneClicked()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
fun setArguments(arguments: TwoFaArguments)
}
class TwoFaViewModelImpl(
private val validator: TwoFaValidator,
private val authUseCase: AuthUseCase,
savedStateHandle: SavedStateHandle
) : TwoFaViewModel, ViewModel() {
override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY)
private var delayJob: Job? = null
init {
// TODO: 08/07/2024, Danil Nikolaev: use when fixed
//savedStateHandle.toRoute<TwoFa>().arguments
val arguments = TwoFa.from(savedStateHandle).arguments
screenState.setValue { old ->
old.copy(
twoFaSid = arguments.validationSid,
canResendSms = arguments.canResendSms,
codeError = arguments.wrongCodeError,
twoFaText = getTwoFaText(TwoFaValidationType.parse(arguments.validationType)),
phoneMask = arguments.phoneMask
)
}
}
override fun onCodeInputChanged(newCode: String) {
screenState.updateValue(
screenState.value.copy(
twoFaCode = newCode.trim(),
codeError = null
)
)
if (newCode.length == 6) {
viewModelScope.launch {
delay(250)
onDoneButtonClicked()
}
}
}
override fun onBackButtonClicked() {
onCancelButtonClicked()
}
override fun onCancelButtonClicked() {
screenState.updateValue(
screenState.value.copy(
twoFaCode = null,
isNeedToOpenLogin = true
)
)
}
override fun onRequestSmsButtonClicked() {
sendValidationCode()
}
override fun onTextFieldDoneClicked() {
onDoneButtonClicked()
}
override fun onDoneButtonClicked() {
if (!processValidation()) return
screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true))
}
override fun onNavigatedToLogin() {
screenState.updateValue(TwoFaScreenState.EMPTY)
}
override fun setArguments(arguments: TwoFaArguments) {
Log.d("TwoFaViewModel", "TwoFaArguments: $arguments")
// screenState.updateValue(
// screenState.value.copy(
// twoFaSid = arguments.validationSid,
// canResendSms = arguments.canResendSms,
// codeError = arguments.wrongCodeError,
// twoFaText = getTwoFaText(TwoFaValidationType.parse(arguments.validationType)),
// phoneMask = arguments.phoneMask
// )
// )
}
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() {
val validationSid = screenState.value.twoFaSid
authUseCase.sendSms(validationSid)
.listenValue { state ->
state.processState(
error = { error -> },
success = { response ->
val newValidationType = response.validationType
val newCanResendSms = response.validationResend == "sms"
screenState.setValue { old ->
old.copy(
canResendSms = newCanResendSms,
twoFaText = getTwoFaText(
TwoFaValidationType.parse(newValidationType.orEmpty())
)
)
}
startTickTimer(response.delay)
}
)
if (state.isLoading()) {
screenState.emit(screenState.value.copy(canResendSms = false))
}
}
}
fun startTickTimer(delay: Int?) {
if (delay == null || delayJob?.isActive == true) return
delayJob = createTimerFlow(
time = delay,
onStartAction = {
screenState.updateValue(
screenState.value.copy(canResendSms = false)
)
},
onTickAction = { remainedTime ->
screenState.updateValue(
screenState.value.copy(delayTime = remainedTime)
)
},
onTimeoutAction = {
screenState.updateValue(
screenState.value.copy(
canResendSms = true
)
)
},
).launchIn(viewModelScope)
}
private fun getTwoFaText(validationType: TwoFaValidationType): UiText {
return when (validationType) {
TwoFaValidationType.Sms -> {
UiText.Simple("SMS with the code is sent to ${screenState.value.phoneMask}")
}
TwoFaValidationType.TwoFaApp -> {
UiText.Simple("Enter the code from the code generator application")
}
is TwoFaValidationType.Another -> UiText.Simple(validationType.type)
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.app.fast.auth.twofa.di
import com.meloda.app.fast.auth.twofa.AuthUseCase
import com.meloda.app.fast.auth.twofa.AuthUseCaseImpl
import com.meloda.app.fast.auth.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl
import com.meloda.app.fast.auth.twofa.validation.TwoFaValidator
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 twoFaModule = module {
singleOf(::TwoFaValidator)
viewModelOf(::TwoFaViewModelImpl) bind TwoFaViewModel::class
singleOf(::AuthUseCaseImpl) bind AuthUseCase::class
}
@@ -0,0 +1,16 @@
package com.meloda.app.fast.auth.twofa.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TwoFaArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
val validationType: String,
val canResendSms: Boolean,
val wrongCodeError: String?,
) : Parcelable
@@ -0,0 +1,28 @@
package com.meloda.app.fast.auth.twofa.model
import com.meloda.app.fast.common.UiText
data class TwoFaScreenState(
val twoFaSid: String,
val twoFaCode: String?,
val twoFaText: UiText,
val canResendSms: Boolean,
val codeError: String?,
val delayTime: Int,
val isNeedToOpenLogin: Boolean,
val phoneMask: String
) {
companion object {
val EMPTY = TwoFaScreenState(
twoFaSid = "",
twoFaCode = null,
twoFaText = UiText.Simple(""),
canResendSms = false,
codeError = null,
delayTime = 0,
isNeedToOpenLogin = false,
phoneMask = ""
)
}
}
@@ -0,0 +1,6 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaUiAction {
data class CodeResult(val code: String) : TwoFaUiAction()
data object BackClicked : TwoFaUiAction()
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationResult {
data object Empty : TwoFaValidationResult()
data object Valid : TwoFaValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationType(val value: String) {
data object Sms : TwoFaValidationType(TYPE_SMS)
data object TwoFaApp : TwoFaValidationType(TYPE_TWO_FA_APP)
data class Another(val type: String) : TwoFaValidationType(type)
companion object {
private const val TYPE_SMS = "sms"
private const val TYPE_TWO_FA_APP = "2fa_app"
fun parse(validationType: String): TwoFaValidationType {
return when (validationType) {
TYPE_SMS -> Sms
TYPE_TWO_FA_APP -> TwoFaApp
else -> Another(validationType)
}
}
}
}
@@ -0,0 +1,49 @@
package com.meloda.app.fast.auth.twofa.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.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.model.TwoFaUiAction
import com.meloda.app.fast.auth.twofa.presentation.TwoFaScreen
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class TwoFa(val arguments: TwoFaArguments) {
companion object {
val typeMap = mapOf(typeOf<TwoFaArguments>() to customNavType<TwoFaArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<TwoFa>(typeMap)
}
}
fun NavGraphBuilder.twoFaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<TwoFa>(typeMap = TwoFa.typeMap) {
TwoFaScreen(
onAction = { action ->
when (action) {
TwoFaUiAction.BackClicked -> onBack()
is TwoFaUiAction.CodeResult -> onResult(action.code)
}
}
)
}
}
fun NavController.navigateToTwoFa(arguments: TwoFaArguments) {
this.navigate(TwoFa(arguments))
}
fun NavController.setTwoFaResult(code: String) {
this.currentBackStackEntry
?.savedStateHandle
?.set("twofacode", code)
}
@@ -0,0 +1,255 @@
package com.meloda.app.fast.auth.twofa.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.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.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl
import com.meloda.app.fast.auth.twofa.model.TwoFaUiAction
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
private typealias OnAction = (TwoFaUiAction) -> Unit
@Composable
fun TwoFaScreen(
onAction: OnAction,
viewModel: TwoFaViewModel = koinViewModel<TwoFaViewModelImpl>(),
) {
val focusManager = LocalFocusManager.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
var showExitAlert by rememberSaveable {
mutableStateOf(false)
}
if (confirmedExit) {
onAction(TwoFaUiAction.BackClicked)
}
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)
)
}
if (screenState.isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.twoFaCode
if (code == null) {
onAction(TwoFaUiAction.BackClicked)
} else {
onAction(TwoFaUiAction.CodeResult(code = code))
}
}
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(30.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
ExtendedFloatingActionButton(
onClick = { onAction(TwoFaUiAction.BackClicked) },
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.twoFaText.getString().orEmpty(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(10.dp))
val delayRemainedTime = screenState.delayTime
AnimatedVisibility(visible = delayRemainedTime > 0) {
Text(
text = "Can resend after $delayRemainedTime seconds",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(10.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) }
val codeError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
if (newText.text.length > 6) return@TextField
code = newText
viewModel.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()
viewModel.onTextFieldDoneClicked()
}
),
isError = codeError != null
)
AnimatedVisibility(visible = codeError != null) {
TextFieldErrorText(text = codeError.orEmpty())
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val canResendSms = screenState.canResendSms
AnimatedVisibility(
visible = canResendSms,
) {
ExtendedFloatingActionButton(
onClick = viewModel::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 = viewModel::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.twofa.validation
import com.meloda.app.fast.auth.twofa.model.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationResult
class TwoFaValidator {
fun validate(screenState: TwoFaScreenState): TwoFaValidationResult {
return when {
screenState.twoFaCode.isNullOrEmpty() -> TwoFaValidationResult.Empty
else -> TwoFaValidationResult.Valid
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+60
View File
@@ -0,0 +1,60 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.com.google.devtools.ksp)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.org.jetbrains.kotlin.plugin.parcelize)
}
group = "com.meloda.app.fast.userbanned"
android {
namespace = "com.meloda.app.fast.userbanned"
compileSdk = Configs.compileSdk
defaultConfig {
minSdk = Configs.minSdk
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = Configs.java
targetCompatibility = Configs.java
}
kotlinOptions {
jvmTarget = Configs.java.toString()
freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers")
}
buildFeatures {
compose = true
}
composeOptions {
useLiveLiterals = true
}
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.nanokt.android)
implementation(libs.nanokt.jvm)
implementation(libs.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -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,14 @@
package com.meloda.app.fast.userbanned.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class UserBannedArguments(
val name: String,
val message: String,
val restoreUrl: String,
val accessToken: String
) : Parcelable
@@ -0,0 +1,53 @@
package com.meloda.app.fast.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 com.meloda.app.fast.userbanned.model.UserBannedArguments
import com.meloda.app.fast.userbanned.presentation.UserBannedScreen
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()
UserBannedScreen(
onBack = onBack,
name = arguments.name,
message = arguments.message,
)
}
}
fun NavController.navigateToUserBanned(arguments: UserBannedArguments) {
this.navigate(UserBanned(arguments))
}
@@ -0,0 +1,97 @@
package com.meloda.app.fast.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.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 com.meloda.app.fast.designsystem.AppTheme
import com.meloda.app.fast.designsystem.R as UiR
@Preview
@Composable
fun UserBannedScreenPreview() {
AppTheme {
UserBannedScreen(
onBack = {},
name = "Calvin Harris",
message = "Eto konets"
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserBannedScreen(
onBack: () -> Unit,
name: String,
message: String,
) {
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(name)
}
)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = UiR.string.blocking_reason_title))
append(": ")
}
append(message)
}
)
}
}
}