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

This commit is contained in:
2024-07-13 22:45:49 +03:00
parent dfdc48b682
commit 733627f935
98 changed files with 1611 additions and 1637 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ dependencies {
implementation(projects.feature.auth.login)
implementation(projects.feature.auth.captcha)
implementation(projects.feature.auth.twofa)
implementation(projects.feature.auth.validation)
implementation(projects.feature.auth.userbanned)
implementation(libs.koin.androidx.compose)
@@ -18,7 +18,7 @@ interface CaptchaViewModel {
fun onCodeInputChanged(newCode: String)
fun onTextFieldDoneClicked()
fun onTextFieldDoneAction()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
@@ -32,24 +32,22 @@ class CaptchaViewModelImpl(
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
init {
val arguments = Captcha.from(savedStateHandle).arguments
val captchaImage = Captcha.from(savedStateHandle).captchaImageUrl
screenState.setValue { old ->
old.copy(
captchaSid = arguments.captchaSid,
captchaImage = URLDecoder.decode(arguments.captchaImage, "utf-8")
)
old.copy(captchaImageUrl = URLDecoder.decode(captchaImage, "utf-8"))
}
}
override fun onCodeInputChanged(newCode: String) {
val newState = screenState.value.copy(captchaCode = newCode.trim())
val newState = screenState.value.copy(code = newCode.trim())
screenState.update { newState }
processValidation()
}
override fun onTextFieldDoneClicked() {
override fun onTextFieldDoneAction() {
onDoneButtonClicked()
}
@@ -1,12 +0,0 @@
package com.meloda.app.fast.auth.captcha.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class CaptchaArguments(
val captchaSid: String,
val captchaImage: String
) : Parcelable
@@ -1,17 +1,15 @@
package com.meloda.app.fast.auth.captcha.model
data class CaptchaScreenState(
val captchaSid: String,
val captchaImage: String,
val captchaCode: String,
val captchaImageUrl: String,
val code: String,
val codeError: Boolean
) {
companion object {
val EMPTY = CaptchaScreenState(
captchaSid = "",
captchaImage = "",
captchaCode = "",
captchaImageUrl = "",
code = "",
codeError = false
)
}
@@ -0,0 +1,40 @@
package com.meloda.app.fast.auth.captcha.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.auth.captcha.presentation.CaptchaRoute
import kotlinx.serialization.Serializable
@Serializable
data class Captcha(val captchaImageUrl: String) {
companion object {
fun from(savedStateHandle: SavedStateHandle) = savedStateHandle.toRoute<Captcha>()
}
}
fun NavGraphBuilder.captchaScreen(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha> {
CaptchaRoute(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToCaptcha(captchaImageUrl: String) {
this.navigate(Captcha(captchaImageUrl))
}
fun NavController.setCaptchaResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("captcha_code", code)
}
@@ -1,48 +0,0 @@
package com.meloda.app.fast.auth.captcha.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.presentation.CaptchaScreen
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class Captcha(val arguments: CaptchaArguments) {
companion object {
val typeMap = mapOf(typeOf<CaptchaArguments>() to customNavType<CaptchaArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<Captcha>(typeMap)
}
}
fun NavGraphBuilder.captchaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha>(
typeMap = Captcha.typeMap
) {
CaptchaScreen(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToCaptcha(arguments: CaptchaArguments) {
this.navigate(Captcha(arguments))
}
fun NavController.setCaptchaResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("captchacode", code)
}
@@ -49,6 +49,7 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.meloda.app.fast.auth.captcha.CaptchaViewModel
import com.meloda.app.fast.auth.captcha.CaptchaViewModelImpl
import com.meloda.app.fast.auth.captcha.model.CaptchaScreenState
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
@@ -56,7 +57,7 @@ import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun CaptchaScreen(
fun CaptchaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
@@ -64,6 +65,30 @@ fun CaptchaScreen(
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.code)
}
}
CaptchaScreen(
screenState = screenState,
onBack = onBack,
onCodeInputChanged = viewModel::onCodeInputChanged,
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
onDoneButtonClicked = viewModel::onDoneButtonClicked
)
}
@Composable
fun CaptchaScreen(
screenState: CaptchaScreenState = CaptchaScreenState.EMPTY,
onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {},
onTextFieldDoneAction: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
) {
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
@@ -97,13 +122,6 @@ fun CaptchaScreen(
)
}
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.captchaCode)
}
}
val focusManager = LocalFocusManager.current
Scaffold { padding ->
@@ -171,7 +189,7 @@ fun CaptchaScreen(
} else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(screenState.captchaImage)
.data(screenState.captchaImageUrl)
.crossfade(true)
.build(),
contentDescription = "Captcha image",
@@ -183,14 +201,14 @@ fun CaptchaScreen(
Spacer(modifier = Modifier.height(30.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.captchaCode)) }
var code by remember { mutableStateOf(TextFieldValue(screenState.code)) }
val showError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
code = newText
viewModel.onCodeInputChanged(newText.text)
onCodeInputChanged(newText.text)
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
@@ -213,7 +231,7 @@ fun CaptchaScreen(
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.onTextFieldDoneClicked()
onTextFieldDoneAction()
}
),
isError = showError
@@ -225,7 +243,7 @@ fun CaptchaScreen(
}
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
onClick = onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
@@ -7,7 +7,7 @@ class CaptchaValidator {
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
return when {
screenState.captchaCode.isEmpty() -> CaptchaValidationResult.Empty
screenState.code.trim().isEmpty() -> CaptchaValidationResult.Empty
else -> CaptchaValidationResult.Valid
}
}
+3
View File
@@ -91,4 +91,7 @@ dependencies {
implementation(libs.kotlin.serialization)
implementation(libs.rebugger)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -0,0 +1,23 @@
package com.meloda.fast.auth.login
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import com.meloda.fast.auth.login.presentation.LoginScreen
import org.junit.Rule
import org.junit.Test
class LoginSignInTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun singInButton_isClickable() {
composeTestRule.setContent {
LoginScreen()
}
composeTestRule.onNodeWithTag(testTag = "sing_in_fab").assertHasClickAction()
}
}
@@ -15,10 +15,10 @@ 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.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.CaptchaArguments
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.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.meloda.fast.auth.login.model.LoginValidationResult
import com.meloda.fast.auth.login.validation.LoginValidator
@@ -36,10 +36,10 @@ interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
val loginError: StateFlow<LoginError?>
val twoFaCode: StateFlow<String?>
val twoFaArguments: StateFlow<LoginTwoFaArguments?>
val validationCode: StateFlow<String?>
val validationArguments: StateFlow<LoginValidationArguments?>
val captchaCode: StateFlow<String?>
val captchaArguments: StateFlow<LoginCaptchaArguments?>
val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
@@ -55,9 +55,9 @@ interface LoginViewModel {
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToTwoFa()
fun onNavigatedToValidation()
fun onTwoFaCodeReceived(code: String)
fun onValidationCodeReceived(code: String)
fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked()
@@ -73,10 +73,10 @@ class LoginViewModelImpl(
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 validationCode = MutableStateFlow<String?>(null)
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
override val captchaCode = MutableStateFlow<String?>(null)
override val captchaArguments = MutableStateFlow<LoginCaptchaArguments?>(null)
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
@@ -125,12 +125,12 @@ class LoginViewModelImpl(
captchaArguments.update { null }
}
override fun onNavigatedToTwoFa() {
twoFaArguments.update { null }
override fun onNavigatedToValidation() {
validationArguments.update { null }
}
override fun onTwoFaCodeReceived(code: String) {
twoFaCode.update { code }
override fun onValidationCodeReceived(code: String) {
validationCode.update { code }
login()
}
@@ -186,7 +186,7 @@ class LoginViewModelImpl(
"LoginViewModel",
"auth: login: ${currentState.login}; " +
"password: ${currentState.password}; " +
"2fa code: ${twoFaCode.value}; " +
"2fa code: ${validationCode.value}; " +
"captcha code: ${captchaCode.value}"
)
@@ -197,7 +197,7 @@ class LoginViewModelImpl(
login = currentState.login,
password = currentState.password,
forceSms = forceSms,
twoFaCode = twoFaCode.value,
validationCode = validationCode.value,
captchaSid = captchaArguments.value?.captchaSid,
captchaKey = captchaCode.value
).listenValue { state ->
@@ -205,7 +205,7 @@ class LoginViewModelImpl(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
twoFaCode.update { null }
validationCode.update { null }
captchaCode.update { null }
parseError(error)
@@ -229,7 +229,7 @@ class LoginViewModelImpl(
userId = userId,
accessToken = accessToken,
fastToken = null,
trustedHash = response.twoFaHash
trustedHash = response.validationHash
).also { account ->
UserConfig.currentUserId = account.userId
UserConfig.userId = account.userId
@@ -243,8 +243,8 @@ class LoginViewModelImpl(
captchaArguments.update { null }
captchaCode.update { null }
twoFaArguments.update { null }
twoFaCode.update { null }
validationArguments.update { null }
validationCode.update { null }
screenState.setValue { old ->
old.copy(
@@ -265,7 +265,7 @@ class LoginViewModelImpl(
is State.Error.OAuthError -> {
when (val error = stateError.error) {
is OAuthErrorDomain.ValidationRequiredError -> {
val arguments = LoginTwoFaArguments(
val arguments = LoginValidationArguments(
validationSid = error.validationSid,
redirectUri = error.redirectUri,
phoneMask = error.phoneMask,
@@ -273,13 +273,13 @@ class LoginViewModelImpl(
canResendSms = error.validationResend == "sms",
wrongCodeError = null
)
twoFaArguments.update { arguments }
validationArguments.update { arguments }
}
is OAuthErrorDomain.CaptchaRequiredError -> {
val arguments = LoginCaptchaArguments(
val arguments = CaptchaArguments(
captchaSid = error.captchaSid,
captchaImage = error.captchaImageUrl
captchaImageUrl = error.captchaImageUrl
)
captchaArguments.update { arguments }
}
@@ -298,12 +298,12 @@ class LoginViewModelImpl(
userBannedArguments.update { arguments }
}
OAuthErrorDomain.WrongTwoFaCode -> {
loginError.update { LoginError.WrongTwoFaCode }
OAuthErrorDomain.WrongValidationCode -> {
loginError.update { LoginError.WrongValidationCode }
}
OAuthErrorDomain.WrongTwoFaCodeFormat -> {
loginError.update { LoginError.WrongTwoFaCodeFormat }
OAuthErrorDomain.WrongValidationCodeFormat -> {
loginError.update { LoginError.WrongValidationCodeFormat }
}
OAuthErrorDomain.TooManyTriesError -> {
@@ -320,102 +320,6 @@ class LoginViewModelImpl(
else -> false
}
// return when (val error =
// (stateError as? State.Error.OAuthError<*>)?.error) {
// null -> false
// is CaptchaRequiredError -> {
// val captchaArguments = CaptchaArguments(
// captchaSid = error.captchaSid,
// captchaImage = error.captchaImage,
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenCaptcha = true,
// captchaArguments = captchaArguments
// )
// }
//
// true
// }
//
// is InvalidCredentialsError -> {
// screenState.setValue { old -> old.copy(error = LoginError.WrongCredentials) }
//
// true
// }
//
// is UserBannedError -> {
// val banInfo = error.banInfo
//
// val userBannedArguments = UserBannedArguments(
// name = banInfo.memberName,
// message = banInfo.message,
// restoreUrl = banInfo.restoreUrl,
// accessToken = banInfo.accessToken
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenUserBanned = true,
// userBannedArguments = userBannedArguments
// )
// }
//
// true
// }
//
// is ValidationRequiredError -> {
// val twoFaArguments = TwoFaArguments(
// validationSid = error.validationSid,
// redirectUri = error.redirectUri,
// phoneMask = error.phoneMask,
// validationType = error.validationType,
// canResendSms = error.validationResend == "sms",
// wrongCodeError = null
// )
//
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = twoFaArguments
// )
// }
//
// true
// }
//
// is WrongTwoFaCode -> {
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = old.twoFaArguments?.copy(
// wrongCodeError = UiText.Simple("Wrong code")
// )
// )
// }
//
// true
// }
//
// is WrongTwoFaCodeFormat -> {
// screenState.setValue { old ->
// old.copy(
// isNeedToOpenTwoFa = true,
// twoFaArguments = old.twoFaArguments?.copy(
// wrongCodeError = UiText.Simple("Wrong code format")
// )
// )
// }
//
// true
// }
// else -> false
// }
}
private fun processValidation() {
@@ -10,7 +10,7 @@ interface OAuthUseCase {
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>>
@@ -18,7 +18,7 @@ class OAuthUseCaseImpl(
login: String,
password: String,
forceSms: Boolean,
twoFaCode: String?,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
): Flow<State<AuthInfo>> = flow {
@@ -27,7 +27,7 @@ class OAuthUseCaseImpl(
val response = oAuthRepository.auth(
login = login,
password = password,
twoFaCode = twoFaCode,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
forceSms = forceSms
@@ -39,7 +39,7 @@ class OAuthUseCaseImpl(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
twoFaHash = response.twoFaHash
validationHash = response.validationHash
)
)
}
@@ -92,11 +92,11 @@ class OAuthUseCaseImpl(
VkOAuthErrors.INVALID_REQUEST -> {
when (response.errorType) {
VkErrorTypes.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCode)
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
}
VkErrorTypes.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongTwoFaCodeFormat)
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
}
else -> {
@@ -3,5 +3,5 @@ package com.meloda.fast.auth.login.model
data class AuthInfo(
val userId: Int?,
val accessToken: String?,
val twoFaHash: String?
val validationHash: String?
)
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class LoginCaptchaArguments(
data class CaptchaArguments(
val captchaSid: String,
val captchaImage: String
val captchaImageUrl: String
) : Parcelable
@@ -7,6 +7,6 @@ sealed class LoginError {
data object Unknown : LoginError()
data object WrongCredentials : LoginError()
data object TooManyTries : LoginError()
data object WrongTwoFaCode : LoginError()
data object WrongTwoFaCodeFormat : LoginError()
data object WrongValidationCode : LoginError()
data object WrongValidationCodeFormat : LoginError()
}
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class LoginTwoFaArguments(
data class LoginValidationArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
@@ -5,14 +5,13 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginValidationArguments
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 com.meloda.fast.auth.login.presentation.LoginRoute
import com.meloda.fast.auth.login.presentation.LogoRoute
import kotlinx.serialization.Serializable
@Serializable
@@ -21,10 +20,9 @@ object Login
@Serializable
object Logo
fun NavGraphBuilder.loginRoute(
onError: (BaseError) -> Unit,
onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
@@ -34,25 +32,24 @@ fun NavGraphBuilder.loginRoute(
val viewModel: LoginViewModel =
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
val twoFaCode = backStackEntry.getTwoFaResult()
val validationCode = backStackEntry.getValidationResult()
val captchaCode = backStackEntry.getCaptchaResult()
LoginScreen(
onError = onError,
LoginRoute(
onNavigateToUserBanned = onNavigateToUserBanned,
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToTwoFa = onNavigateToTwoFa,
twoFaCode = twoFaCode,
onNavigateToValidation = onNavigateToValidation,
validationCode = validationCode,
captchaCode = captchaCode,
viewModel = viewModel
)
}
composable<Logo> {
LogoScreen(
LogoRoute(
onNavigateToMain = onNavigateToMain,
onShowCredentials = onNavigateToCredentials
onGoNextButtonClicked = onNavigateToCredentials
)
}
}
@@ -61,10 +58,10 @@ fun NavController.navigateToLogin() {
this.navigate(route = Login)
}
fun NavBackStackEntry.getTwoFaResult(): String? {
return savedStateHandle["twofacode"]
fun NavBackStackEntry.getValidationResult(): String? {
return savedStateHandle["validation_code"]
}
fun NavBackStackEntry.getCaptchaResult(): String? {
return savedStateHandle["captchacode"]
return savedStateHandle["captcha_code"]
}
@@ -55,25 +55,23 @@ import com.meloda.app.fast.designsystem.connectNode
import com.meloda.app.fast.designsystem.defaultFocusChangeAutoFill
import com.meloda.app.fast.designsystem.handleEnterKey
import com.meloda.app.fast.designsystem.handleTabKey
import com.meloda.app.fast.model.BaseError
import com.meloda.fast.auth.login.LoginViewModel
import com.meloda.fast.auth.login.LoginViewModelImpl
import com.meloda.fast.auth.login.model.LoginCaptchaArguments
import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginTwoFaArguments
import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
onError: (BaseError) -> Unit,
fun LoginRoute(
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (LoginCaptchaArguments) -> Unit,
onNavigateToTwoFa: (LoginTwoFaArguments) -> Unit,
twoFaCode: String?,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
@@ -81,7 +79,7 @@ fun LoginScreen(
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 validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
val loginError by viewModel.loginError.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenMain) {
@@ -105,16 +103,16 @@ fun LoginScreen(
}
}
LaunchedEffect(twoFaArguments) {
twoFaArguments?.let { arguments ->
viewModel.onNavigatedToTwoFa()
onNavigateToTwoFa(arguments)
LaunchedEffect(validationArguments) {
validationArguments?.let { arguments ->
viewModel.onNavigatedToValidation()
onNavigateToValidation(arguments)
}
}
LaunchedEffect(twoFaCode) {
if (twoFaCode != null) {
viewModel.onTwoFaCodeReceived(twoFaCode)
LaunchedEffect(validationCode) {
if (validationCode != null) {
viewModel.onValidationCodeReceived(validationCode)
}
}
@@ -124,9 +122,41 @@ fun LoginScreen(
}
}
LoginScreen(
screenState = screenState,
onLoginAutoFilled = viewModel::onLoginInputChanged,
onPasswordAutoFilled = viewModel::onPasswordInputChanged,
onLoginInputChanged = viewModel::onLoginInputChanged,
onPasswordInputChanged = viewModel::onPasswordInputChanged,
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked
)
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = loginError
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
screenState: LoginScreenState = LoginScreenState.EMPTY,
onLoginAutoFilled: (String) -> Unit = {},
onPasswordAutoFilled: (String) -> Unit = {},
onLoginInputChanged: (String) -> Unit = {},
onPasswordInputChanged: (String) -> Unit = {},
onPasswordFieldEnterKeyClicked: () -> Unit = {},
onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {}
) {
val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
// TODO: 13/07/2024, Danil Nikolaev: remove
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
val showLoginError = screenState.loginError
@@ -135,7 +165,7 @@ fun LoginScreen(
onFill = { value ->
loginText =
TextFieldValue(text = value, selection = TextRange(value.length))
viewModel.onLoginInputChanged(value)
onLoginAutoFilled(value)
}
)
@@ -147,7 +177,7 @@ fun LoginScreen(
onFill = { value ->
passwordText =
TextFieldValue(text = value, selection = TextRange(value.length))
viewModel.onPasswordInputChanged(value)
onPasswordAutoFilled(value)
}
)
@@ -200,7 +230,7 @@ fun LoginScreen(
}
loginText = newText
viewModel.onLoginInputChanged(text)
onLoginInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
@@ -236,7 +266,7 @@ fun LoginScreen(
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
onPasswordFieldEnterKeyClicked()
true
}
.focusRequester(passwordFocusable)
@@ -250,7 +280,7 @@ fun LoginScreen(
}
passwordText = newText
viewModel.onPasswordInputChanged(text)
onPasswordInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
@@ -271,7 +301,7 @@ fun LoginScreen(
else UiR.drawable.round_visibility_24
)
IconButton(onClick = viewModel::onPasswordVisibilityButtonClicked) {
IconButton(onClick = onPasswordVisibilityButtonClicked) {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
@@ -286,7 +316,7 @@ fun LoginScreen(
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
onPasswordFieldGoAction()
}
),
isError = showPasswordError,
@@ -310,10 +340,10 @@ fun LoginScreen(
FloatingActionButton(
onClick = {
focusManager.clearFocus()
viewModel.onSignInButtonClicked()
onSignInButtonClicked()
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("Sign in button")
modifier = Modifier.testTag("sing_in_fab")
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
@@ -332,11 +362,6 @@ fun LoginScreen(
}
}
}
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = loginError
)
}
@Composable
@@ -375,7 +400,7 @@ fun HandleError(
}
LoginError.WrongTwoFaCode -> {
LoginError.WrongValidationCode -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
@@ -384,7 +409,7 @@ fun HandleError(
)
}
LoginError.WrongTwoFaCodeFormat -> {
LoginError.WrongValidationCodeFormat -> {
MaterialDialog(
onDismissAction = onDismiss,
title = UiText.Simple("Error"),
@@ -34,11 +34,10 @@ import com.meloda.fast.auth.login.LoginViewModelImpl
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
fun LogoRoute(
onNavigateToMain: () -> Unit,
onShowCredentials: () -> Unit,
onGoNextButtonClicked: () -> Unit,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
@@ -50,15 +49,37 @@ fun LogoScreen(
}
}
LogoScreen(
onLogoLongClicked = viewModel::onLogoLongClicked,
onGoNextButtonClicked = onGoNextButtonClicked
)
}
// TODO: 13/07/2024, Danil Nikolaev: replace with scaffold?
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
onLogoLongClicked: () -> Unit = {},
onGoNextButtonClicked: () -> Unit = {}
) {
Scaffold { padding ->
val topPadding by animateDpAsState(targetValue = padding.calculateTopPadding())
val bottomPadding by animateDpAsState(targetValue = padding.calculateBottomPadding())
val topPadding by animateDpAsState(
targetValue = padding.calculateTopPadding(),
label = "topPaddingAnimation"
)
val bottomPadding by animateDpAsState(
targetValue = padding.calculateBottomPadding(),
label = "bottomPaddingAnimation"
)
val endPadding by animateDpAsState(
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr)
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr),
label = "endPaddingAnimation"
)
val startPadding by animateDpAsState(
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr)
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr),
label = "startPaddingAnimation"
)
Box(
@@ -85,7 +106,7 @@ fun LogoScreen(
modifier = Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = viewModel::onLogoLongClicked,
onLongClick = onLogoLongClicked,
onClick = {}
)
)
@@ -98,7 +119,7 @@ fun LogoScreen(
}
FloatingActionButton(
onClick = onShowCredentials,
onClick = onGoNextButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.BottomCenter)
) {
@@ -3,20 +3,19 @@ package com.meloda.app.fast.auth
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.navigation
import com.meloda.app.fast.auth.captcha.model.CaptchaArguments
import com.meloda.app.fast.auth.captcha.navigation.captchaRoute
import com.meloda.app.fast.auth.captcha.navigation.captchaScreen
import com.meloda.app.fast.auth.captcha.navigation.navigateToCaptcha
import com.meloda.app.fast.auth.captcha.navigation.setCaptchaResult
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.navigation.navigateToTwoFa
import com.meloda.app.fast.auth.twofa.navigation.setTwoFaResult
import com.meloda.app.fast.auth.twofa.navigation.twoFaRoute
import com.meloda.app.fast.auth.validation.model.ValidationArguments
import com.meloda.app.fast.auth.validation.navigation.navigateToValidation
import com.meloda.app.fast.auth.validation.navigation.setValidationResult
import com.meloda.app.fast.auth.validation.navigation.validationScreen
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.userbanned.model.UserBannedArguments
import com.meloda.app.fast.userbanned.navigation.navigateToUserBanned
import com.meloda.app.fast.userbanned.navigation.userBannedRoute
import com.meloda.fast.auth.login.navigation.Logo
import com.meloda.fast.auth.login.navigation.loginRoute
import com.meloda.fast.auth.login.navigation.loginScreen
import com.meloda.fast.auth.login.navigation.navigateToLogin
import kotlinx.serialization.Serializable
import java.net.URLEncoder
@@ -25,26 +24,21 @@ import java.net.URLEncoder
object AuthGraph
fun NavGraphBuilder.authNavGraph(
onError: (BaseError) -> Unit,
onNavigateToMain: () -> Unit,
navController: NavController
) {
navigation<AuthGraph>(
startDestination = Logo
) {
loginRoute(
onError = onError,
loginScreen(
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
CaptchaArguments(
arguments.captchaSid,
URLEncoder.encode(arguments.captchaImage, "utf-8")
)
captchaImageUrl = URLEncoder.encode(arguments.captchaImageUrl, "utf-8")
)
},
onNavigateToTwoFa = { arguments ->
navController.navigateToTwoFa(
TwoFaArguments(
onNavigateToValidation = { arguments ->
navController.navigateToValidation(
ValidationArguments(
validationSid = arguments.validationSid,
redirectUri = URLEncoder.encode(arguments.redirectUri, "utf-8"),
phoneMask = arguments.phoneMask,
@@ -58,7 +52,7 @@ fun NavGraphBuilder.authNavGraph(
onNavigateToUserBanned = { arguments ->
navController.navigateToUserBanned(
UserBannedArguments(
name = arguments.name,
userName = arguments.name,
message = arguments.message,
restoreUrl = arguments.restoreUrl,
accessToken = arguments.accessToken
@@ -69,18 +63,18 @@ fun NavGraphBuilder.authNavGraph(
navController = navController
)
twoFaRoute(
validationScreen(
onBack = {
navController.navigateUp()
navController.setTwoFaResult(null)
navController.setValidationResult(null)
},
onResult = { code ->
navController.popBackStack()
navController.setTwoFaResult(code)
navController.setValidationResult(code)
}
)
captchaRoute(
captchaScreen(
onBack = {
navController.navigateUp()
navController.setCaptchaResult(null)
@@ -1,14 +1,14 @@
package com.meloda.app.fast.auth
import com.meloda.app.fast.auth.captcha.di.captchaModule
import com.meloda.app.fast.auth.twofa.di.twoFaModule
import com.meloda.app.fast.auth.validation.di.validationModule
import com.meloda.fast.auth.login.di.loginModule
import org.koin.dsl.module
val authModule = module {
includes(
loginModule,
twoFaModule,
validationModule,
captchaModule,
)
}
@@ -1,17 +0,0 @@
package com.meloda.app.fast.auth.twofa.di
import com.meloda.app.fast.auth.twofa.AuthUseCase
import com.meloda.app.fast.auth.twofa.AuthUseCaseImpl
import com.meloda.app.fast.auth.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl
import com.meloda.app.fast.auth.twofa.validation.TwoFaValidator
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val twoFaModule = module {
singleOf(::TwoFaValidator)
viewModelOf(::TwoFaViewModelImpl) bind TwoFaViewModel::class
singleOf(::AuthUseCaseImpl) bind AuthUseCase::class
}
@@ -1,26 +0,0 @@
package com.meloda.app.fast.auth.twofa.model
import com.meloda.app.fast.common.UiText
data class TwoFaScreenState(
val twoFaSid: String,
val twoFaCode: String?,
val twoFaText: UiText,
val canResendSms: Boolean,
val codeError: String?,
val delayTime: Int,
val phoneMask: String
) {
companion object {
val EMPTY = TwoFaScreenState(
twoFaSid = "",
twoFaCode = null,
twoFaText = UiText.Simple(""),
canResendSms = false,
codeError = null,
delayTime = 0,
phoneMask = ""
)
}
}
@@ -1,8 +0,0 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationResult {
data object Empty : TwoFaValidationResult()
data object Valid : TwoFaValidationResult()
fun isValid() = this == Valid
}
@@ -1,23 +0,0 @@
package com.meloda.app.fast.auth.twofa.model
sealed class TwoFaValidationType(val value: String) {
data object Sms : TwoFaValidationType(TYPE_SMS)
data object TwoFaApp : TwoFaValidationType(TYPE_TWO_FA_APP)
data class Another(val type: String) : TwoFaValidationType(type)
companion object {
private const val TYPE_SMS = "sms"
private const val TYPE_TWO_FA_APP = "2fa_app"
fun parse(validationType: String): TwoFaValidationType {
return when (validationType) {
TYPE_SMS -> Sms
TYPE_TWO_FA_APP -> TwoFaApp
else -> Another(validationType)
}
}
}
}
@@ -1,46 +0,0 @@
package com.meloda.app.fast.auth.twofa.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.auth.twofa.model.TwoFaArguments
import com.meloda.app.fast.auth.twofa.presentation.TwoFaScreen
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class TwoFa(val arguments: TwoFaArguments) {
companion object {
val typeMap = mapOf(typeOf<TwoFaArguments>() to customNavType<TwoFaArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<TwoFa>(typeMap)
}
}
fun NavGraphBuilder.twoFaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<TwoFa>(typeMap = TwoFa.typeMap) {
TwoFaScreen(
onBack = onBack,
onCodeResult = onResult
)
}
}
fun NavController.navigateToTwoFa(arguments: TwoFaArguments) {
this.navigate(TwoFa(arguments))
}
fun NavController.setTwoFaResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("twofacode", code)
}
@@ -1,14 +0,0 @@
package com.meloda.app.fast.auth.twofa.validation
import com.meloda.app.fast.auth.twofa.model.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationResult
class TwoFaValidator {
fun validate(screenState: TwoFaScreenState): TwoFaValidationResult {
return when {
screenState.twoFaCode.isNullOrEmpty() -> TwoFaValidationResult.Empty
else -> TwoFaValidationResult.Valid
}
}
}
+2
View File
@@ -57,4 +57,6 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
debugImplementation(libs.androidx.ui.tooling)
}
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class UserBannedArguments(
val name: String,
val userName: String,
val message: String,
val restoreUrl: String,
val accessToken: String
@@ -0,0 +1,14 @@
package com.meloda.app.fast.userbanned.model
data class UserBannedScreenState(
val userName: String,
val message: String
) {
companion object {
val EMPTY: UserBannedScreenState = UserBannedScreenState(
userName = "",
message = ""
)
}
}
@@ -8,7 +8,7 @@ import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.userbanned.model.UserBannedArguments
import com.meloda.app.fast.userbanned.presentation.UserBannedScreen
import com.meloda.app.fast.userbanned.presentation.UserBannedRoute
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -32,18 +32,16 @@ val UserBannedNavType = object : NavType<UserBannedArguments>(isNullableAllowed
override val name: String = "UserBannedArguments"
}
fun NavGraphBuilder.userBannedRoute(
onBack: () -> Unit
) {
fun NavGraphBuilder.userBannedRoute(onBack: () -> Unit) {
composable<UserBanned>(
typeMap = mapOf(typeOf<UserBannedArguments>() to UserBannedNavType)
) { backStackEntry ->
val arguments: UserBannedArguments = backStackEntry.toRoute()
UserBannedScreen(
UserBannedRoute(
onBack = onBack,
name = arguments.name,
message = arguments.message,
userName = arguments.userName,
message = arguments.message
)
}
}
@@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
@@ -22,27 +23,44 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.AppTheme
import com.meloda.app.fast.userbanned.model.UserBannedScreenState
import com.meloda.app.fast.designsystem.R as UiR
@Preview
@Composable
fun UserBannedScreenPreview() {
AppTheme {
UserBannedScreen(
onBack = {},
name = "Calvin Harris",
message = "Eto konets"
UserBannedScreen(
screenState = UserBannedScreenState(
userName = "Andre Shultz",
message = "Bruteforce"
)
)
}
@Composable
fun UserBannedRoute(
onBack: () -> Unit,
userName: String,
message: String
) {
val screenState = remember(userName, message) {
UserBannedScreenState(
userName = userName,
message = message
)
}
UserBannedScreen(
screenState = screenState,
onBack = onBack
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserBannedScreen(
onBack: () -> Unit,
name: String,
message: String,
screenState: UserBannedScreenState = UserBannedScreenState.EMPTY,
onBack: () -> Unit = {},
) {
Scaffold(
topBar = {
@@ -80,7 +98,7 @@ fun UserBannedScreen(
append(": ")
}
append(name)
append(screenState.userName)
}
)
Text(
@@ -89,7 +107,7 @@ fun UserBannedScreen(
append(stringResource(id = UiR.string.blocking_reason_title))
append(": ")
}
append(message)
append(screenState.message)
}
)
}
@@ -7,7 +7,7 @@ plugins {
}
android {
namespace = "com.meloda.app.fast.twofa"
namespace = "com.meloda.app.fast.validation"
compileSdk = Configs.compileSdk
defaultConfig {
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa
package com.meloda.app.fast.auth.validation
import com.meloda.app.fast.data.State
import com.meloda.app.fast.model.api.responses.SendSmsResponse
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa
package com.meloda.app.fast.auth.validation
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.auth.AuthRepository
@@ -1,12 +1,12 @@
package com.meloda.app.fast.auth.twofa
package com.meloda.app.fast.auth.validation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.auth.twofa.model.TwoFaScreenState
import com.meloda.app.fast.auth.twofa.model.TwoFaValidationType
import com.meloda.app.fast.auth.twofa.navigation.TwoFa
import com.meloda.app.fast.auth.twofa.validation.TwoFaValidator
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.auth.validation.model.ValidationType
import com.meloda.app.fast.auth.validation.navigation.Validation
import com.meloda.app.fast.auth.validation.validation.ValidationValidator
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.createTimerFlow
import com.meloda.app.fast.common.extensions.listenValue
@@ -22,9 +22,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
interface TwoFaViewModel {
interface ValidationViewModel {
val screenState: StateFlow<TwoFaScreenState>
val screenState: StateFlow<ValidationScreenState>
val isNeedToOpenLogin: StateFlow<Boolean>
@@ -33,36 +33,39 @@ interface TwoFaViewModel {
fun onBackButtonClicked()
fun onCancelButtonClicked()
fun onRequestSmsButtonClicked()
fun onTextFieldDoneClicked()
fun onTextFieldDoneAction()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
}
class TwoFaViewModelImpl(
private val validator: TwoFaValidator,
class ValidationViewModelImpl(
private val validator: ValidationValidator,
private val authUseCase: AuthUseCase,
savedStateHandle: SavedStateHandle
) : TwoFaViewModel, ViewModel() {
) : ValidationViewModel, ViewModel() {
override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY)
override val screenState = MutableStateFlow(ValidationScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
private var validationSid: String? = null
private var delayJob: Job? = null
init {
// TODO: 08/07/2024, Danil Nikolaev: use when fixed
//savedStateHandle.toRoute<TwoFa>().arguments
//savedStateHandle.toRoute<Validation>().arguments
val arguments = TwoFa.from(savedStateHandle).arguments
val arguments = Validation.from(savedStateHandle).arguments
validationSid = arguments.validationSid
screenState.setValue { old ->
old.copy(
twoFaSid = arguments.validationSid,
canResendSms = arguments.canResendSms,
isSmsButtonVisible = arguments.canResendSms,
codeError = arguments.wrongCodeError,
twoFaText = getTwoFaText(TwoFaValidationType.parse(arguments.validationType)),
validationText = getValidationText(ValidationType.parse(arguments.validationType)),
phoneMask = arguments.phoneMask
)
}
@@ -71,7 +74,7 @@ class TwoFaViewModelImpl(
override fun onCodeInputChanged(newCode: String) {
screenState.updateValue(
screenState.value.copy(
twoFaCode = newCode.trim(),
code = newCode.trim(),
codeError = null
)
)
@@ -89,7 +92,7 @@ class TwoFaViewModelImpl(
}
override fun onCancelButtonClicked() {
screenState.setValue { old -> old.copy(twoFaCode = null) }
screenState.setValue { old -> old.copy(code = null) }
isNeedToOpenLogin.update { true }
}
@@ -97,7 +100,7 @@ class TwoFaViewModelImpl(
sendValidationCode()
}
override fun onTextFieldDoneClicked() {
override fun onTextFieldDoneAction() {
onDoneButtonClicked()
}
@@ -108,7 +111,7 @@ class TwoFaViewModelImpl(
}
override fun onNavigatedToLogin() {
screenState.updateValue(TwoFaScreenState.EMPTY)
screenState.updateValue(ValidationScreenState.EMPTY)
isNeedToOpenLogin.update { false }
}
@@ -126,9 +129,7 @@ class TwoFaViewModelImpl(
}
private fun sendValidationCode() {
val validationSid = screenState.value.twoFaSid
authUseCase.sendSms(validationSid)
authUseCase.sendSms(validationSid.orEmpty())
.listenValue { state ->
state.processState(
error = { error ->
@@ -140,9 +141,9 @@ class TwoFaViewModelImpl(
screenState.setValue { old ->
old.copy(
canResendSms = newCanResendSms,
twoFaText = getTwoFaText(
TwoFaValidationType.parse(newValidationType.orEmpty())
isSmsButtonVisible = newCanResendSms,
validationText = getValidationText(
ValidationType.parse(newValidationType.orEmpty())
)
)
}
@@ -152,7 +153,7 @@ class TwoFaViewModelImpl(
)
if (state.isLoading()) {
screenState.emit(screenState.value.copy(canResendSms = false))
screenState.emit(screenState.value.copy(isSmsButtonVisible = false))
}
}
}
@@ -164,7 +165,7 @@ class TwoFaViewModelImpl(
time = delay,
onStartAction = {
screenState.updateValue(
screenState.value.copy(canResendSms = false)
screenState.value.copy(isSmsButtonVisible = false)
)
},
onTickAction = { remainedTime ->
@@ -175,24 +176,24 @@ class TwoFaViewModelImpl(
onTimeoutAction = {
screenState.updateValue(
screenState.value.copy(
canResendSms = true
isSmsButtonVisible = true
)
)
},
).launchIn(viewModelScope)
}
private fun getTwoFaText(validationType: TwoFaValidationType): UiText {
private fun getValidationText(validationType: ValidationType): UiText {
return when (validationType) {
TwoFaValidationType.Sms -> {
ValidationType.Sms -> {
UiText.Simple("SMS with the code is sent to ${screenState.value.phoneMask}")
}
TwoFaValidationType.TwoFaApp -> {
ValidationType.App -> {
UiText.Simple("Enter the code from the code generator application")
}
is TwoFaValidationType.Another -> UiText.Simple(validationType.type)
is ValidationType.Other -> UiText.Simple(validationType.type)
}
}
}
@@ -0,0 +1,17 @@
package com.meloda.app.fast.auth.validation.di
import com.meloda.app.fast.auth.validation.AuthUseCase
import com.meloda.app.fast.auth.validation.AuthUseCaseImpl
import com.meloda.app.fast.auth.validation.ValidationViewModel
import com.meloda.app.fast.auth.validation.ValidationViewModelImpl
import com.meloda.app.fast.auth.validation.validation.ValidationValidator
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val validationModule = module {
singleOf(::ValidationValidator)
viewModelOf(::ValidationViewModelImpl) bind ValidationViewModel::class
singleOf(::AuthUseCaseImpl) bind AuthUseCase::class
}
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa.model
package com.meloda.app.fast.auth.validation.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class TwoFaArguments(
data class ValidationArguments(
val validationSid: String,
val redirectUri: String,
val phoneMask: String,
@@ -0,0 +1,27 @@
package com.meloda.app.fast.auth.validation.model
import com.meloda.app.fast.common.UiText
data class ValidationScreenState(
val code: String?,
val codeError: String?,
val isSmsButtonVisible: Boolean,
val delayTime: Int,
val phoneMask: String,
// TODO: 13/07/2024, Danil Nikolaev: check wtf is this
val validationText: UiText,
) {
companion object {
val EMPTY = ValidationScreenState(
code = null,
codeError = null,
isSmsButtonVisible = false,
delayTime = 0,
phoneMask = "",
validationText = UiText.Simple("")
)
}
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.auth.validation.model
sealed class ValidationType(val value: String) {
data object Sms : ValidationType(TYPE_SMS)
data object App : ValidationType(TYPE_TWO_FA_APP)
data class Other(val type: String) : ValidationType(type)
companion object {
private const val TYPE_SMS = "sms"
private const val TYPE_TWO_FA_APP = "2fa_app"
fun parse(validationType: String): ValidationType {
return when (validationType) {
TYPE_SMS -> Sms
TYPE_TWO_FA_APP -> App
else -> Other(validationType)
}
}
}
}
@@ -0,0 +1,8 @@
package com.meloda.app.fast.auth.validation.model
sealed class ValidationValidationResult {
data object Empty : ValidationValidationResult()
data object Valid : ValidationValidationResult()
fun isValid() = this == Valid
}
@@ -0,0 +1,46 @@
package com.meloda.app.fast.auth.validation.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.auth.validation.model.ValidationArguments
import com.meloda.app.fast.auth.validation.presentation.ValidationRoute
import com.meloda.app.fast.common.customNavType
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class Validation(val arguments: ValidationArguments) {
companion object {
val typeMap = mapOf(typeOf<ValidationArguments>() to customNavType<ValidationArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<Validation>(typeMap)
}
}
fun NavGraphBuilder.validationScreen(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Validation>(typeMap = Validation.typeMap) {
ValidationRoute(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToValidation(arguments: ValidationArguments) {
this.navigate(Validation(arguments))
}
fun NavController.setValidationResult(code: String?) {
this.currentBackStackEntry
?.savedStateHandle
?.set("validation_code", code)
}
@@ -1,4 +1,4 @@
package com.meloda.app.fast.auth.twofa.presentation
package com.meloda.app.fast.auth.validation.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -42,8 +43,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.auth.twofa.TwoFaViewModel
import com.meloda.app.fast.auth.twofa.TwoFaViewModelImpl
import com.meloda.app.fast.auth.validation.ValidationViewModel
import com.meloda.app.fast.auth.validation.ValidationViewModelImpl
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.TextFieldErrorText
@@ -52,17 +54,48 @@ import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun TwoFaScreen(
fun ValidationRoute(
onBack: () -> Unit,
onCodeResult: (code: String) -> Unit,
viewModel: TwoFaViewModel = koinViewModel<TwoFaViewModelImpl>(),
onResult: (String) -> Unit,
viewModel: ValidationViewModel = koinViewModel<ValidationViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.code
if (code == null) {
onBack()
} else {
onResult(code)
}
}
}
ValidationScreen(
screenState = screenState,
onBack = onBack,
onCodeInputChanged = viewModel::onCodeInputChanged,
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
onRequestSmsButtonClicked = viewModel::onRequestSmsButtonClicked,
onDoneButtonClicked = viewModel::onDoneButtonClicked
)
}
@Composable
fun ValidationScreen(
screenState: ValidationScreenState = ValidationScreenState.EMPTY,
onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {},
onTextFieldDoneAction: () -> Unit = {},
onRequestSmsButtonClicked: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
) {
val focusManager = LocalFocusManager.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
var confirmedExit by rememberSaveable {
mutableStateOf(false)
}
@@ -96,20 +129,7 @@ fun TwoFaScreen(
)
}
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
val code = screenState.twoFaCode
if (code == null) {
onBack()
} else {
onCodeResult(code)
}
}
}
var code by remember { mutableStateOf(TextFieldValue(screenState.twoFaCode.orEmpty())) }
var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) }
val codeError = screenState.codeError
Scaffold { padding ->
@@ -140,7 +160,6 @@ fun TwoFaScreen(
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Two-Factor\nAuthentication",
style = MaterialTheme.typography.displayMedium,
@@ -148,16 +167,18 @@ fun TwoFaScreen(
)
Spacer(modifier = Modifier.height(38.dp))
Text(
text = screenState.twoFaText.getString().orEmpty(),
text = screenState.validationText.getString().orEmpty(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(10.dp))
val delayRemainedTime = screenState.delayTime
AnimatedVisibility(visible = delayRemainedTime > 0) {
val isResendTextVisible by remember {
derivedStateOf { screenState.delayTime > 0 }
}
AnimatedVisibility(visible = isResendTextVisible) {
Text(
text = "Can resend after $delayRemainedTime seconds",
text = "Can resend after ${screenState.delayTime} seconds",
style = MaterialTheme.typography.bodySmall
)
}
@@ -169,7 +190,7 @@ fun TwoFaScreen(
if (newText.text.length > 6) return@TextField
code = newText
viewModel.onCodeInputChanged((newText.text))
onCodeInputChanged((newText.text))
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
@@ -195,7 +216,7 @@ fun TwoFaScreen(
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
viewModel.onTextFieldDoneClicked()
onTextFieldDoneAction()
}
),
isError = codeError != null
@@ -211,13 +232,13 @@ fun TwoFaScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val canResendSms = screenState.canResendSms
val canResendSms = screenState.isSmsButtonVisible
AnimatedVisibility(
visible = canResendSms,
) {
ExtendedFloatingActionButton(
onClick = viewModel::onRequestSmsButtonClicked,
onClick = onRequestSmsButtonClicked,
text = {
Text(
text = "Request SMS",
@@ -238,7 +259,7 @@ fun TwoFaScreen(
Spacer(modifier = Modifier.width(16.dp))
FloatingActionButton(
onClick = viewModel::onDoneButtonClicked,
onClick = onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
) {
Icon(
@@ -0,0 +1,14 @@
package com.meloda.app.fast.auth.validation.validation
import com.meloda.app.fast.auth.validation.model.ValidationScreenState
import com.meloda.app.fast.auth.validation.model.ValidationValidationResult
class ValidationValidator {
fun validate(screenState: ValidationScreenState): ValidationValidationResult {
return when {
screenState.code.isNullOrEmpty() -> ValidationValidationResult.Empty
else -> ValidationValidationResult.Valid
}
}
}
@@ -5,7 +5,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.chatmaterials.presentation.ChatMaterialsScreen
import com.meloda.app.fast.chatmaterials.presentation.ChatMaterialsRoute
import kotlinx.serialization.Serializable
@Serializable
@@ -19,13 +19,11 @@ data class ChatMaterials(
}
}
fun NavGraphBuilder.chatMaterialsRoute(
fun NavGraphBuilder.chatMaterialsScreen(
onBack: () -> Unit
) {
composable<ChatMaterials> {
ChatMaterialsScreen(
onBack = onBack
)
ChatMaterialsRoute(onBack = onBack)
}
}
@@ -66,6 +66,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModel
import com.meloda.app.fast.chatmaterials.ChatMaterialsViewModelImpl
import com.meloda.app.fast.chatmaterials.model.ChatMaterialsScreenState
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.R
import dev.chrisbanes.haze.HazeState
@@ -75,6 +76,22 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
@Composable
fun ChatMaterialsRoute(
onBack: () -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
ChatMaterialsScreen(
screenState = screenState,
onBack = onBack,
onTypeChanged = viewModel::onTypeChanged,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh
)
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
@@ -82,11 +99,14 @@ import org.koin.androidx.compose.koinViewModel
)
@Composable
fun ChatMaterialsScreen(
onBack: () -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
screenState: ChatMaterialsScreenState,
onBack: () -> Unit = {},
onTypeChanged: (String) -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val currentTheme = LocalTheme.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val attachments = screenState.materials
val imageLoader = LocalContext.current.imageLoader
@@ -106,7 +126,7 @@ fun ChatMaterialsScreen(
}
LaunchedEffect(checkedTypeIndex) {
viewModel.onTypeChanged(
onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
@@ -213,7 +233,7 @@ fun ChatMaterialsScreen(
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
@@ -342,7 +362,7 @@ fun ChatMaterialsScreen(
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
onRefresh()
}
}
@@ -44,7 +44,7 @@ interface ConversationsViewModel {
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onPaginationConditionsMet()
fun onDeleteDialogDismissed()
@@ -52,7 +52,7 @@ interface ConversationsViewModel {
fun onRefresh()
fun onConversationItemClick(conversationId: Int)
fun onConversationItemClick()
fun onConversationItemLongClick(conversation: UiConversation)
fun onPinDialogDismissed()
@@ -76,7 +76,7 @@ class ConversationsViewModelImpl(
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override fun onMetPaginationCondition() {
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size }
loadConversations()
}
@@ -113,7 +113,7 @@ class ConversationsViewModelImpl(
loadConversations(offset = 0)
}
override fun onConversationItemClick(conversationId: Int) {
override fun onConversationItemClick() {
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
@@ -225,25 +225,14 @@ class ConversationsViewModelImpl(
conversationsUseCase.getConversations(count = LOAD_COUNT, offset = offset).listenValue { state ->
state.processState(
error = { error ->
when (error) {
is State.Error.ApiError -> {
val (code, message) = error
when (code) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
Unit
}
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
}
State.Error.ConnectionError -> TODO()
State.Error.InternalError -> TODO()
is State.Error.OAuthError -> TODO()
State.Error.Unknown -> TODO()
else -> Unit
}
}
},
success = { response ->
@@ -31,7 +31,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.AppTheme
const val numberOfDots = 3
val dotSize = 6.dp
@@ -298,7 +297,7 @@ fun DotsCollision() {
@Preview(showBackground = true)
@Composable
fun DotsPreview() = AppTheme {
fun DotsPreview() {
Column(
modifier = Modifier
.padding(4.dp)
@@ -6,27 +6,25 @@ import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.presentation.ConversationsScreen
import com.meloda.app.fast.conversations.presentation.ConversationsRoute
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Conversations
fun NavGraphBuilder.conversationsRoute(
fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
ConversationsScreen(
ConversationsRoute(
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onListScrollingUp = onListScrollingUp,
onConversationItemClicked = onConversationItemClicked,
viewModel = viewModel
)
}
@@ -72,6 +72,7 @@ import coil.request.ImageRequest
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.LocalBottomPadding
@@ -89,34 +90,70 @@ import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
ConversationsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onConversationItemClicked = { id ->
onConversationItemClicked(id)
viewModel.onConversationItemClick()
},
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh
)
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ConversationsScreen(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (conversationId: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
val view = LocalView.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val currentTheme = LocalTheme.current
val maxLines by remember {
@@ -129,10 +166,6 @@ fun ConversationsScreen(
val isListScrollingUp = listState.isScrollingUp()
LaunchedEffect(isListScrollingUp) {
onListScrollingUp(isListScrollingUp)
}
val paginationConditionMet by remember {
derivedStateOf {
canPaginate &&
@@ -143,7 +176,7 @@ fun ConversationsScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
onPaginationConditionsMet()
}
}
@@ -213,7 +246,7 @@ fun ConversationsScreen(
) {
DropdownMenuItem(
onClick = {
viewModel.onRefresh()
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
@@ -301,7 +334,7 @@ fun ConversationsScreen(
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
@@ -320,10 +353,10 @@ fun ConversationsScreen(
) {
ConversationsListComposable(
onConversationsClick = { id ->
onNavigateToMessagesHistory(id)
viewModel.onConversationItemClick(id)
onConversationItemClicked(id)
},
onConversationsLongClick = viewModel::onConversationItemLongClick,
onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState,
state = listState,
maxLines = maxLines,
@@ -335,13 +368,13 @@ fun ConversationsScreen(
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = viewModel::onOptionClicked,
onOptionClicked = onOptionClicked,
padding = padding
)
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
onRefresh()
}
}
@@ -362,11 +395,6 @@ fun ConversationsScreen(
}
}
}
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
}
@@ -1,7 +1,6 @@
package com.meloda.app.fast.friends
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.State
@@ -9,30 +8,24 @@ import com.meloda.app.fast.data.api.friends.FriendsUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.friends.model.FriendsScreenState
import com.meloda.app.fast.friends.model.UiFriend
import com.meloda.app.fast.friends.util.asPresentation
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.api.domain.VkUser
import com.meloda.app.fast.network.VkErrorCodes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
// TODO: 13/07/2024, Danil Nikolaev: separate two lists and their pagination
interface FriendsViewModel {
val screenState: StateFlow<FriendsScreenState>
val uiFriends: StateFlow<List<UiFriend>>
val uiOnlineFriends: StateFlow<List<UiFriend>>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onPaginationConditionsMet()
fun onRefresh()
@@ -46,11 +39,6 @@ class FriendsViewModelImpl(
override val screenState = MutableStateFlow(FriendsScreenState.EMPTY)
override val uiFriends = screenState.map { it.friends }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
override val uiOnlineFriends = MutableStateFlow<List<UiFriend>>(emptyList())
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
@@ -64,7 +52,7 @@ class FriendsViewModelImpl(
loadFriends()
}
override fun onMetPaginationCondition() {
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
loadFriends()
}
@@ -130,19 +118,19 @@ class FriendsViewModelImpl(
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
newState.copy(
friends = loadedFriends,
onlineFriends = loadedOnlineFriends
)
}
uiOnlineFriends.setValue { loadedOnlineFriends }
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
friends = newState.friends.plus(loadedFriends),
onlineFriends = newState.onlineFriends.plus(loadedOnlineFriends)
)
}
uiOnlineFriends.setValue { old ->
old.plus(loadedFriends)
}
}
}
)
@@ -164,17 +152,19 @@ class FriendsViewModelImpl(
conversation.asPresentation(useContactNames)
}
val onlineUiFriends = uiOnlineFriends.value.mapNotNull { friend ->
val onlineUiFriends = screenState.value.onlineFriends.mapNotNull { friend ->
uiFriends.find { it.userId == friend.userId }
}
screenState.setValue { old ->
old.copy(friends = uiFriends)
old.copy(
friends = uiFriends,
onlineFriends = onlineUiFriends
)
}
uiOnlineFriends.setValue { onlineUiFriends }
}
companion object {
const val LOAD_COUNT = 30
const val LOAD_COUNT = 60
}
}
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable
data class FriendsScreenState(
val isLoading: Boolean,
val friends: List<UiFriend>,
val onlineFriends: List<UiFriend>,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean
) {
@@ -14,6 +15,7 @@ data class FriendsScreenState(
val EMPTY: FriendsScreenState = FriendsScreenState(
isLoading = true,
friends = emptyList(),
onlineFriends = emptyList(),
isPaginating = false,
isPaginationExhausted = false
)
@@ -6,14 +6,14 @@ import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.friends.FriendsViewModel
import com.meloda.app.fast.friends.FriendsViewModelImpl
import com.meloda.app.fast.friends.presentation.FriendsScreen
import com.meloda.app.fast.friends.presentation.FriendsRoute
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Friends
fun NavGraphBuilder.friendsRoute(
fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit,
navController: NavController
) {
@@ -21,7 +21,7 @@ fun NavGraphBuilder.friendsRoute(
val viewModel: FriendsViewModel =
it.sharedViewModel<FriendsViewModelImpl>(navController = navController)
FriendsScreen(
FriendsRoute(
onError = onError,
viewModel = viewModel
)
@@ -58,6 +58,7 @@ import com.meloda.app.fast.designsystem.components.FullScreenLoader
import com.meloda.app.fast.designsystem.components.NoItemsView
import com.meloda.app.fast.friends.FriendsViewModel
import com.meloda.app.fast.friends.FriendsViewModelImpl
import com.meloda.app.fast.friends.model.FriendsScreenState
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.ui.ErrorView
import dev.chrisbanes.haze.haze
@@ -67,30 +68,53 @@ import dev.chrisbanes.haze.materials.HazeMaterials
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun FriendsScreen(
fun FriendsRoute(
onError: (BaseError) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
) {
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val friends by viewModel.uiFriends.collectAsStateWithLifecycle()
val onlineFriends by viewModel.uiOnlineFriends.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
FriendsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh
)
}
// TODO: 13/07/2024, Danil Nikolaev: support for online
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun FriendsScreen(
screenState: FriendsScreenState = FriendsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val currentTheme = LocalTheme.current
val maxLines by remember {
@@ -111,7 +135,7 @@ fun FriendsScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
onPaginationConditionsMet()
}
}
@@ -223,11 +247,11 @@ fun FriendsScreen(
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
screenState.isLoading && friends.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -259,8 +283,7 @@ fun FriendsScreen(
.padding(bottom = padding.calculateBottomPadding())
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
val friendsToDisplay = if (index == 0) friends
else onlineFriends
val friendsToDisplay = screenState.friends
FriendsList(
modifier = if (currentTheme.usingBlur) {
@@ -289,7 +312,7 @@ fun FriendsScreen(
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
onRefresh()
}
}
@@ -1,9 +1,13 @@
package com.meloda.app.fast.languagepicker
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.languagepicker.model.LanguagePickerScreenState
import com.meloda.app.fast.languagepicker.model.SelectableLanguage
import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,25 +16,54 @@ import kotlinx.coroutines.flow.StateFlow
interface LanguagePickerViewModel {
val screenState: StateFlow<LanguagePickerScreenState>
fun setLanguages(languages: List<SelectableLanguage>)
fun onLanguagePicked(newLanguage: SelectableLanguage)
fun onApplyButtonClicked()
fun updateCurrentLocale(locale: String)
}
class LanguagePickerViewModelImpl : LanguagePickerViewModel, ViewModel() {
class LanguagePickerViewModelImpl(
private val resources: Resources
) : LanguagePickerViewModel, ViewModel() {
override val screenState = MutableStateFlow(
LanguagePickerScreenState(
languages = emptyList(),
currentLanguage = AppCompatDelegate.getApplicationLocales().toLanguageTags()
)
)
override val screenState = MutableStateFlow(LanguagePickerScreenState.EMPTY)
init {
val languages = listOf(
Triple(
"",
UiText.Resource(R.string.language_key_system),
UiText.Resource(R.string.language_system)
),
Triple(
"en-US",
UiText.Resource(R.string.language_key_english),
UiText.Resource(R.string.language_english),
),
Triple(
"ru-RU",
UiText.Resource(R.string.language_key_russian),
UiText.Resource(R.string.language_russian)
),
Triple(
"uk-UA",
UiText.Resource(R.string.language_key_ukrainian),
UiText.Resource(R.string.language_ukrainian)
)
).map { (key, language, local) ->
Triple(
key,
language.parseString(resources).orEmpty(),
local.parseString(resources).orEmpty()
)
}.map { (key, language, local) ->
SelectableLanguage(
local = local,
language = language,
key = key,
isSelected = key == AppCompatDelegate.getApplicationLocales().toLanguageTags()
)
}
override fun setLanguages(languages: List<SelectableLanguage>) {
screenState.setValue { old -> old.copy(languages = languages) }
}
@@ -6,4 +6,12 @@ import androidx.compose.runtime.Immutable
data class LanguagePickerScreenState(
val languages: List<SelectableLanguage>,
val currentLanguage: String?,
)
) {
companion object {
val EMPTY: LanguagePickerScreenState = LanguagePickerScreenState(
languages = emptyList(),
currentLanguage = null
)
}
}
@@ -0,0 +1,22 @@
package com.meloda.app.fast.languagepicker.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.languagepicker.presentation.LanguagePickerRoute
import kotlinx.serialization.Serializable
@Serializable
object LanguagePicker
fun NavGraphBuilder.languagePickerScreen(
onBack: () -> Unit,
) {
composable<LanguagePicker> {
LanguagePickerRoute(onBack = onBack)
}
}
fun NavController.navigateToLanguagePicker() {
this.navigate(LanguagePicker)
}
@@ -1,78 +0,0 @@
package com.meloda.app.fast.languagepicker.navigation
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.languagepicker.LanguagePickerViewModel
import com.meloda.app.fast.languagepicker.LanguagePickerViewModelImpl
import com.meloda.app.fast.languagepicker.model.SelectableLanguage
import com.meloda.app.fast.languagepicker.presentation.LanguagePickerScreen
import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@Serializable
object LanguagePicker
private fun getLanguages(resources: Resources): List<SelectableLanguage> {
return listOf(
Triple(
"",
UiText.Resource(R.string.language_key_system),
UiText.Resource(R.string.language_system)
),
Triple(
"en-US",
UiText.Resource(R.string.language_key_english),
UiText.Resource(R.string.language_english),
),
Triple(
"ru-RU",
UiText.Resource(R.string.language_key_russian),
UiText.Resource(R.string.language_russian)
),
Triple(
"uk-UA",
UiText.Resource(R.string.language_key_ukrainian),
UiText.Resource(R.string.language_ukrainian)
)
).map { (key, language, local) ->
Triple(
key,
language.parseString(resources).orEmpty(),
local.parseString(resources).orEmpty()
)
}.map { (key, language, local) ->
SelectableLanguage(
local = local,
language = language,
key = key,
isSelected = key == AppCompatDelegate.getApplicationLocales().toLanguageTags()
)
}
}
fun NavGraphBuilder.languagePickerRoute(
onBack: () -> Unit,
) {
composable<LanguagePicker> {
val languages = getLanguages(LocalContext.current.resources)
val viewModel: LanguagePickerViewModel = koinViewModel<LanguagePickerViewModelImpl>()
viewModel.setLanguages(languages)
LanguagePickerScreen(
onBack = onBack,
viewModel = viewModel
)
}
}
fun NavController.navigateToLanguagePicker() {
this.navigate(LanguagePicker)
}
@@ -56,31 +56,46 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meloda.app.fast.languagepicker.LanguagePickerViewModel
import com.meloda.app.fast.languagepicker.LanguagePickerViewModelImpl
import com.meloda.app.fast.languagepicker.model.LanguagePickerScreenState
import com.meloda.app.fast.languagepicker.model.SelectableLanguage
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LanguagePickerScreen(
fun LanguagePickerRoute(
onBack: () -> Unit,
viewModel: LanguagePickerViewModel = koinViewModel<LanguagePickerViewModelImpl>()
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val languages = screenState.languages
LifecycleResumeEffect(true) {
viewModel.updateCurrentLocale(AppCompatDelegate.getApplicationLocales().toLanguageTags())
onPauseOrDispose {}
}
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
LanguagePickerScreen(
screenState = screenState,
onBack = onBack,
onLanguagePicked = viewModel::onLanguagePicked,
onApplyButtonClicked = viewModel::onApplyButtonClicked
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LanguagePickerScreen(
screenState: LanguagePickerScreenState = LanguagePickerScreenState.EMPTY,
onBack: () -> Unit = {},
onLanguagePicked: (SelectableLanguage) -> Unit = {},
onApplyButtonClicked: () -> Unit = {}
) {
val context = LocalContext.current
val isButtonEnabled by remember(screenState) {
derivedStateOf {
screenState.currentLanguage != null &&
languages.isNotEmpty() &&
languages.find(SelectableLanguage::isSelected)?.key != screenState.currentLanguage
screenState.languages.isNotEmpty() &&
screenState.languages.find(SelectableLanguage::isSelected)?.key != screenState.currentLanguage
}
}
@@ -165,10 +180,13 @@ fun LanguagePickerScreen(
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(screenState.languages.toList()) { item ->
items(
items = screenState.languages.toList(),
key = SelectableLanguage::key
) { item ->
LanguageItem(
item = item,
onClick = viewModel::onLanguagePicked
onClick = onLanguagePicked
)
}
@@ -183,7 +201,7 @@ fun LanguagePickerScreen(
}
Button(
onClick = viewModel::onApplyButtonClicked,
onClick = onApplyButtonClicked,
enabled = isButtonEnabled,
modifier = Modifier
.fillMaxWidth()
@@ -4,6 +4,7 @@ import android.content.SharedPreferences
import android.content.res.Resources
import android.util.Log
import androidx.core.content.edit
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.conena.nanokt.collections.indexOfFirstOrNull
@@ -20,11 +21,10 @@ import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.messageshistory.model.ActionMode
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
import com.meloda.app.fast.messageshistory.model.MessagesHistoryScreenState
import com.meloda.app.fast.messageshistory.navigation.MessagesHistory
import com.meloda.app.fast.messageshistory.util.asPresentation
import com.meloda.app.fast.messageshistory.util.extractAvatar
import com.meloda.app.fast.messageshistory.util.extractShowName
import com.meloda.app.fast.messageshistory.util.extractTitle
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.LongPollEvent
@@ -48,17 +48,14 @@ interface MessagesHistoryViewModel {
val canPaginate: StateFlow<Boolean>
fun onRefresh()
fun onAttachmentButtonClicked()
fun onInputChanged(newText: String)
fun onMessageInputChanged(newText: String)
fun onEmojiButtonClicked()
fun onActionButtonClicked()
fun onTopAppBarMenuClicked(id: Int)
fun setArguments(arguments: MessagesHistoryArguments)
fun onMetPaginationCondition()
fun onShowDatesClicked(showDates: Boolean)
fun onShowNamesClicked(showNames: Boolean)
fun onEnableAnimationsClicked(enableAnimations: Boolean)
fun onPaginationConditionsMet()
fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean)
}
class MessagesHistoryViewModelImpl(
@@ -67,6 +64,7 @@ class MessagesHistoryViewModelImpl(
private val preferences: SharedPreferences,
private val resources: Resources,
updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() {
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
@@ -85,17 +83,26 @@ class MessagesHistoryViewModelImpl(
private val sendingMessages: MutableList<VkMessage> = mutableListOf()
init {
val arguments = MessagesHistory.from(savedStateHandle).arguments
screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) }
loadMessagesHistory()
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingEvent)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingEvent)
}
override fun onRefresh() {
loadMessagesHistory(offset = 0)
}
override fun onAttachmentButtonClicked() {
}
override fun onInputChanged(newText: String) {
override fun onMessageInputChanged(newText: String) {
screenState.setValue { old ->
old.copy(
message = newText,
@@ -131,58 +138,12 @@ class MessagesHistoryViewModelImpl(
}
}
override fun onTopAppBarMenuClicked(id: Int) {
when (id) {
0 -> loadMessagesHistory(0)
else -> Unit
}
}
override fun setArguments(arguments: MessagesHistoryArguments) {
if (arguments.conversationId == screenState.value.conversationId) return
screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) }
loadMessagesHistory()
}
override fun onMetPaginationCondition() {
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.messages.size }
loadMessagesHistory()
}
override fun onShowDatesClicked(showDates: Boolean) {
preferences.edit { putBoolean(SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES, showDates) }
screenState.setValue { old ->
old.copy(
messages = old.messages.map { message ->
message.copy(showDate = showDates)
}
)
}
}
override fun onShowNamesClicked(showNames: Boolean) {
preferences.edit { putBoolean(SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES, showNames) }
screenState.setValue { old ->
old.copy(
messages = old.messages.map { message ->
message.copy(
showName = if (showNames) {
val index = messages.value.indexOfFirst { it.id == message.id }
val domainMessage = messages.value[index]
val prevMessage = messages.value.getOrNull(index + 1)
domainMessage.extractShowName(prevMessage)
} else false
)
}
)
}
}
override fun onEnableAnimationsClicked(enableAnimations: Boolean) {
override fun onToggleAnimationsDropdownItemClicked(enableAnimations: Boolean) {
preferences.edit {
putBoolean(
SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES,
@@ -285,15 +246,10 @@ class MessagesHistoryViewModelImpl(
messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations)
val showDate =
preferences.getBoolean(SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES, false)
val showName =
preferences.getBoolean(SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES, false)
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
showDate = showDate,
showName = showName,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
)
@@ -0,0 +1,43 @@
package com.meloda.app.fast.messageshistory.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.common.customNavType
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
import com.meloda.app.fast.messageshistory.presentation.MessagesHistoryRoute
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
import kotlin.reflect.typeOf
@Serializable
data class MessagesHistory(val arguments: MessagesHistoryArguments) {
companion object {
val typeMap =
mapOf(typeOf<MessagesHistoryArguments>() to customNavType<MessagesHistoryArguments>())
fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<MessagesHistory>(typeMap)
}
}
fun NavGraphBuilder.messagesHistoryScreen(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
) {
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
MessagesHistoryRoute(
onError = onError,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
)
}
}
fun NavController.navigateToMessagesHistory(conversationId: Int) {
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
}
@@ -1,64 +0,0 @@
package com.meloda.app.fast.messageshistory.navigation
import android.os.Bundle
import androidx.core.os.BundleCompat
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModel
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
import com.meloda.app.fast.messageshistory.presentation.MessagesHistoryScreen
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.koin.androidx.compose.koinViewModel
import kotlin.reflect.typeOf
@Serializable
data class MessagesHistory(val arguments: MessagesHistoryArguments)
val MessagesHistoryNavType = object : NavType<MessagesHistoryArguments>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): MessagesHistoryArguments? =
BundleCompat.getParcelable(bundle, key, MessagesHistoryArguments::class.java)
override fun parseValue(value: String): MessagesHistoryArguments = Json.decodeFromString(value)
override fun serializeAsValue(value: MessagesHistoryArguments): String =
Json.encodeToString(value)
override fun put(bundle: Bundle, key: String, value: MessagesHistoryArguments) {
bundle.putParcelable(key, value)
}
override val name: String = "MessagesHistoryArguments"
}
fun NavGraphBuilder.messagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatAttachments: (peerId: Int, conversationMessageId: Int) -> Unit
) {
composable<MessagesHistory>(
typeMap = mapOf(typeOf<MessagesHistoryArguments>() to MessagesHistoryNavType)
) { backStackEntry ->
val arguments: MessagesHistory = backStackEntry.toRoute()
val viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
viewModel.setArguments(arguments.arguments)
MessagesHistoryScreen(
onError = onError,
onBack = onBack,
onNavigateToChatMaterials = onNavigateToChatAttachments,
viewModel = viewModel
)
}
}
fun NavController.navigateToMessagesHistory(conversationId: Int) {
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
}
@@ -71,6 +71,7 @@ import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModel
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
import com.meloda.app.fast.messageshistory.model.ActionMode
import com.meloda.app.fast.messageshistory.model.MessagesHistoryScreenState
import com.meloda.app.fast.model.BaseError
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
@@ -81,6 +82,32 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import com.meloda.app.fast.designsystem.R as UiR
@Composable
fun MessagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
MessagesHistoryScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onToggleAnimationsDropdownItemClicked = viewModel::onToggleAnimationsDropdownItemClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
@@ -88,21 +115,23 @@ import com.meloda.app.fast.designsystem.R as UiR
)
@Composable
fun MessagesHistoryScreen(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Int, conversationMessageId: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefreshDropdownItemClicked: () -> Unit = {},
onToggleAnimationsDropdownItemClicked: (Boolean) -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (String) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val preferences: SharedPreferences = koinInject()
val currentTheme = LocalTheme.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val messages = screenState.messages
val listState = rememberLazyListState()
val paginationConditionMet by remember {
@@ -115,7 +144,7 @@ fun MessagesHistoryScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
onPaginationConditionsMet()
}
}
@@ -125,24 +154,6 @@ fun MessagesHistoryScreen(
val hazeSate = remember { HazeState() }
var datesShown by remember {
mutableStateOf(
preferences.getBoolean(
SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES,
false
)
)
}
var namesShown by remember {
mutableStateOf(
preferences.getBoolean(
SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES,
false
)
)
}
var animationsEnabled by remember {
mutableStateOf(
preferences.getBoolean(
@@ -217,7 +228,8 @@ fun MessagesHistoryScreen(
dropDownMenuExpanded = false
// TODO: 11/07/2024, Danil Nikolaev: to VM
onNavigateToChatMaterials(
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.first().conversationMessageId
)
@@ -228,7 +240,7 @@ fun MessagesHistoryScreen(
)
DropdownMenuItem(
onClick = {
viewModel.onTopAppBarMenuClicked(0)
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
@@ -248,27 +260,6 @@ fun MessagesHistoryScreen(
)
) {
HorizontalDivider()
DropdownMenuItem(
text = {
Text(text = if (datesShown) "Hide dates" else "Show dates")
},
onClick = {
dropDownMenuExpanded = false
datesShown = !datesShown
viewModel.onShowDatesClicked(datesShown)
}
)
DropdownMenuItem(
text = {
Text(text = if (namesShown) "Hide names" else "Show names")
},
onClick = {
dropDownMenuExpanded = false
namesShown = !namesShown
viewModel.onShowNamesClicked(namesShown)
}
)
DropdownMenuItem(
text = {
@@ -277,14 +268,14 @@ fun MessagesHistoryScreen(
onClick = {
dropDownMenuExpanded = false
animationsEnabled = !animationsEnabled
viewModel.onEnableAnimationsClicked(animationsEnabled)
onToggleAnimationsDropdownItemClicked(animationsEnabled)
}
)
}
}
}
)
if (screenState.isLoading && messages.isNotEmpty()) {
if (screenState.isLoading && screenState.messages.isNotEmpty()) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
@@ -300,7 +291,7 @@ fun MessagesHistoryScreen(
MessagesList(
hazeState = hazeSate,
listState = listState,
immutableMessages = ImmutableList.copyOf(messages),
immutableMessages = ImmutableList.copyOf(screenState.messages),
isPaginating = screenState.isPaginating,
enableAnimations = animationsEnabled
)
@@ -372,7 +363,7 @@ fun MessagesHistoryScreen(
TextField(
modifier = Modifier.weight(1f),
value = screenState.message,
onValueChange = viewModel::onInputChanged,
onValueChange = onMessageInputChanged,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
@@ -382,7 +373,7 @@ fun MessagesHistoryScreen(
placeholder = { Text(text = stringResource(id = UiR.string.message_input_hint)) }
)
IconButton(onClick = viewModel::onAttachmentButtonClicked) {
IconButton(onClick = onAttachmentButtonClicked) {
Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button",
@@ -414,7 +405,7 @@ fun MessagesHistoryScreen(
}
}
} else {
viewModel.onActionButtonClicked()
onActionButtonClicked()
}
},
modifier = Modifier.rotate(rotation.value)
@@ -445,7 +436,7 @@ fun MessagesHistoryScreen(
}
}
if (screenState.isLoading && messages.isEmpty()) {
if (screenState.isLoading && screenState.messages.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
@@ -5,14 +5,18 @@ import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.users.UsersUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.network.VkErrorCodes
import com.meloda.app.fast.profile.model.ProfileScreenState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
interface ProfileViewModel {
val screenState: StateFlow<ProfileScreenState>
val baseError: StateFlow<BaseError?>
}
class ProfileViewModelImpl(
@@ -20,6 +24,7 @@ class ProfileViewModelImpl(
) : ViewModel(), ProfileViewModel {
override val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
init {
getLocalAccountInfo()
@@ -30,6 +35,16 @@ class ProfileViewModelImpl(
.listenValue { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
screenState.setValue { old ->
old.copy(
avatarUrl = null,
@@ -7,24 +7,24 @@ import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.profile.ProfileViewModel
import com.meloda.app.fast.profile.ProfileViewModelImpl
import com.meloda.app.fast.profile.presentation.ProfileScreen
import com.meloda.app.fast.profile.presentation.ProfileRoute
import kotlinx.serialization.Serializable
@Serializable
object Profile
fun NavGraphBuilder.profileRoute(
fun NavGraphBuilder.profileScreen(
onError: (BaseError) -> Unit,
onNavigateToSettings: () -> Unit,
onSettingsButtonClicked: () -> Unit,
navController: NavController
) {
composable<Profile> {
val viewModel: ProfileViewModel =
it.sharedViewModel<ProfileViewModelImpl>(navController = navController)
ProfileScreen(
ProfileRoute(
onError = onError,
onNavigateToSettings = onNavigateToSettings,
onSettingsButtonClicked = onSettingsButtonClicked,
viewModel = viewModel
)
}
@@ -1,6 +1,5 @@
package com.meloda.app.fast.profile.presentation
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -38,27 +37,42 @@ import coil.compose.AsyncImage
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.profile.ProfileViewModel
import com.meloda.app.fast.profile.ProfileViewModelImpl
import com.meloda.app.fast.profile.model.ProfileScreenState
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
fun ProfileRoute(
onError: (BaseError) -> Unit,
onNavigateToSettings: () -> Unit,
onSettingsButtonClicked: () -> Unit,
viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
Log.d("ProfileScreen", "isLoading: ${screenState.isLoading}")
ProfileScreen(
screenState = screenState,
baseError = baseError,
onSettingsButtonClicked = onSettingsButtonClicked
)
}
// TODO: 13/07/2024, Danil Nikolaev: handle expired session
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
screenState: ProfileScreenState = ProfileScreenState.EMPTY,
baseError: BaseError? = null,
onSettingsButtonClicked: () -> Unit = {},
) {
Scaffold(
topBar = {
TopAppBar(
title = {},
actions = {
IconButton(onClick = onNavigateToSettings) {
IconButton(onClick = onSettingsButtonClicked) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = null
@@ -12,6 +12,7 @@ import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.isDebugSettingsShown
import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.settings.model.SettingsItem
@@ -44,13 +45,14 @@ interface SettingsViewModel {
fun onSettingsItemLongClicked(key: String)
fun onSettingsItemChanged(key: String, newValue: Any?)
fun onHapticsUsed()
fun onHapticPerformed()
fun onNotificationsPermissionRequested()
}
class SettingsViewModelImpl(
private val accountsRepository: AccountsRepository,
private val userSettings: UserSettings
) : SettingsViewModel, ViewModel() {
override val screenState = MutableStateFlow(SettingsScreenState.EMPTY)
@@ -159,6 +161,7 @@ class SettingsViewModelImpl(
when (key) {
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> {
val isEnabled = (newValue as? Boolean) == true
userSettings.setLongPollBackground(isEnabled)
if (isEnabled) {
// TODO: 26/11/2023, Danil Nikolaev: implement
@@ -169,10 +172,41 @@ class SettingsViewModelImpl(
}
}
}
SettingsKeys.KEY_APPEARANCE_MULTILINE -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useMultiline(isUsing)
}
SettingsKeys.KEY_APPEARANCE_AMOLED_THEME -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useAmoledThemeChanged(isUsing)
}
SettingsKeys.KEY_USE_DYNAMIC_COLORS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useDynamicColorsChanged(isUsing)
}
SettingsKeys.KEY_APPEARANCE_BLUR -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useBlurChanged(isUsing)
}
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.setOnline(isUsing)
}
SettingsKeys.KEY_USE_CONTACT_NAMES -> {
val isUsing = newValue as? Boolean ?: false
userSettings.onUseContactNamesChanged(isUsing)
}
}
}
override fun onHapticsUsed() {
override fun onHapticPerformed() {
screenState.setValue { old -> old.copy(useHaptics = HapticType.None) }
}
@@ -0,0 +1,32 @@
package com.meloda.app.fast.settings.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.settings.model.OnSettingsClickListener
import com.meloda.app.fast.settings.presentation.SettingsRoute
import com.meloda.app.fast.settings.presentation.SettingsScreen
import kotlinx.serialization.Serializable
@Serializable
object Settings
fun NavGraphBuilder.settingsScreen(
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit
) {
composable<Settings> {
SettingsRoute(
onBack = onBack,
onLogOutButtonClicked = onLogOutButtonClicked,
onLanguageItemClicked = onLanguageItemClicked
)
}
}
fun NavController.navigateToSettings() {
this.navigate(Settings)
}
@@ -1,30 +0,0 @@
package com.meloda.app.fast.settings.presentation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Settings
fun NavGraphBuilder.settingsRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToAuth: () -> Unit,
onNavigateToLanguagePicker: () -> Unit
) {
composable<Settings> {
SettingsScreen(
onError = onError,
onBack = onBack,
onNavigateToAuth = onNavigateToAuth,
onNavigateToLanguagePicker = onNavigateToLanguagePicker
)
}
}
fun NavController.navigateToSettings() {
this.navigate(Settings)
}
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -23,7 +24,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -41,13 +41,9 @@ import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.isUsingDarkMode
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.settings.HapticType
import com.meloda.app.fast.settings.SettingsViewModel
import com.meloda.app.fast.settings.SettingsViewModelImpl
import com.meloda.app.fast.settings.model.OnSettingsChangeListener
import com.meloda.app.fast.settings.model.OnSettingsClickListener
import com.meloda.app.fast.settings.model.OnSettingsLongClickListener
import com.meloda.app.fast.settings.model.SettingsItem
import com.meloda.app.fast.settings.model.SettingsScreenState
import com.meloda.app.fast.settings.presentation.items.EditTextSettingsItem
@@ -64,102 +60,94 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun SettingsScreen(
onError: (BaseError) -> Unit,
fun SettingsRoute(
onBack: () -> Unit,
onNavigateToAuth: () -> Unit,
onNavigateToLanguagePicker: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
viewModel: SettingsViewModel = koinViewModel<SettingsViewModelImpl>()
) {
val context = LocalContext.current
val view = LocalView.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val hapticType = screenState.useHaptics
if (hapticType != HapticType.None) {
view.performHapticFeedback(hapticType.getHaptic())
viewModel.onHapticsUsed()
}
val userSettings: UserSettings = koinInject()
LaunchedEffect(true) {
userSettings.enableDebugSettings(screenState.showDebugOptions)
}
SettingsScreen(screenState = screenState,
onBack = onBack,
onHapticPerformed = viewModel::onHapticPerformed,
onSettingsItemClicked = { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onLanguageItemClicked()
}
else -> viewModel.onSettingsItemClicked(key)
}
},
onSettingsItemLongClicked = viewModel::onSettingsItemLongClicked,
onSettingsItemValueChanged = { key, newValue ->
when (key) {
SettingsKeys.KEY_APPEARANCE_DARK_THEME -> {
val newMode = newValue as? Int ?: 0
AppCompatDelegate.setDefaultNightMode(newMode)
val isUsing = context.getSystemService<PowerManager>()?.let { manager ->
isUsingDarkMode(
context.resources,
manager
)
} ?: false
userSettings.useDarkThemeChanged(isUsing)
}
else -> viewModel.onSettingsItemChanged(key, newValue)
}
}
)
HandlePopups(
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
logoutPositiveClick = {
viewModel.onLogOutAlertPositiveClick()
onLogOutButtonClicked()
},
logoutDismissed = viewModel::onLogOutAlertDismissed,
longPollingPositiveClick = viewModel::onLongPollingAlertPositiveClicked,
longPollingDismissed = viewModel::onLongPollingAlertDismissed,
screenState = screenState
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun SettingsScreen(
screenState: SettingsScreenState = SettingsScreenState.EMPTY,
onBack: () -> Unit = {},
onHapticPerformed: () -> Unit = {},
onSettingsItemClicked: (key: String) -> Unit = {},
onSettingsItemLongClicked: (key: String) -> Unit = {},
onSettingsItemValueChanged: (key: String, newValue: Any?) -> Unit = { _, _ -> }
) {
val view = LocalView.current
val hapticType = screenState.useHaptics
LaunchedEffect(hapticType) {
if (hapticType != HapticType.None) {
view.performHapticFeedback(hapticType.getHaptic())
onHapticPerformed()
}
}
val currentTheme = LocalTheme.current
val settingsList = screenState.settings
val clickListener = OnSettingsClickListener { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onNavigateToLanguagePicker()
}
else -> viewModel.onSettingsItemClicked(key)
}
}
val longClickListener = OnSettingsLongClickListener(viewModel::onSettingsItemLongClicked)
val changeListener = OnSettingsChangeListener { key, newValue ->
when (key) {
SettingsKeys.KEY_APPEARANCE_MULTILINE -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useMultiline(isUsing)
}
SettingsKeys.KEY_APPEARANCE_DARK_THEME -> {
val newMode = newValue as? Int ?: return@OnSettingsChangeListener
AppCompatDelegate.setDefaultNightMode(newMode)
val isUsing = context.getSystemService<PowerManager>()?.let { manager ->
isUsingDarkMode(
context.resources,
manager
)
} ?: false
userSettings.useDarkThemeChanged(isUsing)
}
SettingsKeys.KEY_APPEARANCE_AMOLED_THEME -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useAmoledThemeChanged(isUsing)
}
SettingsKeys.KEY_USE_DYNAMIC_COLORS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useDynamicColorsChanged(isUsing)
}
SettingsKeys.KEY_APPEARANCE_BLUR -> {
val isUsing = newValue as? Boolean ?: false
userSettings.useBlurChanged(isUsing)
}
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> {
val isUsing = newValue as? Boolean ?: false
userSettings.setLongPollBackground(isUsing)
}
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
val isUsing = newValue as? Boolean ?: false
userSettings.setOnline(isUsing)
}
SettingsKeys.KEY_USE_CONTACT_NAMES -> {
val isUsing = newValue as? Boolean ?: false
userSettings.onUseContactNamesChanged(isUsing)
}
else -> viewModel.onSettingsItemChanged(key, newValue)
}
}
val hazeState = remember { HazeState() }
@@ -167,19 +155,16 @@ fun SettingsScreen(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
val title = @Composable { Text(text = stringResource(id = UiR.string.title_settings)) }
val navigationIcon = @Composable {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_arrow_back_24),
contentDescription = "Back button"
)
}
}
TopAppBar(
title = title,
navigationIcon = navigationIcon,
title = { Text(text = stringResource(id = UiR.string.title_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_arrow_back_24),
contentDescription = "Back button"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(
alpha = if (currentTheme.usingBlur) 0f else 1f
@@ -215,33 +200,23 @@ fun SettingsScreen(
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding())
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
count = settingsList.size,
// key = { index ->
// val item = settingsList[index]
// requireNotNull(item.title ?: item.summary)
// },
contentType = { index ->
when (settingsList[index]) {
is SettingsItem.ListItem -> "listitem"
items = screenState.settings,
key = { item -> item.key },
contentType = { item ->
when (item) {
is SettingsItem.ListItem -> "list_item"
is SettingsItem.Switch -> "switch"
is SettingsItem.TextField -> "textfield"
is SettingsItem.TextField -> "text_field"
is SettingsItem.Title -> "title"
is SettingsItem.TitleSummary -> "titlesummary"
is SettingsItem.TitleSummary -> "title_summary"
}
}
) { index ->
val needToShowSpacer by remember {
derivedStateOf {
index == 0
}
}
if (needToShowSpacer) {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
when (val item = settingsList[index]) {
) { item ->
when (item) {
is SettingsItem.Title -> TitleSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
@@ -251,67 +226,47 @@ fun SettingsScreen(
is SettingsItem.TitleSummary -> TitleSummarySettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
is SettingsItem.Switch -> SwitchSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsChangeListener = changeListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
onSettingsChangeListener = onSettingsItemValueChanged,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
is SettingsItem.TextField -> EditTextSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsChangeListener = changeListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
onSettingsChangeListener = onSettingsItemValueChanged,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
is SettingsItem.ListItem -> ListSettingsItem(
item = item,
isMultiline = currentTheme.multiline,
onSettingsClickListener = clickListener,
onSettingsLongClickListener = longClickListener,
onSettingsChangeListener = changeListener,
onSettingsClickListener = onSettingsItemClicked,
onSettingsLongClickListener = onSettingsItemLongClicked,
onSettingsChangeListener = onSettingsItemValueChanged,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
}
}
val showBottomNavigationBarsSpacer by remember {
derivedStateOf {
index == settingsList.size - 1
}
}
if (showBottomNavigationBarsSpacer) {
Spacer(modifier = Modifier.navigationBarsPadding())
}
item {
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
HandlePopups(
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
logoutPositiveClick = {
viewModel.onLogOutAlertPositiveClick()
onNavigateToAuth()
},
logoutDismissed = viewModel::onLogOutAlertDismissed,
longPollingPositiveClick = viewModel::onLongPollingAlertPositiveClicked,
longPollingDismissed = viewModel::onLongPollingAlertDismissed,
screenState = screenState
)
}
// TODO: 12/04/2024, Danil Nikolaev: rewrite to UiAction
@Composable
fun HandlePopups(
performCrashPositiveClick: () -> Unit,