From ce1867c22c1a67a1fcfaee236c71ecf694f56d06 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sat, 13 Jul 2024 01:37:24 +0300 Subject: [PATCH] support for articles; ui & ux & logic fixes for 2fa and captcha screens; fix mentions; --- app/build.gradle.kts | 2 +- .../data/api/oauth/OAuthRepositoryImpl.kt | 13 +- .../src/main/res/values/strings.xml | 1 + core/model/build.gradle.kts | 3 + .../com/meloda/app/fast/model/BaseError.kt | 4 + .../app/fast/model/api/data/AttachmentType.kt | 3 +- .../app/fast/model/api/data/VkArticleData.kt | 15 ++ .../model/api/data/VkAttachmentItemData.kt | 4 +- .../app/fast/model/api/data/VkGroupData.kt | 8 +- .../fast/model/api/domain/VkArticleDomain.kt | 10 ++ .../com/meloda/app/fast/network/OAuthError.kt | 23 ++- .../app/fast/network/OAuthErrorDomain.kt | 2 + .../fast/network/OAuthResultCallFactory.kt | 9 +- .../meloda/app/fast/network/VkErrorCodes.kt | 2 + .../app/fast/auth/captcha/CaptchaViewModel.kt | 18 +- .../auth/captcha/model/CaptchaScreenState.kt | 6 +- .../auth/captcha/navigation/CaptchaRoute.kt | 2 +- .../captcha/presentation/CaptchaScreen.kt | 16 +- feature/auth/login/build.gradle.kts | 2 + .../meloda/fast/auth/login/LoginViewModel.kt | 117 +++++++++---- .../fast/auth/login/OAuthUseCaseImpl.kt | 4 + ...aArguments.kt => LoginCaptchaArguments.kt} | 2 +- .../fast/auth/login/model/LoginError.kt | 10 +- .../fast/auth/login/model/LoginScreenState.kt | 21 +-- ...guments.kt => LoginUserBannedArguments.kt} | 2 +- .../fast/auth/login/navigation/LoginRoute.kt | 26 ++- .../auth/login/presentation/LoginScreen.kt | 165 ++++++++++++++---- .../auth/login/presentation/LogoScreen.kt | 11 +- .../com/meloda/app/fast/auth/AuthGraph.kt | 16 +- .../app/fast/auth/twofa/TwoFaViewModel.kt | 38 ++-- .../fast/auth/twofa/model/TwoFaScreenState.kt | 2 - .../fast/auth/twofa/model/TwoFaUiAction.kt | 6 - .../fast/auth/twofa/navigation/TwoFaRoute.kt | 13 +- .../auth/twofa/presentation/TwoFaScreen.kt | 39 +++-- .../conversations/ConversationsViewModel.kt | 8 +- .../presentation/ConversationItem.kt | 31 +++- .../util/ConversationDomainMapper.kt | 8 +- gradle/libs.versions.toml | 5 + 38 files changed, 449 insertions(+), 218 deletions(-) create mode 100644 core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkArticleData.kt create mode 100644 core/model/src/main/kotlin/com/meloda/app/fast/model/api/domain/VkArticleDomain.kt rename feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/{CaptchaArguments.kt => LoginCaptchaArguments.kt} (87%) rename feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/{UserBannedArguments.kt => LoginUserBannedArguments.kt} (88%) delete mode 100644 feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaUiAction.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce45158d..b85b1136 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -151,7 +151,7 @@ dependencies { // Coil for 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.tooling) diff --git a/core/data/src/main/kotlin/com/meloda/app/fast/data/api/oauth/OAuthRepositoryImpl.kt b/core/data/src/main/kotlin/com/meloda/app/fast/data/api/oauth/OAuthRepositoryImpl.kt index eee89074..874f8ab3 100644 --- a/core/data/src/main/kotlin/com/meloda/app/fast/data/api/oauth/OAuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/meloda/app/fast/data/api/oauth/OAuthRepositoryImpl.kt @@ -40,12 +40,15 @@ class OAuthRepositoryImpl( requireNotNull(result.error) } - else -> throw IllegalStateException("Unknown result") + is ApiResult.Failure.ApiFailure -> TODO() -// is ApiResult.Failure.ApiFailure -> TODO() -// is ApiResult.Failure.HttpFailure -> TODO() -// is ApiResult.Failure.NetworkFailure -> TODO() -// is ApiResult.Failure.UnknownFailure -> TODO() + is ApiResult.Failure.NetworkFailure -> { + // TODO: 13/07/2024, Danil Nikolaev: implement showing network error + TODO() + } + is ApiResult.Failure.UnknownFailure -> TODO() + + else -> throw IllegalStateException("Unknown result") } } } diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 92d3bd9b..8cf1c8a2 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -104,6 +104,7 @@ Playlist Podcast Narrative + Article Uploading file Uploading photo diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 0cfeb77b..5d0c71c3 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -32,6 +32,9 @@ dependencies { implementation(libs.moshi.kotlin) ksp(libs.moshi.kotlin.codegen) + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.room.ktx) implementation(libs.room.runtime) ksp(libs.room.compiler) diff --git a/core/model/src/main/kotlin/com/meloda/app/fast/model/BaseError.kt b/core/model/src/main/kotlin/com/meloda/app/fast/model/BaseError.kt index 382b7dea..1aa057b3 100644 --- a/core/model/src/main/kotlin/com/meloda/app/fast/model/BaseError.kt +++ b/core/model/src/main/kotlin/com/meloda/app/fast/model/BaseError.kt @@ -1,5 +1,9 @@ package com.meloda.app.fast.model +import androidx.compose.runtime.Immutable + +@Immutable sealed class BaseError { + data object SessionExpired : BaseError() } diff --git a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/AttachmentType.kt b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/AttachmentType.kt index 5ffc19d7..00677feb 100644 --- a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/AttachmentType.kt +++ b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/AttachmentType.kt @@ -26,7 +26,8 @@ enum class AttachmentType(var value: String) { ARTIST("artist"), AUDIO_PLAYLIST("audio_playlist"), PODCAST("podcast"), - NARRATIVE("narrative"); + NARRATIVE("narrative"), + ARTICLE("article"); fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) diff --git a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkArticleData.kt b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkArticleData.kt new file mode 100644 index 00000000..c8e0d28e --- /dev/null +++ b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkArticleData.kt @@ -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 + ) +} diff --git a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkAttachmentItemData.kt b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkAttachmentItemData.kt index cee40fb0..db1570f6 100644 --- a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkAttachmentItemData.kt +++ b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkAttachmentItemData.kt @@ -31,7 +31,8 @@ data class VkAttachmentItemData( @Json(name = "audios") val audios: List?, @Json(name = "audio_playlist") val audioPlaylist: VkAudioPlaylistData?, @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)) { AttachmentType.UNKNOWN -> VkUnknownAttachment @@ -58,5 +59,6 @@ data class VkAttachmentItemData( AttachmentType.AUDIO_PLAYLIST -> audioPlaylist?.toDomain() AttachmentType.PODCAST -> podcast?.toDomain() AttachmentType.NARRATIVE -> narrative?.toDomain() + AttachmentType.ARTICLE -> article?.toDomain() } ?: VkUnknownAttachment } diff --git a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkGroupData.kt b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkGroupData.kt index fabed87b..36014196 100644 --- a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkGroupData.kt +++ b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/data/VkGroupData.kt @@ -10,11 +10,11 @@ data class VkGroupData( @Json(name = "id") val id: Int, @Json(name = "name") val name: 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 = "is_admin") val isAdmin: Int, - @Json(name = "is_member") val isMember: Int, - @Json(name = "is_advertiser") val isAdvertiser: Int, + @Json(name = "is_admin") val isAdmin: Int?, + @Json(name = "is_member") val isMember: Int?, + @Json(name = "is_advertiser") val isAdvertiser: Int?, @Json(name = "photo_50") val photo50: String?, @Json(name = "photo_100") val photo100: String?, @Json(name = "photo_200") val photo200: String?, diff --git a/core/model/src/main/kotlin/com/meloda/app/fast/model/api/domain/VkArticleDomain.kt b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/domain/VkArticleDomain.kt new file mode 100644 index 00000000..fead8b38 --- /dev/null +++ b/core/model/src/main/kotlin/com/meloda/app/fast/model/api/domain/VkArticleDomain.kt @@ -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 +} diff --git a/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthError.kt b/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthError.kt index 52ca2a75..90fefb1c 100644 --- a/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthError.kt +++ b/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthError.kt @@ -75,7 +75,7 @@ data class InvalidCredentialsError( ) @JsonClass(generateAdapter = true) -data class WrongTwoFaCode( +data class WrongTwoFaCodeError( @Json(name = "error") override val error: String, // "invalid_request" @Json(name = "error_description") override val errorDescription: String, @Json(name = "error_type") override val errorType: String // "wrong_otp" @@ -86,7 +86,7 @@ data class WrongTwoFaCode( ) @JsonClass(generateAdapter = true) -data class WrongTwoFaCodeFormat( +data class WrongTwoFaCodeFormatError( @Json(name = "error") override val error: String, // "invalid_request" @Json(name = "error_description") override val errorDescription: String, @Json(name = "error_type") override val errorType: String // "otp_format_is_incorrect" @@ -96,6 +96,17 @@ data class WrongTwoFaCodeFormat( 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) { is ValidationRequiredError -> { OAuthErrorDomain.ValidationRequiredError( @@ -129,13 +140,17 @@ fun OAuthError.toDomain(): OAuthErrorDomain? = when (this) { OAuthErrorDomain.InvalidCredentialsError } - is WrongTwoFaCode -> { + is WrongTwoFaCodeError -> { OAuthErrorDomain.WrongTwoFaCode } - is WrongTwoFaCodeFormat -> { + is WrongTwoFaCodeFormatError -> { OAuthErrorDomain.WrongTwoFaCodeFormat } + is TooManyTriesError -> { + OAuthErrorDomain.TooManyTriesError + } + else -> null } diff --git a/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthErrorDomain.kt b/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthErrorDomain.kt index 7066e53d..b33039f0 100644 --- a/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthErrorDomain.kt +++ b/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthErrorDomain.kt @@ -27,5 +27,7 @@ sealed class OAuthErrorDomain { data object InvalidCredentialsError : OAuthErrorDomain() data object WrongTwoFaCode : OAuthErrorDomain() data object WrongTwoFaCodeFormat : OAuthErrorDomain() + data object TooManyTriesError: OAuthErrorDomain() + data object UnknownError : OAuthErrorDomain() } diff --git a/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthResultCallFactory.kt b/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthResultCallFactory.kt index 55d299cc..4ec71364 100644 --- a/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthResultCallFactory.kt +++ b/core/network/src/main/kotlin/com/meloda/app/fast/network/OAuthResultCallFactory.kt @@ -110,6 +110,11 @@ internal class ResultCall( .fromJson(errorBodyString.orEmpty()) ?: return val error: OAuthError? = when (baseError.error) { + "9;Flood control" -> { + moshi.adapter(TooManyTriesError::class.java) + .fromJson(errorBodyString.orEmpty()) + } + "invalid_client" -> { moshi.adapter(InvalidCredentialsError::class.java) .fromJson(errorBodyString.orEmpty()) @@ -123,12 +128,12 @@ internal class ResultCall( "invalid_request" -> { when (val type = baseError.errorType) { "wrong_otp" -> { - moshi.adapter(WrongTwoFaCode::class.java) + moshi.adapter(WrongTwoFaCodeError::class.java) .fromJson(errorBodyString.orEmpty()) } "otp_format_is_incorrect" -> { - moshi.adapter(WrongTwoFaCodeFormat::class.java) + moshi.adapter(WrongTwoFaCodeFormatError::class.java) .fromJson(errorBodyString.orEmpty()) } diff --git a/core/network/src/main/kotlin/com/meloda/app/fast/network/VkErrorCodes.kt b/core/network/src/main/kotlin/com/meloda/app/fast/network/VkErrorCodes.kt index 9f597b68..c4faa244 100644 --- a/core/network/src/main/kotlin/com/meloda/app/fast/network/VkErrorCodes.kt +++ b/core/network/src/main/kotlin/com/meloda/app/fast/network/VkErrorCodes.kt @@ -51,10 +51,12 @@ object VkOAuthErrors { const val NEED_CAPTCHA = "need_captcha" const val INVALID_CLIENT = "invalid_client" const val INVALID_REQUEST = "invalid_request" + const val FLOOD_CONTROL = "9;Flood control" } object VkErrorTypes { const val WRONG_OTP_FORMAT = "otp_format_is_incorrect" const val WRONG_OTP = "wrong_otp" + const val PASSWORD_BRUTEFORCE_ATTEMPT = "password_bruteforce_attempt" } diff --git a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/CaptchaViewModel.kt b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/CaptchaViewModel.kt index ccbf20a0..3187699f 100644 --- a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/CaptchaViewModel.kt +++ b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/CaptchaViewModel.kt @@ -2,7 +2,6 @@ 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 @@ -13,16 +12,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update interface CaptchaViewModel { - val screenState: StateFlow + val isNeedToOpenLogin: StateFlow fun onCodeInputChanged(newCode: String) fun onTextFieldDoneClicked() fun onDoneButtonClicked() - fun setArguments(arguments: CaptchaArguments) - fun onNavigatedToLogin() } @@ -32,6 +29,7 @@ class CaptchaViewModelImpl( ) : CaptchaViewModel, ViewModel() { override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY) + override val isNeedToOpenLogin = MutableStateFlow(false) init { val arguments = Captcha.from(savedStateHandle).arguments @@ -57,20 +55,12 @@ class CaptchaViewModelImpl( 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 -// ) -// ) + isNeedToOpenLogin.update { true } } override fun onNavigatedToLogin() { screenState.updateValue(CaptchaScreenState.EMPTY) + isNeedToOpenLogin.update { false } } private fun processValidation(): Boolean { diff --git a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/model/CaptchaScreenState.kt b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/model/CaptchaScreenState.kt index ed5a116d..b794364c 100644 --- a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/model/CaptchaScreenState.kt +++ b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/model/CaptchaScreenState.kt @@ -4,8 +4,7 @@ data class CaptchaScreenState( val captchaSid: String, val captchaImage: String, val captchaCode: String, - val codeError: Boolean, - val isNeedToOpenLogin: Boolean + val codeError: Boolean ) { companion object { @@ -13,8 +12,7 @@ data class CaptchaScreenState( captchaSid = "", captchaImage = "", captchaCode = "", - codeError = false, - isNeedToOpenLogin = false + codeError = false ) } } diff --git a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/navigation/CaptchaRoute.kt b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/navigation/CaptchaRoute.kt index 10eabb52..308d74cb 100644 --- a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/navigation/CaptchaRoute.kt +++ b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/navigation/CaptchaRoute.kt @@ -41,7 +41,7 @@ fun NavController.navigateToCaptcha(arguments: CaptchaArguments) { this.navigate(Captcha(arguments)) } -fun NavController.setCaptchaResult(code: String) { +fun NavController.setCaptchaResult(code: String?) { this.currentBackStackEntry ?.savedStateHandle ?.set("captchacode", code) diff --git a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/presentation/CaptchaScreen.kt b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/presentation/CaptchaScreen.kt index 37204ab7..35078b71 100644 --- a/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/presentation/CaptchaScreen.kt +++ b/feature/auth/captcha/src/main/kotlin/com/meloda/app/fast/auth/captcha/presentation/CaptchaScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,6 +62,7 @@ fun CaptchaScreen( viewModel: CaptchaViewModel = koinViewModel() ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle() var confirmedExit by rememberSaveable { mutableStateOf(false) @@ -70,8 +72,10 @@ fun CaptchaScreen( mutableStateOf(false) } - if (confirmedExit) { - onBack() + LaunchedEffect(confirmedExit) { + if (confirmedExit) { + onBack() + } } BackHandler(enabled = !confirmedExit) { @@ -93,9 +97,11 @@ fun CaptchaScreen( ) } - if (screenState.isNeedToOpenLogin) { - viewModel.onNavigatedToLogin() - onResult(screenState.captchaCode) + LaunchedEffect(isNeedToOpenLogin) { + if (isNeedToOpenLogin) { + viewModel.onNavigatedToLogin() + onResult(screenState.captchaCode) + } } val focusManager = LocalFocusManager.current diff --git a/feature/auth/login/build.gradle.kts b/feature/auth/login/build.gradle.kts index e09a69b8..90be05ef 100644 --- a/feature/auth/login/build.gradle.kts +++ b/feature/auth/login/build.gradle.kts @@ -89,4 +89,6 @@ dependencies { implementation(libs.eithernet) implementation(libs.androidx.navigation.compose) implementation(libs.kotlin.serialization) + + implementation(libs.rebugger) } diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/LoginViewModel.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/LoginViewModel.kt index e69b975e..f270ad56 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/LoginViewModel.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/LoginViewModel.kt @@ -15,9 +15,11 @@ 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.LoginCaptchaArguments +import com.meloda.fast.auth.login.model.LoginError import com.meloda.fast.auth.login.model.LoginScreenState 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.validation.LoginValidator import kotlinx.coroutines.Dispatchers @@ -27,10 +29,19 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch interface LoginViewModel { val screenState: StateFlow + val loginError: StateFlow + + val twoFaCode: StateFlow + val twoFaArguments: StateFlow + val captchaCode: StateFlow + val captchaArguments: StateFlow + val userBannedArguments: StateFlow + val isNeedToOpenMain: StateFlow fun onPasswordVisibilityButtonClicked() @@ -56,10 +67,18 @@ class LoginViewModelImpl( private val oAuthUseCase: OAuthUseCase, private val usersUseCase: UsersUseCase, private val accountsRepository: AccountsRepository, - private val loginValidator: LoginValidator, + private val loginValidator: LoginValidator ) : ViewModel(), LoginViewModel { override val screenState = MutableStateFlow(LoginScreenState.EMPTY) + override val loginError = MutableStateFlow(null) + + override val twoFaCode = MutableStateFlow(null) + override val twoFaArguments = MutableStateFlow(null) + override val captchaCode = MutableStateFlow(null) + override val captchaArguments = MutableStateFlow(null) + override val userBannedArguments = MutableStateFlow(null) + override val isNeedToOpenMain = MutableStateFlow(false) private val validationState: StateFlow> = screenState.map(loginValidator::validate) @@ -86,37 +105,38 @@ class LoginViewModelImpl( } override fun onSignInButtonClicked() { + if (screenState.value.isLoading) return login() } override fun onErrorDialogDismissed() { - screenState.setValue { old -> old.copy(error = null) } + loginError.update { null } } override fun onNavigatedToMain() { - screenState.setValue { old -> old.copy(isNeedToNavigateToMain = false) } + isNeedToOpenMain.update { false } } override fun onNavigatedToUserBanned() { - screenState.setValue { old -> old.copy(userBannedArguments = null) } + userBannedArguments.update { null } } override fun onNavigatedToCaptcha() { - screenState.setValue { old -> old.copy(captchaArguments = null) } + captchaArguments.update { null } } override fun onNavigatedToTwoFa() { - screenState.setValue { old -> old.copy(twoFaArguments = null) } + twoFaArguments.update { null } } override fun onTwoFaCodeReceived(code: String) { - screenState.setValue { old -> old.copy(validationCode = code) } + twoFaCode.update { code } login() } override fun onCaptchaCodeReceived(code: String) { - screenState.setValue { old -> old.copy(captchaCode = code) } + captchaCode.update { code } login() } @@ -149,7 +169,8 @@ class LoginViewModelImpl( accountsRepository.storeAccounts(listOf(currentAccount)) delay(350) - screenState.setValue { old -> old.copy(isNeedToNavigateToMain = true) } + + isNeedToOpenMain.update { true } } } ) @@ -163,7 +184,10 @@ class LoginViewModelImpl( Log.d( "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() @@ -173,14 +197,17 @@ class LoginViewModelImpl( login = currentState.login, password = currentState.password, forceSms = forceSms, - twoFaCode = currentState.validationCode, - captchaSid = currentState.captchaArguments?.captchaSid, - captchaKey = currentState.captchaCode + twoFaCode = twoFaCode.value, + captchaSid = captchaArguments.value?.captchaSid, + captchaKey = captchaCode.value ).listenValue { state -> state.processState( error = { error -> Log.d("LoginViewModelImpl", "login: error: $error") + twoFaCode.update { null } + captchaCode.update { null } + parseError(error) }, success = { response -> @@ -213,20 +240,20 @@ class LoginViewModelImpl( accountsRepository.storeAccounts(listOf(currentAccount)) + captchaArguments.update { null } + captchaCode.update { null } + + twoFaArguments.update { null } + twoFaCode.update { null } + screenState.setValue { old -> old.copy( - captchaArguments = null, - captchaCode = null, - validationSid = null, - validationCode = null, - twoFaArguments = null, - login = "", password = "", - - isNeedToNavigateToMain = true ) } + + isNeedToOpenMain.update { true } } ) screenState.emit(screenState.value.copy(isLoading = state.isLoading())) @@ -238,7 +265,7 @@ class LoginViewModelImpl( is State.Error.OAuthError -> { when (val error = stateError.error) { is OAuthErrorDomain.ValidationRequiredError -> { - val twoFaArguments = LoginTwoFaArguments( + val arguments = LoginTwoFaArguments( validationSid = error.validationSid, redirectUri = error.redirectUri, phoneMask = error.phoneMask, @@ -246,25 +273,49 @@ class LoginViewModelImpl( canResendSms = error.validationResend == "sms", wrongCodeError = null ) - screenState.setValue { old -> old.copy(twoFaArguments = twoFaArguments) } - true + twoFaArguments.update { arguments } } is OAuthErrorDomain.CaptchaRequiredError -> { - val captchaArguments = CaptchaArguments( + val arguments = LoginCaptchaArguments( captchaSid = error.captchaSid, captchaImage = error.captchaImageUrl ) - screenState.setValue { old -> old.copy(captchaArguments = captchaArguments) } - true + captchaArguments.update { arguments } } - OAuthErrorDomain.InvalidCredentialsError -> TODO() - is OAuthErrorDomain.UserBannedError -> TODO() - OAuthErrorDomain.WrongTwoFaCode -> TODO() - OAuthErrorDomain.WrongTwoFaCodeFormat -> TODO() - OAuthErrorDomain.UnknownError -> TODO() + OAuthErrorDomain.InvalidCredentialsError -> { + loginError.update { LoginError.WrongCredentials } + } + + is OAuthErrorDomain.UserBannedError -> { + val arguments = LoginUserBannedArguments( + name = error.memberName, + message = error.message, + restoreUrl = error.restoreUrl, + accessToken = error.accessToken + ) + userBannedArguments.update { arguments } + } + + OAuthErrorDomain.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 diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/OAuthUseCaseImpl.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/OAuthUseCaseImpl.kt index b6f50e4f..c16baba5 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/OAuthUseCaseImpl.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/OAuthUseCaseImpl.kt @@ -44,6 +44,10 @@ class OAuthUseCaseImpl( ) } + VkOAuthErrors.FLOOD_CONTROL -> { + State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError) + } + VkOAuthErrors.NEED_VALIDATION -> { if (response.banInfo != null) { val info = requireNotNull(response.banInfo) diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/CaptchaArguments.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginCaptchaArguments.kt similarity index 87% rename from feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/CaptchaArguments.kt rename to feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginCaptchaArguments.kt index 583955dd..b8837e89 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/CaptchaArguments.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginCaptchaArguments.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable @Parcelize -data class CaptchaArguments( +data class LoginCaptchaArguments( val captchaSid: String, val captchaImage: String ) : Parcelable diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginError.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginError.kt index 114fa6db..6dbdcd96 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginError.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginError.kt @@ -1,6 +1,12 @@ 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() } diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginScreenState.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginScreenState.kt index 8951ad4a..2cc6cc8d 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginScreenState.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginScreenState.kt @@ -2,43 +2,24 @@ 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, + passwordVisible = false ) } } diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/UserBannedArguments.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginUserBannedArguments.kt similarity index 88% rename from feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/UserBannedArguments.kt rename to feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginUserBannedArguments.kt index 03dfb5e8..b8b986d8 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/UserBannedArguments.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/model/LoginUserBannedArguments.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable @Parcelize -data class UserBannedArguments( +data class LoginUserBannedArguments( val name: String, val message: String, val restoreUrl: String, diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/navigation/LoginRoute.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/navigation/LoginRoute.kt index d1e08003..ed346db6 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/navigation/LoginRoute.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/navigation/LoginRoute.kt @@ -1,5 +1,6 @@ package com.meloda.fast.auth.login.navigation +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder 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.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.LoginCaptchaArguments 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.LogoScreen import kotlinx.serialization.Serializable @@ -22,16 +23,19 @@ object Logo fun NavGraphBuilder.loginRoute( onError: (BaseError) -> Unit, - onNavigateToCaptcha: (CaptchaArguments) -> Unit, + onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit, onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit, onNavigateToMain: () -> Unit, - onNavigateToUserBanned: (UserBannedArguments) -> Unit, + onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToCredentials: () -> Unit, navController: NavController ) { - composable { + composable { backStackEntry -> val viewModel: LoginViewModel = - it.sharedViewModel(navController = navController) + backStackEntry.sharedViewModel(navController = navController) + + val twoFaCode = backStackEntry.getTwoFaResult() + val captchaCode = backStackEntry.getCaptchaResult() LoginScreen( onError = onError, @@ -39,6 +43,8 @@ fun NavGraphBuilder.loginRoute( onNavigateToMain = onNavigateToMain, onNavigateToCaptcha = onNavigateToCaptcha, onNavigateToTwoFa = onNavigateToTwoFa, + twoFaCode = twoFaCode, + captchaCode = captchaCode, viewModel = viewModel ) } @@ -54,3 +60,11 @@ fun NavGraphBuilder.loginRoute( fun NavController.navigateToLogin() { this.navigate(route = Login) } + +fun NavBackStackEntry.getTwoFaResult(): String? { + return savedStateHandle["twofacode"] +} + +fun NavBackStackEntry.getCaptchaResult(): String? { + return savedStateHandle["captchacode"] +} diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LoginScreen.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LoginScreen.kt index 5108aeee..92c7189c 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LoginScreen.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LoginScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,10 +58,11 @@ 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.LoginCaptchaArguments 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 com.meloda.fast.auth.login.model.LoginUserBannedArguments +import com.theapache64.rebugger.Rebugger import org.koin.androidx.compose.koinViewModel import com.meloda.app.fast.designsystem.R as UiR @@ -68,49 +70,64 @@ import com.meloda.app.fast.designsystem.R as UiR @Composable fun LoginScreen( onError: (BaseError) -> Unit, - onNavigateToUserBanned: (UserBannedArguments) -> Unit, + onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToMain: () -> Unit, - onNavigateToCaptcha: (CaptchaArguments) -> Unit, + onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit, onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit, + twoFaCode: String?, + captchaCode: String?, viewModel: LoginViewModel = koinViewModel() ) { 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) { - viewModel.onNavigatedToMain() - onNavigateToMain() + LaunchedEffect(isNeedToOpenMain) { + if (isNeedToOpenMain) { + viewModel.onNavigatedToMain() + onNavigateToMain() + } } - screenState.userBannedArguments?.let { arguments -> - viewModel.onNavigatedToUserBanned() - onNavigateToUserBanned(arguments) + LaunchedEffect(userBannedArguments) { + userBannedArguments?.let { arguments -> + viewModel.onNavigatedToUserBanned() + onNavigateToUserBanned(arguments) + } } - screenState.captchaArguments?.let { arguments -> - viewModel.onNavigatedToCaptcha() - onNavigateToCaptcha(arguments) + LaunchedEffect(captchaArguments) { + captchaArguments?.let { arguments -> + viewModel.onNavigatedToCaptcha() + onNavigateToCaptcha(arguments) + } } - screenState.twoFaArguments?.let { arguments -> - viewModel.onNavigatedToTwoFa() - onNavigateToTwoFa(arguments) + LaunchedEffect(twoFaArguments) { + twoFaArguments?.let { arguments -> + viewModel.onNavigatedToTwoFa() + onNavigateToTwoFa(arguments) + } + } + + LaunchedEffect(twoFaCode) { + if (twoFaCode != null) { + viewModel.onTwoFaCodeReceived(twoFaCode) + } + } + + LaunchedEffect(captchaCode) { + if (captchaCode != null) { + viewModel.onCaptchaCodeReceived(captchaCode) + } } 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 @@ -165,8 +182,14 @@ fun LoginScreen( .height(58.dp) .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) - .handleEnterKey(loginFieldTabClick::invoke) - .handleTabKey(loginFieldTabClick::invoke) + .handleEnterKey { + passwordFocusable.requestFocus() + true + } + .handleTabKey { + passwordFocusable.requestFocus() + true + } .focusRequester(loginFocusable) .connectNode(handler = autoFillEmailHandler) .defaultFocusChangeAutoFill(handler = autoFillEmailHandler), @@ -213,7 +236,8 @@ fun LoginScreen( .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) .handleEnterKey { - goButtonClickAction.invoke() + focusManager.clearFocus() + viewModel.onSignInButtonClicked() true } .focusRequester(passwordFocusable) @@ -261,7 +285,10 @@ fun LoginScreen( keyboardType = KeyboardType.Password ), keyboardActions = KeyboardActions( - onGo = { goButtonClickAction.invoke() } + onGo = { + focusManager.clearFocus() + viewModel.onSignInButtonClicked() + } ), isError = showPasswordError, visualTransformation = if (screenState.passwordVisible) { @@ -282,7 +309,10 @@ fun LoginScreen( ) { FloatingActionButton( - onClick = goButtonClickAction::invoke, + onClick = { + focusManager.clearFocus() + viewModel.onSignInButtonClicked() + }, containerColor = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.testTag("Sign in button") ) { @@ -306,7 +336,31 @@ fun LoginScreen( HandleError( 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?, ) { when (error) { - LoginError.WrongCredentials -> { + null -> Unit + + LoginError.Unknown -> { MaterialDialog( onDismissAction = onDismiss, title = UiText.Simple("Error"), - text = UiText.Simple("Wrong login or password"), + text = UiText.Simple("Unknown error."), 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) + ) + } } } diff --git a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LogoScreen.kt b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LogoScreen.kt index 232fe5a0..fdbed2b1 100644 --- a/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LogoScreen.kt +++ b/feature/auth/login/src/main/kotlin/com/meloda/fast/auth/login/presentation/LogoScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -40,11 +41,13 @@ fun LogoScreen( onShowCredentials: () -> Unit, viewModel: LoginViewModel = koinViewModel() ) { - val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() - if (screenState.isNeedToNavigateToMain) { - viewModel.onNavigatedToMain() - onNavigateToMain() + LaunchedEffect(isNeedToOpenMain) { + if (isNeedToOpenMain) { + viewModel.onNavigatedToMain() + onNavigateToMain() + } } Scaffold { padding -> diff --git a/feature/auth/src/main/kotlin/com/meloda/app/fast/auth/AuthGraph.kt b/feature/auth/src/main/kotlin/com/meloda/app/fast/auth/AuthGraph.kt index d65b644a..d80bd51c 100644 --- a/feature/auth/src/main/kotlin/com/meloda/app/fast/auth/AuthGraph.kt +++ b/feature/auth/src/main/kotlin/com/meloda/app/fast/auth/AuthGraph.kt @@ -2,7 +2,6 @@ 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 @@ -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.navigateToLogin import kotlinx.serialization.Serializable +import java.net.URLEncoder @Serializable object AuthGraph @@ -27,7 +27,7 @@ object AuthGraph fun NavGraphBuilder.authNavGraph( onError: (BaseError) -> Unit, onNavigateToMain: () -> Unit, - navController: NavHostController + navController: NavController ) { navigation( startDestination = Logo @@ -46,7 +46,7 @@ fun NavGraphBuilder.authNavGraph( navController.navigateToTwoFa( TwoFaArguments( validationSid = arguments.validationSid, - redirectUri = arguments.redirectUri, + redirectUri = URLEncoder.encode(arguments.redirectUri, "utf-8"), phoneMask = arguments.phoneMask, validationType = arguments.validationType, canResendSms = arguments.canResendSms, @@ -70,7 +70,10 @@ fun NavGraphBuilder.authNavGraph( ) twoFaRoute( - onBack = navController::navigateUp, + onBack = { + navController.navigateUp() + navController.setTwoFaResult(null) + }, onResult = { code -> navController.popBackStack() navController.setTwoFaResult(code) @@ -78,7 +81,10 @@ fun NavGraphBuilder.authNavGraph( ) captchaRoute( - onBack = navController::navigateUp, + onBack = { + navController.navigateUp() + navController.setCaptchaResult(null) + }, onResult = { code -> navController.popBackStack() navController.setCaptchaResult(code) diff --git a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/TwoFaViewModel.kt b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/TwoFaViewModel.kt index 3626c5aa..a808d190 100644 --- a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/TwoFaViewModel.kt +++ b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/TwoFaViewModel.kt @@ -1,10 +1,8 @@ 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 @@ -20,6 +18,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -27,6 +26,8 @@ interface TwoFaViewModel { val screenState: StateFlow + val isNeedToOpenLogin: StateFlow + fun onCodeInputChanged(newCode: String) fun onBackButtonClicked() @@ -36,8 +37,6 @@ interface TwoFaViewModel { fun onDoneButtonClicked() fun onNavigatedToLogin() - - fun setArguments(arguments: TwoFaArguments) } class TwoFaViewModelImpl( @@ -48,6 +47,8 @@ class TwoFaViewModelImpl( override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY) + override val isNeedToOpenLogin = MutableStateFlow(false) + private var delayJob: Job? = null init { @@ -88,12 +89,8 @@ class TwoFaViewModelImpl( } override fun onCancelButtonClicked() { - screenState.updateValue( - screenState.value.copy( - twoFaCode = null, - isNeedToOpenLogin = true - ) - ) + screenState.setValue { old -> old.copy(twoFaCode = null) } + isNeedToOpenLogin.update { true } } override fun onRequestSmsButtonClicked() { @@ -107,25 +104,12 @@ class TwoFaViewModelImpl( override fun onDoneButtonClicked() { if (!processValidation()) return - screenState.updateValue(screenState.value.copy(isNeedToOpenLogin = true)) + isNeedToOpenLogin.update { 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 -// ) -// ) + isNeedToOpenLogin.update { false } } private fun processValidation(): Boolean { @@ -147,7 +131,9 @@ class TwoFaViewModelImpl( authUseCase.sendSms(validationSid) .listenValue { state -> state.processState( - error = { error -> }, + error = { error -> + + }, success = { response -> val newValidationType = response.validationType val newCanResendSms = response.validationResend == "sms" diff --git a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaScreenState.kt b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaScreenState.kt index 04456d85..fb90afdb 100644 --- a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaScreenState.kt +++ b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaScreenState.kt @@ -9,7 +9,6 @@ data class TwoFaScreenState( val canResendSms: Boolean, val codeError: String?, val delayTime: Int, - val isNeedToOpenLogin: Boolean, val phoneMask: String ) { @@ -21,7 +20,6 @@ data class TwoFaScreenState( canResendSms = false, codeError = null, delayTime = 0, - isNeedToOpenLogin = false, phoneMask = "" ) } diff --git a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaUiAction.kt b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaUiAction.kt deleted file mode 100644 index 6205be5e..00000000 --- a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/model/TwoFaUiAction.kt +++ /dev/null @@ -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() -} diff --git a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/navigation/TwoFaRoute.kt b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/navigation/TwoFaRoute.kt index 8ad0740e..8c4bfe9a 100644 --- a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/navigation/TwoFaRoute.kt +++ b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/navigation/TwoFaRoute.kt @@ -6,7 +6,6 @@ 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 @@ -28,12 +27,8 @@ fun NavGraphBuilder.twoFaRoute( ) { composable(typeMap = TwoFa.typeMap) { TwoFaScreen( - onAction = { action -> - when (action) { - TwoFaUiAction.BackClicked -> onBack() - is TwoFaUiAction.CodeResult -> onResult(action.code) - } - } + onBack = onBack, + onCodeResult = onResult ) } } @@ -42,8 +37,10 @@ fun NavController.navigateToTwoFa(arguments: TwoFaArguments) { this.navigate(TwoFa(arguments)) } -fun NavController.setTwoFaResult(code: String) { +fun NavController.setTwoFaResult(code: String?) { this.currentBackStackEntry ?.savedStateHandle ?.set("twofacode", code) } + + diff --git a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/presentation/TwoFaScreen.kt b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/presentation/TwoFaScreen.kt index 7723d441..35344f1d 100644 --- a/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/presentation/TwoFaScreen.kt +++ b/feature/auth/twofa/src/main/kotlin/com/meloda/app/fast/auth/twofa/presentation/TwoFaScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,7 +44,6 @@ 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 @@ -51,17 +51,18 @@ 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, + onBack: () -> Unit, + onCodeResult: (code: String) -> Unit, viewModel: TwoFaViewModel = koinViewModel(), ) { val focusManager = LocalFocusManager.current val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle() + var confirmedExit by rememberSaveable { mutableStateOf(false) } @@ -70,8 +71,10 @@ fun TwoFaScreen( mutableStateOf(false) } - if (confirmedExit) { - onAction(TwoFaUiAction.BackClicked) + LaunchedEffect(confirmedExit) { + if (confirmedExit) { + onBack() + } } BackHandler(enabled = !confirmedExit) { @@ -93,17 +96,22 @@ fun TwoFaScreen( ) } - if (screenState.isNeedToOpenLogin) { - viewModel.onNavigatedToLogin() + LaunchedEffect(isNeedToOpenLogin) { + if (isNeedToOpenLogin) { + viewModel.onNavigatedToLogin() - val code = screenState.twoFaCode - if (code == null) { - onAction(TwoFaUiAction.BackClicked) - } else { - onAction(TwoFaUiAction.CodeResult(code = code)) + val code = screenState.twoFaCode + if (code == null) { + onBack() + } else { + onCodeResult(code) + } } } + var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) } + val codeError = screenState.codeError + Scaffold { padding -> Column( modifier = Modifier @@ -113,7 +121,7 @@ fun TwoFaScreen( verticalArrangement = Arrangement.SpaceBetween ) { ExtendedFloatingActionButton( - onClick = { onAction(TwoFaUiAction.BackClicked) }, + onClick = onBack, text = { Text( text = "Cancel", @@ -155,9 +163,6 @@ fun TwoFaScreen( } Spacer(modifier = Modifier.height(10.dp)) - var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) } - val codeError = screenState.codeError - TextField( value = code, onValueChange = { newText -> diff --git a/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/ConversationsViewModel.kt b/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/ConversationsViewModel.kt index 4b3d923f..175d9615 100644 --- a/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/ConversationsViewModel.kt +++ b/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/ConversationsViewModel.kt @@ -222,7 +222,7 @@ class ConversationsViewModelImpl( private fun loadConversations( offset: Int = currentOffset.value ) { - conversationsUseCase.getConversations(count = 30, offset = offset).listenValue { state -> + conversationsUseCase.getConversations(count = LOAD_COUNT, offset = offset).listenValue { state -> state.processState( error = { error -> when (error) { @@ -247,7 +247,7 @@ class ConversationsViewModelImpl( } }, success = { response -> - val itemsCountSufficient = response.size == 30 + val itemsCountSufficient = response.size == LOAD_COUNT canPaginate.setValue { itemsCountSufficient } val paginationExhausted = !itemsCountSufficient && @@ -621,5 +621,9 @@ class ConversationsViewModelImpl( old.copy(conversations = uiConversations) } } + + companion object { + const val LOAD_COUNT = 30 + } } diff --git a/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/presentation/ConversationItem.kt b/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/presentation/ConversationItem.kt index 23d28ef6..7407a97f 100644 --- a/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/presentation/ConversationItem.kt +++ b/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/presentation/ConversationItem.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -299,7 +300,35 @@ fun ConversationItem( LocalContentAlpha(alpha = ContentAlpha.medium) { Text( 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, maxLines = maxLines, style = MaterialTheme.typography.bodyLarge, diff --git a/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/util/ConversationDomainMapper.kt b/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/util/ConversationDomainMapper.kt index cab80f36..a6d9d8f6 100644 --- a/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/util/ConversationDomainMapper.kt +++ b/feature/conversations/src/main/kotlin/com/meloda/app/fast/conversations/util/ConversationDomainMapper.kt @@ -611,6 +611,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? { AttachmentType.AUDIO_PLAYLIST -> null AttachmentType.PODCAST -> null AttachmentType.NARRATIVE -> null + AttachmentType.ARTICLE -> null }?.let(UiImage::Resource) } @@ -660,18 +661,14 @@ private fun getTextWithVisualizedMentions( var currentIndex = 0 val replacements = mutableListOf>() - // TODO: 25/04/2024, Danil Nikolaev: check why not working ([id279494346|@iworld2rist] да убери ты Елену Шлипс от меня) val result = regex.replace(originalText) { matchResult -> val idPrefix = matchResult.groups[1]?.value.orEmpty() val startIndex = matchResult.range.first val endIndex = matchResult.range.last val id = matchResult.groups[2]?.value ?: "" - val text = matchResult.groups[3]?.value ?: "" - val replaced = - text.substring(startIndex, endIndex + 1) - .replace("[$idPrefix$id|$text]", text) + val replaced = matchResult.groups[3]?.value.orEmpty() val indexRange = (startIndex + currentIndex)..startIndex + currentIndex + replaced.length @@ -757,6 +754,7 @@ private fun getAttachmentUiText( AttachmentType.AUDIO_PLAYLIST -> UiR.string.message_attachments_audio_playlist AttachmentType.PODCAST -> UiR.string.message_attachments_podcast AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative + AttachmentType.ARTICLE -> UiR.string.message_attachments_article }.let(UiText::Resource) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77b81e3a..882404c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ eithernet = "1.9.0" haze = "0.7.3" kotlin = "2.0.0" ksp = "2.0.0-1.0.22" +vkompose = "0.5.4-k2" accompanist = "0.34.0" coil = "2.6.0" @@ -28,6 +29,7 @@ espressoCore = "3.6.1" appcompat = "1.7.0" androidx-navigation = "2.8.0-beta05" serialization = "1.7.1" +rebugger = "1.0.0-rc03" [libraries] 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" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +rebugger = { module = "io.github.theapache64:rebugger-android", version.ref = "rebugger" } + [bundles] compose = [ "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" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +com-vk-vkompose = { id = "com.vk.vkompose", version.ref = "vkompose" }