support for articles; ui & ux & logic fixes for 2fa and captcha screens; fix mentions;

This commit is contained in:
2024-07-13 01:37:24 +03:00
parent 25acc6505b
commit ce1867c22c
38 changed files with 449 additions and 218 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ dependencies {
// Coil for Compose // Coil for Compose
implementation(libs.coil.compose) implementation(libs.coil.compose)
androidTestImplementation(libs.compose.ui.test.junit4) // androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest) debugImplementation(libs.compose.ui.test.manifest)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
@@ -40,12 +40,15 @@ class OAuthRepositoryImpl(
requireNotNull(result.error) requireNotNull(result.error)
} }
else -> throw IllegalStateException("Unknown result") is ApiResult.Failure.ApiFailure -> TODO()
// is ApiResult.Failure.ApiFailure -> TODO() is ApiResult.Failure.NetworkFailure -> {
// is ApiResult.Failure.HttpFailure -> TODO() // TODO: 13/07/2024, Danil Nikolaev: implement showing network error
// is ApiResult.Failure.NetworkFailure -> TODO() TODO()
// is ApiResult.Failure.UnknownFailure -> TODO() }
is ApiResult.Failure.UnknownFailure -> TODO()
else -> throw IllegalStateException("Unknown result")
} }
} }
} }
@@ -104,6 +104,7 @@
<string name="message_attachments_audio_playlist">Playlist</string> <string name="message_attachments_audio_playlist">Playlist</string>
<string name="message_attachments_podcast">Podcast</string> <string name="message_attachments_podcast">Podcast</string>
<string name="message_attachments_narrative">Narrative</string> <string name="message_attachments_narrative">Narrative</string>
<string name="message_attachments_article">Article</string>
<string name="chat_interaction_uploading_file">Uploading file</string> <string name="chat_interaction_uploading_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</string> <string name="chat_interaction_uploading_photo">Uploading photo</string>
+3
View File
@@ -32,6 +32,9 @@ dependencies {
implementation(libs.moshi.kotlin) implementation(libs.moshi.kotlin)
ksp(libs.moshi.kotlin.codegen) ksp(libs.moshi.kotlin.codegen)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.room.ktx) implementation(libs.room.ktx)
implementation(libs.room.runtime) implementation(libs.room.runtime)
ksp(libs.room.compiler) ksp(libs.room.compiler)
@@ -1,5 +1,9 @@
package com.meloda.app.fast.model package com.meloda.app.fast.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class BaseError { sealed class BaseError {
data object SessionExpired : BaseError() data object SessionExpired : BaseError()
} }
@@ -26,7 +26,8 @@ enum class AttachmentType(var value: String) {
ARTIST("artist"), ARTIST("artist"),
AUDIO_PLAYLIST("audio_playlist"), AUDIO_PLAYLIST("audio_playlist"),
PODCAST("podcast"), PODCAST("podcast"),
NARRATIVE("narrative"); NARRATIVE("narrative"),
ARTICLE("article");
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -0,0 +1,15 @@
package com.meloda.app.fast.model.api.data
import com.meloda.app.fast.model.api.domain.VkArticleDomain
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class VkArticleData(
@Json(name = "id") val id: Int
) : VkAttachmentData {
fun toDomain(): VkArticleDomain = VkArticleDomain(
id = id
)
}
@@ -31,7 +31,8 @@ data class VkAttachmentItemData(
@Json(name = "audios") val audios: List<VkAudioData>?, @Json(name = "audios") val audios: List<VkAudioData>?,
@Json(name = "audio_playlist") val audioPlaylist: VkAudioPlaylistData?, @Json(name = "audio_playlist") val audioPlaylist: VkAudioPlaylistData?,
@Json(name = "podcast") val podcast: VkPodcastData?, @Json(name = "podcast") val podcast: VkPodcastData?,
@Json(name = "narrative") val narrative: VkNarrativeData? @Json(name = "narrative") val narrative: VkNarrativeData?,
@Json(name = "article") val article: VkArticleData?
) { ) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -58,5 +59,6 @@ data class VkAttachmentItemData(
AttachmentType.AUDIO_PLAYLIST -> audioPlaylist?.toDomain() AttachmentType.AUDIO_PLAYLIST -> audioPlaylist?.toDomain()
AttachmentType.PODCAST -> podcast?.toDomain() AttachmentType.PODCAST -> podcast?.toDomain()
AttachmentType.NARRATIVE -> narrative?.toDomain() AttachmentType.NARRATIVE -> narrative?.toDomain()
AttachmentType.ARTICLE -> article?.toDomain()
} ?: VkUnknownAttachment } ?: VkUnknownAttachment
} }
@@ -10,11 +10,11 @@ data class VkGroupData(
@Json(name = "id") val id: Int, @Json(name = "id") val id: Int,
@Json(name = "name") val name: String, @Json(name = "name") val name: String,
@Json(name = "screen_name") val screenName: String, @Json(name = "screen_name") val screenName: String,
@Json(name = "is_closed") val isClosed: Int, @Json(name = "is_closed") val isClosed: Int?,
@Json(name = "type") val type: String, @Json(name = "type") val type: String,
@Json(name = "is_admin") val isAdmin: Int, @Json(name = "is_admin") val isAdmin: Int?,
@Json(name = "is_member") val isMember: Int, @Json(name = "is_member") val isMember: Int?,
@Json(name = "is_advertiser") val isAdvertiser: Int, @Json(name = "is_advertiser") val isAdvertiser: Int?,
@Json(name = "photo_50") val photo50: String?, @Json(name = "photo_50") val photo50: String?,
@Json(name = "photo_100") val photo100: String?, @Json(name = "photo_100") val photo100: String?,
@Json(name = "photo_200") val photo200: String?, @Json(name = "photo_200") val photo200: String?,
@@ -0,0 +1,10 @@
package com.meloda.app.fast.model.api.domain
import com.meloda.app.fast.model.api.data.AttachmentType
data class VkArticleDomain(
val id: Int
) : VkAttachment {
override val type: AttachmentType = AttachmentType.ARTICLE
}
@@ -75,7 +75,7 @@ data class InvalidCredentialsError(
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class WrongTwoFaCode( data class WrongTwoFaCodeError(
@Json(name = "error") override val error: String, // "invalid_request" @Json(name = "error") override val error: String, // "invalid_request"
@Json(name = "error_description") override val errorDescription: String, @Json(name = "error_description") override val errorDescription: String,
@Json(name = "error_type") override val errorType: String // "wrong_otp" @Json(name = "error_type") override val errorType: String // "wrong_otp"
@@ -86,7 +86,7 @@ data class WrongTwoFaCode(
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class WrongTwoFaCodeFormat( data class WrongTwoFaCodeFormatError(
@Json(name = "error") override val error: String, // "invalid_request" @Json(name = "error") override val error: String, // "invalid_request"
@Json(name = "error_description") override val errorDescription: String, @Json(name = "error_description") override val errorDescription: String,
@Json(name = "error_type") override val errorType: String // "otp_format_is_incorrect" @Json(name = "error_type") override val errorType: String // "otp_format_is_incorrect"
@@ -96,6 +96,17 @@ data class WrongTwoFaCodeFormat(
errorType = errorType errorType = errorType
) )
@JsonClass(generateAdapter = true)
data class TooManyTriesError(
@Json(name = "error") override val error: String, // "9;Flood control"
@Json(name = "error_description") override val errorDescription: String,
@Json(name = "error_type") override val errorType: String // "password_bruteforce_attempt"
) : OAuthError(
error = error,
errorDescription = errorDescription,
errorType = errorType
)
fun OAuthError.toDomain(): OAuthErrorDomain? = when (this) { fun OAuthError.toDomain(): OAuthErrorDomain? = when (this) {
is ValidationRequiredError -> { is ValidationRequiredError -> {
OAuthErrorDomain.ValidationRequiredError( OAuthErrorDomain.ValidationRequiredError(
@@ -129,13 +140,17 @@ fun OAuthError.toDomain(): OAuthErrorDomain? = when (this) {
OAuthErrorDomain.InvalidCredentialsError OAuthErrorDomain.InvalidCredentialsError
} }
is WrongTwoFaCode -> { is WrongTwoFaCodeError -> {
OAuthErrorDomain.WrongTwoFaCode OAuthErrorDomain.WrongTwoFaCode
} }
is WrongTwoFaCodeFormat -> { is WrongTwoFaCodeFormatError -> {
OAuthErrorDomain.WrongTwoFaCodeFormat OAuthErrorDomain.WrongTwoFaCodeFormat
} }
is TooManyTriesError -> {
OAuthErrorDomain.TooManyTriesError
}
else -> null else -> null
} }
@@ -27,5 +27,7 @@ sealed class OAuthErrorDomain {
data object InvalidCredentialsError : OAuthErrorDomain() data object InvalidCredentialsError : OAuthErrorDomain()
data object WrongTwoFaCode : OAuthErrorDomain() data object WrongTwoFaCode : OAuthErrorDomain()
data object WrongTwoFaCodeFormat : OAuthErrorDomain() data object WrongTwoFaCodeFormat : OAuthErrorDomain()
data object TooManyTriesError: OAuthErrorDomain()
data object UnknownError : OAuthErrorDomain() data object UnknownError : OAuthErrorDomain()
} }
@@ -110,6 +110,11 @@ internal class ResultCall<R : Any, E : OAuthError>(
.fromJson(errorBodyString.orEmpty()) ?: return .fromJson(errorBodyString.orEmpty()) ?: return
val error: OAuthError? = when (baseError.error) { val error: OAuthError? = when (baseError.error) {
"9;Flood control" -> {
moshi.adapter(TooManyTriesError::class.java)
.fromJson(errorBodyString.orEmpty())
}
"invalid_client" -> { "invalid_client" -> {
moshi.adapter(InvalidCredentialsError::class.java) moshi.adapter(InvalidCredentialsError::class.java)
.fromJson(errorBodyString.orEmpty()) .fromJson(errorBodyString.orEmpty())
@@ -123,12 +128,12 @@ internal class ResultCall<R : Any, E : OAuthError>(
"invalid_request" -> { "invalid_request" -> {
when (val type = baseError.errorType) { when (val type = baseError.errorType) {
"wrong_otp" -> { "wrong_otp" -> {
moshi.adapter(WrongTwoFaCode::class.java) moshi.adapter(WrongTwoFaCodeError::class.java)
.fromJson(errorBodyString.orEmpty()) .fromJson(errorBodyString.orEmpty())
} }
"otp_format_is_incorrect" -> { "otp_format_is_incorrect" -> {
moshi.adapter(WrongTwoFaCodeFormat::class.java) moshi.adapter(WrongTwoFaCodeFormatError::class.java)
.fromJson(errorBodyString.orEmpty()) .fromJson(errorBodyString.orEmpty())
} }
@@ -51,10 +51,12 @@ object VkOAuthErrors {
const val NEED_CAPTCHA = "need_captcha" const val NEED_CAPTCHA = "need_captcha"
const val INVALID_CLIENT = "invalid_client" const val INVALID_CLIENT = "invalid_client"
const val INVALID_REQUEST = "invalid_request" const val INVALID_REQUEST = "invalid_request"
const val FLOOD_CONTROL = "9;Flood control"
} }
object VkErrorTypes { object VkErrorTypes {
const val WRONG_OTP_FORMAT = "otp_format_is_incorrect" const val WRONG_OTP_FORMAT = "otp_format_is_incorrect"
const val WRONG_OTP = "wrong_otp" const val WRONG_OTP = "wrong_otp"
const val PASSWORD_BRUTEFORCE_ATTEMPT = "password_bruteforce_attempt"
} }
@@ -2,7 +2,6 @@ package com.meloda.app.fast.auth.captcha
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel 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.model.CaptchaScreenState
import com.meloda.app.fast.auth.captcha.navigation.Captcha import com.meloda.app.fast.auth.captcha.navigation.Captcha
import com.meloda.app.fast.auth.captcha.validation.CaptchaValidator import com.meloda.app.fast.auth.captcha.validation.CaptchaValidator
@@ -13,16 +12,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
interface CaptchaViewModel { interface CaptchaViewModel {
val screenState: StateFlow<CaptchaScreenState> val screenState: StateFlow<CaptchaScreenState>
val isNeedToOpenLogin: StateFlow<Boolean>
fun onCodeInputChanged(newCode: String) fun onCodeInputChanged(newCode: String)
fun onTextFieldDoneClicked() fun onTextFieldDoneClicked()
fun onDoneButtonClicked() fun onDoneButtonClicked()
fun setArguments(arguments: CaptchaArguments)
fun onNavigatedToLogin() fun onNavigatedToLogin()
} }
@@ -32,6 +29,7 @@ class CaptchaViewModelImpl(
) : CaptchaViewModel, ViewModel() { ) : CaptchaViewModel, ViewModel() {
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY) override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
init { init {
val arguments = Captcha.from(savedStateHandle).arguments val arguments = Captcha.from(savedStateHandle).arguments
@@ -57,20 +55,12 @@ class CaptchaViewModelImpl(
override fun onDoneButtonClicked() { override fun onDoneButtonClicked() {
if (!processValidation()) return if (!processValidation()) return
screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true)) isNeedToOpenLogin.update { true }
}
override fun setArguments(arguments: CaptchaArguments) {
// screenState.updateValue(
// screenState.value.copy(
// captchaSid = arguments.captchaSid,
// captchaImage = arguments.captchaImage
// )
// )
} }
override fun onNavigatedToLogin() { override fun onNavigatedToLogin() {
screenState.updateValue(CaptchaScreenState.EMPTY) screenState.updateValue(CaptchaScreenState.EMPTY)
isNeedToOpenLogin.update { false }
} }
private fun processValidation(): Boolean { private fun processValidation(): Boolean {
@@ -4,8 +4,7 @@ data class CaptchaScreenState(
val captchaSid: String, val captchaSid: String,
val captchaImage: String, val captchaImage: String,
val captchaCode: String, val captchaCode: String,
val codeError: Boolean, val codeError: Boolean
val isNeedToOpenLogin: Boolean
) { ) {
companion object { companion object {
@@ -13,8 +12,7 @@ data class CaptchaScreenState(
captchaSid = "", captchaSid = "",
captchaImage = "", captchaImage = "",
captchaCode = "", captchaCode = "",
codeError = false, codeError = false
isNeedToOpenLogin = false
) )
} }
} }
@@ -41,7 +41,7 @@ fun NavController.navigateToCaptcha(arguments: CaptchaArguments) {
this.navigate(Captcha(arguments)) this.navigate(Captcha(arguments))
} }
fun NavController.setCaptchaResult(code: String) { fun NavController.setCaptchaResult(code: String?) {
this.currentBackStackEntry this.currentBackStackEntry
?.savedStateHandle ?.savedStateHandle
?.set("captchacode", code) ?.set("captchacode", code)
@@ -27,6 +27,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -61,6 +62,7 @@ fun CaptchaScreen(
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>() viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable { var confirmedExit by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@@ -70,9 +72,11 @@ fun CaptchaScreen(
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(confirmedExit) {
if (confirmedExit) { if (confirmedExit) {
onBack() onBack()
} }
}
BackHandler(enabled = !confirmedExit) { BackHandler(enabled = !confirmedExit) {
if (!confirmedExit) { if (!confirmedExit) {
@@ -93,10 +97,12 @@ fun CaptchaScreen(
) )
} }
if (screenState.isNeedToOpenLogin) { LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin() viewModel.onNavigatedToLogin()
onResult(screenState.captchaCode) onResult(screenState.captchaCode)
} }
}
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
+2
View File
@@ -89,4 +89,6 @@ dependencies {
implementation(libs.eithernet) implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization) implementation(libs.kotlin.serialization)
implementation(libs.rebugger)
} }
@@ -15,9 +15,11 @@ import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.data.processState import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.database.AccountEntity import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.network.OAuthErrorDomain import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.fast.auth.login.model.CaptchaArguments import com.meloda.fast.auth.login.model.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginScreenState import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginTwoFaArguments import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.meloda.fast.auth.login.model.LoginValidationResult import com.meloda.fast.auth.login.model.LoginValidationResult
import com.meloda.fast.auth.login.validation.LoginValidator import com.meloda.fast.auth.login.validation.LoginValidator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -27,10 +29,19 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
interface LoginViewModel { interface LoginViewModel {
val screenState: StateFlow<LoginScreenState> val screenState: StateFlow<LoginScreenState>
val loginError: StateFlow<LoginError?>
val twoFaCode: StateFlow<String?>
val twoFaArguments: StateFlow<LoginTwoFaArguments?>
val captchaCode: StateFlow<String?>
val captchaArguments: StateFlow<LoginCaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
fun onPasswordVisibilityButtonClicked() fun onPasswordVisibilityButtonClicked()
@@ -56,10 +67,18 @@ class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase, private val oAuthUseCase: OAuthUseCase,
private val usersUseCase: UsersUseCase, private val usersUseCase: UsersUseCase,
private val accountsRepository: AccountsRepository, private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator, private val loginValidator: LoginValidator
) : ViewModel(), LoginViewModel { ) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY) override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
override val loginError = MutableStateFlow<LoginError?>(null)
override val twoFaCode = MutableStateFlow<String?>(null)
override val twoFaArguments = MutableStateFlow<LoginTwoFaArguments?>(null)
override val captchaCode = MutableStateFlow<String?>(null)
override val captchaArguments = MutableStateFlow<LoginCaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
private val validationState: StateFlow<List<LoginValidationResult>> = private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate) screenState.map(loginValidator::validate)
@@ -86,37 +105,38 @@ class LoginViewModelImpl(
} }
override fun onSignInButtonClicked() { override fun onSignInButtonClicked() {
if (screenState.value.isLoading) return
login() login()
} }
override fun onErrorDialogDismissed() { override fun onErrorDialogDismissed() {
screenState.setValue { old -> old.copy(error = null) } loginError.update { null }
} }
override fun onNavigatedToMain() { override fun onNavigatedToMain() {
screenState.setValue { old -> old.copy(isNeedToNavigateToMain = false) } isNeedToOpenMain.update { false }
} }
override fun onNavigatedToUserBanned() { override fun onNavigatedToUserBanned() {
screenState.setValue { old -> old.copy(userBannedArguments = null) } userBannedArguments.update { null }
} }
override fun onNavigatedToCaptcha() { override fun onNavigatedToCaptcha() {
screenState.setValue { old -> old.copy(captchaArguments = null) } captchaArguments.update { null }
} }
override fun onNavigatedToTwoFa() { override fun onNavigatedToTwoFa() {
screenState.setValue { old -> old.copy(twoFaArguments = null) } twoFaArguments.update { null }
} }
override fun onTwoFaCodeReceived(code: String) { override fun onTwoFaCodeReceived(code: String) {
screenState.setValue { old -> old.copy(validationCode = code) } twoFaCode.update { code }
login() login()
} }
override fun onCaptchaCodeReceived(code: String) { override fun onCaptchaCodeReceived(code: String) {
screenState.setValue { old -> old.copy(captchaCode = code) } captchaCode.update { code }
login() login()
} }
@@ -149,7 +169,8 @@ class LoginViewModelImpl(
accountsRepository.storeAccounts(listOf(currentAccount)) accountsRepository.storeAccounts(listOf(currentAccount))
delay(350) delay(350)
screenState.setValue { old -> old.copy(isNeedToNavigateToMain = true) }
isNeedToOpenMain.update { true }
} }
} }
) )
@@ -163,7 +184,10 @@ class LoginViewModelImpl(
Log.d( Log.d(
"LoginViewModel", "LoginViewModel",
"auth: login: ${currentState.login}; password: ${currentState.password}; code: ${currentState.validationCode}" "auth: login: ${currentState.login}; " +
"password: ${currentState.password}; " +
"2fa code: ${twoFaCode.value}; " +
"captcha code: ${captchaCode.value}"
) )
processValidation() processValidation()
@@ -173,14 +197,17 @@ class LoginViewModelImpl(
login = currentState.login, login = currentState.login,
password = currentState.password, password = currentState.password,
forceSms = forceSms, forceSms = forceSms,
twoFaCode = currentState.validationCode, twoFaCode = twoFaCode.value,
captchaSid = currentState.captchaArguments?.captchaSid, captchaSid = captchaArguments.value?.captchaSid,
captchaKey = currentState.captchaCode captchaKey = captchaCode.value
).listenValue { state -> ).listenValue { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error") Log.d("LoginViewModelImpl", "login: error: $error")
twoFaCode.update { null }
captchaCode.update { null }
parseError(error) parseError(error)
}, },
success = { response -> success = { response ->
@@ -213,20 +240,20 @@ class LoginViewModelImpl(
accountsRepository.storeAccounts(listOf(currentAccount)) accountsRepository.storeAccounts(listOf(currentAccount))
captchaArguments.update { null }
captchaCode.update { null }
twoFaArguments.update { null }
twoFaCode.update { null }
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
captchaArguments = null,
captchaCode = null,
validationSid = null,
validationCode = null,
twoFaArguments = null,
login = "", login = "",
password = "", password = "",
isNeedToNavigateToMain = true
) )
} }
isNeedToOpenMain.update { true }
} }
) )
screenState.emit(screenState.value.copy(isLoading = state.isLoading())) screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
@@ -238,7 +265,7 @@ class LoginViewModelImpl(
is State.Error.OAuthError -> { is State.Error.OAuthError -> {
when (val error = stateError.error) { when (val error = stateError.error) {
is OAuthErrorDomain.ValidationRequiredError -> { is OAuthErrorDomain.ValidationRequiredError -> {
val twoFaArguments = LoginTwoFaArguments( val arguments = LoginTwoFaArguments(
validationSid = error.validationSid, validationSid = error.validationSid,
redirectUri = error.redirectUri, redirectUri = error.redirectUri,
phoneMask = error.phoneMask, phoneMask = error.phoneMask,
@@ -246,25 +273,49 @@ class LoginViewModelImpl(
canResendSms = error.validationResend == "sms", canResendSms = error.validationResend == "sms",
wrongCodeError = null wrongCodeError = null
) )
screenState.setValue { old -> old.copy(twoFaArguments = twoFaArguments) } twoFaArguments.update { arguments }
true
} }
is OAuthErrorDomain.CaptchaRequiredError -> { is OAuthErrorDomain.CaptchaRequiredError -> {
val captchaArguments = CaptchaArguments( val arguments = LoginCaptchaArguments(
captchaSid = error.captchaSid, captchaSid = error.captchaSid,
captchaImage = error.captchaImageUrl captchaImage = error.captchaImageUrl
) )
screenState.setValue { old -> old.copy(captchaArguments = captchaArguments) } captchaArguments.update { arguments }
true
} }
OAuthErrorDomain.InvalidCredentialsError -> TODO() OAuthErrorDomain.InvalidCredentialsError -> {
is OAuthErrorDomain.UserBannedError -> TODO() loginError.update { LoginError.WrongCredentials }
OAuthErrorDomain.WrongTwoFaCode -> TODO()
OAuthErrorDomain.WrongTwoFaCodeFormat -> TODO()
OAuthErrorDomain.UnknownError -> TODO()
} }
is OAuthErrorDomain.UserBannedError -> {
val arguments = LoginUserBannedArguments(
name = error.memberName,
message = error.message,
restoreUrl = error.restoreUrl,
accessToken = error.accessToken
)
userBannedArguments.update { arguments }
}
OAuthErrorDomain.WrongTwoFaCode -> {
loginError.update { LoginError.WrongTwoFaCode }
}
OAuthErrorDomain.WrongTwoFaCodeFormat -> {
loginError.update { LoginError.WrongTwoFaCodeFormat }
}
OAuthErrorDomain.TooManyTriesError -> {
loginError.update { LoginError.TooManyTries }
}
OAuthErrorDomain.UnknownError -> {
loginError.update { LoginError.Unknown }
}
}
true
} }
else -> false else -> false
@@ -44,6 +44,10 @@ class OAuthUseCaseImpl(
) )
} }
VkOAuthErrors.FLOOD_CONTROL -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthErrors.NEED_VALIDATION -> { VkOAuthErrors.NEED_VALIDATION -> {
if (response.banInfo != null) { if (response.banInfo != null) {
val info = requireNotNull(response.banInfo) val info = requireNotNull(response.banInfo)
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
@Parcelize @Parcelize
data class CaptchaArguments( data class LoginCaptchaArguments(
val captchaSid: String, val captchaSid: String,
val captchaImage: String val captchaImage: String
) : Parcelable ) : Parcelable
@@ -1,6 +1,12 @@
package com.meloda.fast.auth.login.model package com.meloda.fast.auth.login.model
sealed interface LoginError { import androidx.compose.runtime.Immutable
data object WrongCredentials : LoginError @Immutable
sealed class LoginError {
data object Unknown : LoginError()
data object WrongCredentials : LoginError()
data object TooManyTries : LoginError()
data object WrongTwoFaCode : LoginError()
data object WrongTwoFaCodeFormat : LoginError()
} }
@@ -2,43 +2,24 @@ package com.meloda.fast.auth.login.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
// TODO: 04/05/2024, Danil Nikolaev: simplify
@Immutable @Immutable
data class LoginScreenState( data class LoginScreenState(
val login: String, val login: String,
val password: String, val password: String,
val captchaCode: String?,
val validationSid: String?,
val validationCode: String?,
val isLoading: Boolean, val isLoading: Boolean,
val loginError: Boolean, val loginError: Boolean,
val passwordError: Boolean, val passwordError: Boolean,
val passwordVisible: Boolean, val passwordVisible: Boolean,
val copiedCode: String?,
val isNeedToNavigateToMain: Boolean,
val twoFaArguments: LoginTwoFaArguments?,
val captchaArguments: CaptchaArguments?,
val userBannedArguments: UserBannedArguments?,
val error: LoginError?,
) { ) {
companion object { companion object {
val EMPTY = LoginScreenState( val EMPTY = LoginScreenState(
login = "", login = "",
password = "", password = "",
captchaCode = null,
validationSid = null,
validationCode = null,
isLoading = false, isLoading = false,
loginError = false, loginError = false,
passwordError = false, passwordError = false,
passwordVisible = false, passwordVisible = false
copiedCode = null,
isNeedToNavigateToMain = false,
twoFaArguments = null,
captchaArguments = null,
userBannedArguments = null,
error = null,
) )
} }
} }
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
@Parcelize @Parcelize
data class UserBannedArguments( data class LoginUserBannedArguments(
val name: String, val name: String,
val message: String, val message: String,
val restoreUrl: String, val restoreUrl: String,
@@ -1,5 +1,6 @@
package com.meloda.fast.auth.login.navigation package com.meloda.fast.auth.login.navigation
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -7,9 +8,9 @@ import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.model.BaseError import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.CaptchaArguments import com.meloda.fast.auth.login.model.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.LoginTwoFaArguments import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.UserBannedArguments import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.meloda.fast.auth.login.presentation.LoginScreen import com.meloda.fast.auth.login.presentation.LoginScreen
import com.meloda.fast.auth.login.presentation.LogoScreen import com.meloda.fast.auth.login.presentation.LogoScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -22,16 +23,19 @@ object Logo
fun NavGraphBuilder.loginRoute( fun NavGraphBuilder.loginRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit, onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (UserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit, onNavigateToCredentials: () -> Unit,
navController: NavController navController: NavController
) { ) {
composable<Login> { composable<Login> { backStackEntry ->
val viewModel: LoginViewModel = val viewModel: LoginViewModel =
it.sharedViewModel<LoginViewModelImpl>(navController = navController) backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
val twoFaCode = backStackEntry.getTwoFaResult()
val captchaCode = backStackEntry.getCaptchaResult()
LoginScreen( LoginScreen(
onError = onError, onError = onError,
@@ -39,6 +43,8 @@ fun NavGraphBuilder.loginRoute(
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha, onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToTwoFa = onNavigateToTwoFa, onNavigateToTwoFa = onNavigateToTwoFa,
twoFaCode = twoFaCode,
captchaCode = captchaCode,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -54,3 +60,11 @@ fun NavGraphBuilder.loginRoute(
fun NavController.navigateToLogin() { fun NavController.navigateToLogin() {
this.navigate(route = Login) this.navigate(route = Login)
} }
fun NavBackStackEntry.getTwoFaResult(): String? {
return savedStateHandle["twofacode"]
}
fun NavBackStackEntry.getCaptchaResult(): String? {
return savedStateHandle["captchacode"]
}
@@ -23,6 +23,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -57,10 +58,11 @@ import com.meloda.app.fast.designsystem.handleTabKey
import com.meloda.app.fast.model.BaseError import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.CaptchaArguments import com.meloda.fast.auth.login.model.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.LoginError import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginTwoFaArguments import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.UserBannedArguments import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.theapache64.rebugger.Rebugger
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR import com.meloda.app.fast.designsystem.R as UiR
@@ -68,49 +70,64 @@ import com.meloda.app.fast.designsystem.R as UiR
@Composable @Composable
fun LoginScreen( fun LoginScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onNavigateToUserBanned: (UserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit, onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
twoFaCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>() viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val twoFaArguments by viewModel.twoFaArguments.collectAsStateWithLifecycle()
val loginError by viewModel.loginError.collectAsStateWithLifecycle()
if (screenState.isNeedToNavigateToMain) { LaunchedEffect(isNeedToOpenMain) {
if (isNeedToOpenMain) {
viewModel.onNavigatedToMain() viewModel.onNavigatedToMain()
onNavigateToMain() onNavigateToMain()
} }
}
screenState.userBannedArguments?.let { arguments -> LaunchedEffect(userBannedArguments) {
userBannedArguments?.let { arguments ->
viewModel.onNavigatedToUserBanned() viewModel.onNavigatedToUserBanned()
onNavigateToUserBanned(arguments) onNavigateToUserBanned(arguments)
} }
}
screenState.captchaArguments?.let { arguments -> LaunchedEffect(captchaArguments) {
captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha() viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments) onNavigateToCaptcha(arguments)
} }
}
screenState.twoFaArguments?.let { arguments -> LaunchedEffect(twoFaArguments) {
twoFaArguments?.let { arguments ->
viewModel.onNavigatedToTwoFa() viewModel.onNavigatedToTwoFa()
onNavigateToTwoFa(arguments) onNavigateToTwoFa(arguments)
} }
}
LaunchedEffect(twoFaCode) {
if (twoFaCode != null) {
viewModel.onTwoFaCodeReceived(twoFaCode)
}
}
LaunchedEffect(captchaCode) {
if (captchaCode != null) {
viewModel.onCaptchaCodeReceived(captchaCode)
}
}
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs() 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)) } var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
val showLoginError = screenState.loginError val showLoginError = screenState.loginError
@@ -165,8 +182,14 @@ fun LoginScreen(
.height(58.dp) .height(58.dp)
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.handleEnterKey(loginFieldTabClick::invoke) .handleEnterKey {
.handleTabKey(loginFieldTabClick::invoke) passwordFocusable.requestFocus()
true
}
.handleTabKey {
passwordFocusable.requestFocus()
true
}
.focusRequester(loginFocusable) .focusRequester(loginFocusable)
.connectNode(handler = autoFillEmailHandler) .connectNode(handler = autoFillEmailHandler)
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler), .defaultFocusChangeAutoFill(handler = autoFillEmailHandler),
@@ -213,7 +236,8 @@ fun LoginScreen(
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.handleEnterKey { .handleEnterKey {
goButtonClickAction.invoke() focusManager.clearFocus()
viewModel.onSignInButtonClicked()
true true
} }
.focusRequester(passwordFocusable) .focusRequester(passwordFocusable)
@@ -261,7 +285,10 @@ fun LoginScreen(
keyboardType = KeyboardType.Password keyboardType = KeyboardType.Password
), ),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onGo = { goButtonClickAction.invoke() } onGo = {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
}
), ),
isError = showPasswordError, isError = showPasswordError,
visualTransformation = if (screenState.passwordVisible) { visualTransformation = if (screenState.passwordVisible) {
@@ -282,7 +309,10 @@ fun LoginScreen(
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = goButtonClickAction::invoke, onClick = {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
},
containerColor = MaterialTheme.colorScheme.secondaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("Sign in button") modifier = Modifier.testTag("Sign in button")
) { ) {
@@ -306,7 +336,31 @@ fun LoginScreen(
HandleError( HandleError(
onDismiss = viewModel::onErrorDialogDismissed, onDismiss = viewModel::onErrorDialogDismissed,
error = screenState.error error = loginError
)
Rebugger(
trackMap = mapOf(
"onError" to onError,
"onNavigateToUserBanned" to onNavigateToUserBanned,
"onNavigateToMain" to onNavigateToMain,
"onNavigateToCaptcha" to onNavigateToCaptcha,
"onNavigateToTwoFa" to onNavigateToTwoFa,
"viewModel" to viewModel,
"screenState" to screenState,
"isNeedToOpenMain" to isNeedToOpenMain,
"userBannedArguments" to userBannedArguments,
"captchaArguments" to captchaArguments,
"twoFaArguments" to twoFaArguments,
"loginError" to loginError,
"focusManager" to focusManager,
"loginText" to loginText,
"showLoginError" to showLoginError,
"autoFillEmailHandler" to autoFillEmailHandler,
"passwordText" to passwordText,
"showPasswordError" to showPasswordError,
"autoFillPasswordHandler" to autoFillPasswordHandler,
),
) )
} }
@@ -316,15 +370,52 @@ fun HandleError(
error: LoginError?, error: LoginError?,
) { ) {
when (error) { when (error) {
LoginError.WrongCredentials -> { null -> Unit
LoginError.Unknown -> {
MaterialDialog( MaterialDialog(
onDismissAction = onDismiss, onDismissAction = onDismiss,
title = UiText.Simple("Error"), title = UiText.Simple("Error"),
text = UiText.Simple("Wrong login or password"), text = UiText.Simple("Unknown error."),
confirmText = UiText.Resource(UiR.string.ok) confirmText = UiText.Resource(UiR.string.ok)
) )
} }
null -> Unit LoginError.WrongCredentials -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
text = UiText.Simple("Wrong login or password."),
confirmText = UiText.Resource(UiR.string.ok)
)
}
LoginError.TooManyTries -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
text = UiText.Simple("Too many tries. Try in another hour or later."),
confirmText = UiText.Resource(UiR.string.ok)
)
}
LoginError.WrongTwoFaCode -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
text = UiText.Simple("Wrong validation code."),
confirmText = UiText.Resource(UiR.string.ok)
)
}
LoginError.WrongTwoFaCodeFormat -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
text = UiText.Simple("Wrong validation code format."),
confirmText = UiText.Resource(UiR.string.ok)
)
}
} }
} }
@@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -40,12 +41,14 @@ fun LogoScreen(
onShowCredentials: () -> Unit, onShowCredentials: () -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>() viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
if (screenState.isNeedToNavigateToMain) { LaunchedEffect(isNeedToOpenMain) {
if (isNeedToOpenMain) {
viewModel.onNavigatedToMain() viewModel.onNavigatedToMain()
onNavigateToMain() onNavigateToMain()
} }
}
Scaffold { padding -> Scaffold { padding ->
val topPadding by animateDpAsState(targetValue = padding.calculateTopPadding()) val topPadding by animateDpAsState(targetValue = padding.calculateTopPadding())
@@ -2,7 +2,6 @@ package com.meloda.app.fast.auth
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.navigation import androidx.navigation.navigation
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments 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.captchaRoute
@@ -20,6 +19,7 @@ import com.meloda.fast.auth.login.navigation.Logo
import com.meloda.fast.auth.login.navigation.loginRoute import com.meloda.fast.auth.login.navigation.loginRoute
import com.meloda.fast.auth.login.navigation.navigateToLogin import com.meloda.fast.auth.login.navigation.navigateToLogin
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.net.URLEncoder
@Serializable @Serializable
object AuthGraph object AuthGraph
@@ -27,7 +27,7 @@ object AuthGraph
fun NavGraphBuilder.authNavGraph( fun NavGraphBuilder.authNavGraph(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
navController: NavHostController navController: NavController
) { ) {
navigation<AuthGraph>( navigation<AuthGraph>(
startDestination = Logo startDestination = Logo
@@ -46,7 +46,7 @@ fun NavGraphBuilder.authNavGraph(
navController.navigateToTwoFa( navController.navigateToTwoFa(
TwoFaArguments( TwoFaArguments(
validationSid = arguments.validationSid, validationSid = arguments.validationSid,
redirectUri = arguments.redirectUri, redirectUri = URLEncoder.encode(arguments.redirectUri, "utf-8"),
phoneMask = arguments.phoneMask, phoneMask = arguments.phoneMask,
validationType = arguments.validationType, validationType = arguments.validationType,
canResendSms = arguments.canResendSms, canResendSms = arguments.canResendSms,
@@ -70,7 +70,10 @@ fun NavGraphBuilder.authNavGraph(
) )
twoFaRoute( twoFaRoute(
onBack = navController::navigateUp, onBack = {
navController.navigateUp()
navController.setTwoFaResult(null)
},
onResult = { code -> onResult = { code ->
navController.popBackStack() navController.popBackStack()
navController.setTwoFaResult(code) navController.setTwoFaResult(code)
@@ -78,7 +81,10 @@ fun NavGraphBuilder.authNavGraph(
) )
captchaRoute( captchaRoute(
onBack = navController::navigateUp, onBack = {
navController.navigateUp()
navController.setCaptchaResult(null)
},
onResult = { code -> onResult = { code ->
navController.popBackStack() navController.popBackStack()
navController.setCaptchaResult(code) navController.setCaptchaResult(code)
@@ -1,10 +1,8 @@
package com.meloda.app.fast.auth.twofa package com.meloda.app.fast.auth.twofa
import android.util.Log
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationType 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.navigation.TwoFa
@@ -20,6 +18,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,6 +26,8 @@ interface TwoFaViewModel {
val screenState: StateFlow<TwoFaScreenState> val screenState: StateFlow<TwoFaScreenState>
val isNeedToOpenLogin: StateFlow<Boolean>
fun onCodeInputChanged(newCode: String) fun onCodeInputChanged(newCode: String)
fun onBackButtonClicked() fun onBackButtonClicked()
@@ -36,8 +37,6 @@ interface TwoFaViewModel {
fun onDoneButtonClicked() fun onDoneButtonClicked()
fun onNavigatedToLogin() fun onNavigatedToLogin()
fun setArguments(arguments: TwoFaArguments)
} }
class TwoFaViewModelImpl( class TwoFaViewModelImpl(
@@ -48,6 +47,8 @@ class TwoFaViewModelImpl(
override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY) override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
private var delayJob: Job? = null private var delayJob: Job? = null
init { init {
@@ -88,12 +89,8 @@ class TwoFaViewModelImpl(
} }
override fun onCancelButtonClicked() { override fun onCancelButtonClicked() {
screenState.updateValue( screenState.setValue { old -> old.copy(twoFaCode = null) }
screenState.value.copy( isNeedToOpenLogin.update { true }
twoFaCode = null,
isNeedToOpenLogin = true
)
)
} }
override fun onRequestSmsButtonClicked() { override fun onRequestSmsButtonClicked() {
@@ -107,25 +104,12 @@ class TwoFaViewModelImpl(
override fun onDoneButtonClicked() { override fun onDoneButtonClicked() {
if (!processValidation()) return if (!processValidation()) return
screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true)) isNeedToOpenLogin.update { true }
} }
override fun onNavigatedToLogin() { override fun onNavigatedToLogin() {
screenState.updateValue(TwoFaScreenState.EMPTY) screenState.updateValue(TwoFaScreenState.EMPTY)
} isNeedToOpenLogin.update { false }
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 { private fun processValidation(): Boolean {
@@ -147,7 +131,9 @@ class TwoFaViewModelImpl(
authUseCase.sendSms(validationSid) authUseCase.sendSms(validationSid)
.listenValue { state -> .listenValue { state ->
state.processState( state.processState(
error = { error -> }, error = { error ->
},
success = { response -> success = { response ->
val newValidationType = response.validationType val newValidationType = response.validationType
val newCanResendSms = response.validationResend == "sms" val newCanResendSms = response.validationResend == "sms"
@@ -9,7 +9,6 @@ data class TwoFaScreenState(
val canResendSms: Boolean, val canResendSms: Boolean,
val codeError: String?, val codeError: String?,
val delayTime: Int, val delayTime: Int,
val isNeedToOpenLogin: Boolean,
val phoneMask: String val phoneMask: String
) { ) {
@@ -21,7 +20,6 @@ data class TwoFaScreenState(
canResendSms = false, canResendSms = false,
codeError = null, codeError = null,
delayTime = 0, delayTime = 0,
isNeedToOpenLogin = false,
phoneMask = "" phoneMask = ""
) )
} }
@@ -1,6 +0,0 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaUiAction {
data class CodeResult(val code: String) : TwoFaUiAction()
data object BackClicked : TwoFaUiAction()
}
@@ -6,7 +6,6 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments 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.auth.twofa.presentation.TwoFaScreen
import com.meloda.app.fast.common.customNavType import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -28,12 +27,8 @@ fun NavGraphBuilder.twoFaRoute(
) { ) {
composable<TwoFa>(typeMap = TwoFa.typeMap) { composable<TwoFa>(typeMap = TwoFa.typeMap) {
TwoFaScreen( TwoFaScreen(
onAction = { action -> onBack = onBack,
when (action) { onCodeResult = onResult
TwoFaUiAction.BackClicked -> onBack()
is TwoFaUiAction.CodeResult -> onResult(action.code)
}
}
) )
} }
} }
@@ -42,8 +37,10 @@ fun NavController.navigateToTwoFa(arguments: TwoFaArguments) {
this.navigate(TwoFa(arguments)) this.navigate(TwoFa(arguments))
} }
fun NavController.setTwoFaResult(code: String) { fun NavController.setTwoFaResult(code: String?) {
this.currentBackStackEntry this.currentBackStackEntry
?.savedStateHandle ?.savedStateHandle
?.set("twofacode", code) ?.set("twofacode", code)
} }
@@ -26,6 +26,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -43,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.auth.twofa.TwoFaViewModel import com.meloda.app.fast.auth.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl 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.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText import com.meloda.app.fast.designsystem.TextFieldErrorText
@@ -51,17 +51,18 @@ import com.meloda.app.fast.designsystem.getString
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR import com.meloda.app.fast.designsystem.R as UiR
private typealias OnAction = (TwoFaUiAction) -> Unit
@Composable @Composable
fun TwoFaScreen( fun TwoFaScreen(
onAction: OnAction, onBack: () -> Unit,
onCodeResult: (code: String) -> Unit,
viewModel: TwoFaViewModel = koinViewModel<TwoFaViewModelImpl>(), viewModel: TwoFaViewModel = koinViewModel<TwoFaViewModelImpl>(),
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable { var confirmedExit by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
@@ -70,8 +71,10 @@ fun TwoFaScreen(
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(confirmedExit) {
if (confirmedExit) { if (confirmedExit) {
onAction(TwoFaUiAction.BackClicked) onBack()
}
} }
BackHandler(enabled = !confirmedExit) { BackHandler(enabled = !confirmedExit) {
@@ -93,16 +96,21 @@ fun TwoFaScreen(
) )
} }
if (screenState.isNeedToOpenLogin) { LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin() viewModel.onNavigatedToLogin()
val code = screenState.twoFaCode val code = screenState.twoFaCode
if (code == null) { if (code == null) {
onAction(TwoFaUiAction.BackClicked) onBack()
} else { } else {
onAction(TwoFaUiAction.CodeResult(code = code)) onCodeResult(code)
} }
} }
}
var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) }
val codeError = screenState.codeError
Scaffold { padding -> Scaffold { padding ->
Column( Column(
@@ -113,7 +121,7 @@ fun TwoFaScreen(
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween
) { ) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { onAction(TwoFaUiAction.BackClicked) }, onClick = onBack,
text = { text = {
Text( Text(
text = "Cancel", text = "Cancel",
@@ -155,9 +163,6 @@ fun TwoFaScreen(
} }
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) }
val codeError = screenState.codeError
TextField( TextField(
value = code, value = code,
onValueChange = { newText -> onValueChange = { newText ->
@@ -222,7 +222,7 @@ class ConversationsViewModelImpl(
private fun loadConversations( private fun loadConversations(
offset: Int = currentOffset.value offset: Int = currentOffset.value
) { ) {
conversationsUseCase.getConversations(count = 30, offset = offset).listenValue { state -> conversationsUseCase.getConversations(count = LOAD_COUNT, offset = offset).listenValue { state ->
state.processState( state.processState(
error = { error -> error = { error ->
when (error) { when (error) {
@@ -247,7 +247,7 @@ class ConversationsViewModelImpl(
} }
}, },
success = { response -> success = { response ->
val itemsCountSufficient = response.size == 30 val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient &&
@@ -621,5 +621,9 @@ class ConversationsViewModelImpl(
old.copy(conversations = uiConversations) old.copy(conversations = uiConversations)
} }
} }
companion object {
const val LOAD_COUNT = 30
}
} }
@@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -299,7 +300,35 @@ fun ConversationItem(
LocalContentAlpha(alpha = ContentAlpha.medium) { LocalContentAlpha(alpha = ContentAlpha.medium) {
Text( Text(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
text = conversation.message, text = kotlin.run {
val builder =
AnnotatedString.Builder(conversation.message.text)
conversation.message.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
} else {
spanStyleRange.item
}
builder.addStyle(
style = updatedSpanStyle,
start = spanStyleRange.start,
end = spanStyleRange.end
)
}
conversation.message.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
end = style.end
)
}
builder.toAnnotatedString()
},
minLines = 1, minLines = 1,
maxLines = maxLines, maxLines = maxLines,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -611,6 +611,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.AUDIO_PLAYLIST -> null AttachmentType.AUDIO_PLAYLIST -> null
AttachmentType.PODCAST -> null AttachmentType.PODCAST -> null
AttachmentType.NARRATIVE -> null AttachmentType.NARRATIVE -> null
AttachmentType.ARTICLE -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -660,18 +661,14 @@ private fun getTextWithVisualizedMentions(
var currentIndex = 0 var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>() val replacements = mutableListOf<Pair<IntRange, String>>()
// TODO: 25/04/2024, Danil Nikolaev: check why not working ([id279494346|@iworld2rist] да убери ты Елену Шлипс от меня)
val result = regex.replace(originalText) { matchResult -> val result = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty() val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first val startIndex = matchResult.range.first
val endIndex = matchResult.range.last val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: "" val id = matchResult.groups[2]?.value ?: ""
val text = matchResult.groups[3]?.value ?: ""
val replaced = val replaced = matchResult.groups[3]?.value.orEmpty()
text.substring(startIndex, endIndex + 1)
.replace("[$idPrefix$id|$text]", text)
val indexRange = val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length (startIndex + currentIndex)..startIndex + currentIndex + replaced.length
@@ -757,6 +754,7 @@ private fun getAttachmentUiText(
AttachmentType.AUDIO_PLAYLIST -> UiR.string.message_attachments_audio_playlist AttachmentType.AUDIO_PLAYLIST -> UiR.string.message_attachments_audio_playlist
AttachmentType.PODCAST -> UiR.string.message_attachments_podcast AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative
AttachmentType.ARTICLE -> UiR.string.message_attachments_article
}.let(UiText::Resource) }.let(UiText::Resource)
} }
+5
View File
@@ -5,6 +5,7 @@ eithernet = "1.9.0"
haze = "0.7.3" haze = "0.7.3"
kotlin = "2.0.0" kotlin = "2.0.0"
ksp = "2.0.0-1.0.22" ksp = "2.0.0-1.0.22"
vkompose = "0.5.4-k2"
accompanist = "0.34.0" accompanist = "0.34.0"
coil = "2.6.0" coil = "2.6.0"
@@ -28,6 +29,7 @@ espressoCore = "3.6.1"
appcompat = "1.7.0" appcompat = "1.7.0"
androidx-navigation = "2.8.0-beta05" androidx-navigation = "2.8.0-beta05"
serialization = "1.7.1" serialization = "1.7.1"
rebugger = "1.0.0-rc03"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
@@ -80,6 +82,8 @@ appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "a
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
rebugger = { module = "io.github.theapache64:rebugger-android", version.ref = "rebugger" }
[bundles] [bundles]
compose = [ compose = [
"compose-material3", "compose-material3",
@@ -100,3 +104,4 @@ com-google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"
org-jetbrains-kotlin-plugin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } org-jetbrains-kotlin-plugin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
com-vk-vkompose = { id = "com.vk.vkompose", version.ref = "vkompose" }