ability to import/export auth data and some refactoring

This commit is contained in:
2025-07-09 17:29:51 +03:00
parent d2aaac68e2
commit 9e6b079bf6
21 changed files with 721 additions and 437 deletions
@@ -3,6 +3,7 @@ package dev.meloda.fast.presentation
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -47,6 +48,7 @@ fun RootScreen(
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val activity = LocalActivity.current
val context = LocalContext.current val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle() val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle() val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
@@ -129,6 +131,7 @@ fun RootScreen(
viewModel.onUserAuthenticated() viewModel.onUserAuthenticated()
navController.navigateToMain() navController.navigateToMain()
}, },
onNavigateToSettings = navController::navigateToSettings,
navController = navController navController = navController
) )
@@ -162,7 +165,15 @@ fun RootScreen(
settingsScreen( settingsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) }, onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker onLanguageItemClicked = navController::navigateToLanguagePicker,
onRestartRequired = {
activity?.let {
val intent = Intent(activity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
activity.startActivity(intent)
activity.finish()
}
}
) )
languagePickerScreen(onBack = navController::navigateUp) languagePickerScreen(onBack = navController::navigateUp)
@@ -50,6 +50,9 @@ object SettingsKeys {
const val DEFAULT_ENABLE_HAPTIC = true const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level" const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 3 const val DEFAULT_NETWORK_LOG_LEVEL = 3
const val KEY_DEBUG_IMPORT_AUTH_DATA = "debug_import_auth_data"
const val KEY_DEBUG_EXPORT_AUTH_DATA = "debug_export_auth_data"
const val KEY_USE_SYSTEM_FONT = "use_system_font" const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false const val DEFAULT_USE_SYSTEM_FONT = false
const val KEY_MORE_ANIMATIONS = "more_animations" const val KEY_MORE_ANIMATIONS = "more_animations"
@@ -23,6 +23,7 @@ object AuthGraph
fun NavGraphBuilder.authNavGraph( fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController navController: NavController
) { ) {
navigation<AuthGraph>(startDestination = Login) { navigation<AuthGraph>(startDestination = Login) {
@@ -54,6 +55,7 @@ fun NavGraphBuilder.authNavGraph(
) )
) )
}, },
onNavigateToSettings = onNavigateToSettings,
navController = navController navController = navController
) )
@@ -35,49 +35,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
interface LoginViewModel { class LoginViewModel(
val screenState: StateFlow<LoginScreenState>
val loginDialog: StateFlow<LoginDialog?>
val validationArguments: StateFlow<LoginValidationArguments?>
val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
val isNeedToClearCaptchaCode: StateFlow<Boolean>
val isNeedToClearValidationCode: StateFlow<Boolean>
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
fun onDialogDismissed(dialog: LoginDialog)
fun onBackPressed()
fun onPasswordVisibilityButtonClicked()
fun onLoginInputChanged(newLogin: String)
fun onPasswordInputChanged(newPassword: String)
fun onSignInButtonClicked()
fun onLogoClicked()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToValidation()
fun onValidationCodeReceived(code: String?)
fun onValidationCodeCleared()
fun onCaptchaCodeReceived(code: String?)
fun onCaptchaCodeCleared()
}
class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase, private val oAuthUseCase: OAuthUseCase,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val loadUserByIdUseCase: LoadUserByIdUseCase, private val loadUserByIdUseCase: LoadUserByIdUseCase,
@@ -85,18 +49,30 @@ class LoginViewModelImpl(
private val loginValidator: LoginValidator, private val loginValidator: LoginValidator,
private val longPollController: LongPollController, private val longPollController: LongPollController,
private val userSettings: UserSettings private val userSettings: UserSettings
) : ViewModel(), LoginViewModel { ) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
override val screenState = MutableStateFlow(LoginScreenState.EMPTY) private val _loginDialog = MutableStateFlow<LoginDialog?>(null)
override val loginDialog = MutableStateFlow<LoginDialog?>(null) val loginDialog = _loginDialog.asStateFlow()
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null) private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null) val validationArguments = _validationArguments.asStateFlow()
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
override val isNeedToClearCaptchaCode = MutableStateFlow(false) private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val isNeedToClearValidationCode = MutableStateFlow(false) val captchaArguments = _captchaArguments.asStateFlow()
private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
val userBannedArguments = _userBannedArguments.asStateFlow()
private val _isNeedToOpenMain = MutableStateFlow(false)
val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow()
private val _isNeedToClearCaptchaCode = MutableStateFlow(false)
val isNeedToClearCaptchaCode = _isNeedToClearCaptchaCode.asStateFlow()
private val _isNeedToClearValidationCode = MutableStateFlow(false)
val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow()
private val validationState: StateFlow<List<LoginValidationResult>> = private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate) screenState.map(loginValidator::validate)
@@ -120,7 +96,7 @@ class LoginViewModelImpl(
} }
} }
override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) { fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
onDialogDismissed(dialog) onDialogDismissed(dialog)
when (dialog) { when (dialog) {
@@ -128,20 +104,24 @@ class LoginViewModelImpl(
} }
} }
override fun onDialogDismissed(dialog: LoginDialog) { fun onDialogDismissed(dialog: LoginDialog) {
loginDialog.setValue { null } when (dialog) {
is LoginDialog.Error -> Unit
}
_loginDialog.setValue { null }
} }
override fun onBackPressed() { fun onBackPressed() {
screenState.setValue { old -> old.copy(showLogo = true) } _screenState.setValue { old -> old.copy(showLogo = true) }
} }
override fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) } _screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
} }
override fun onLoginInputChanged(newLogin: String) { fun onLoginInputChanged(newLogin: String) {
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
login = newLogin.trim(), login = newLogin.trim(),
loginError = false loginError = false
@@ -149,8 +129,8 @@ class LoginViewModelImpl(
} }
} }
override fun onPasswordInputChanged(newPassword: String) { fun onPasswordInputChanged(newPassword: String) {
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
password = newPassword.trim(), password = newPassword.trim(),
passwordError = false passwordError = false
@@ -158,18 +138,18 @@ class LoginViewModelImpl(
} }
} }
override fun onSignInButtonClicked() { fun onSignInButtonClicked() {
if (screenState.value.isLoading) return if (screenState.value.isLoading) return
if (screenState.value.showLogo) { if (screenState.value.showLogo) {
screenState.setValue { old -> old.copy(showLogo = false) } _screenState.setValue { old -> old.copy(showLogo = false) }
return return
} }
login() login()
} }
override fun onLogoClicked() { fun onLogoClicked() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged( userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value !userSettings.enableDynamicColors.value
@@ -177,36 +157,36 @@ class LoginViewModelImpl(
} }
} }
override fun onNavigatedToMain() { fun onNavigatedToMain() {
isNeedToOpenMain.update { false } _isNeedToOpenMain.update { false }
} }
override fun onNavigatedToUserBanned() { fun onNavigatedToUserBanned() {
userBannedArguments.update { null } _userBannedArguments.update { null }
} }
override fun onNavigatedToCaptcha() { fun onNavigatedToCaptcha() {
captchaArguments.update { null } _captchaArguments.update { null }
} }
override fun onNavigatedToValidation() { fun onNavigatedToValidation() {
validationArguments.update { null } _validationArguments.update { null }
} }
override fun onValidationCodeReceived(code: String?) { fun onValidationCodeReceived(code: String?) {
validationCode.update { code } validationCode.update { code }
} }
override fun onValidationCodeCleared() { fun onValidationCodeCleared() {
isNeedToClearValidationCode.update { false } _isNeedToClearValidationCode.update { false }
} }
override fun onCaptchaCodeReceived(code: String?) { fun onCaptchaCodeReceived(code: String?) {
captchaCode.update { code } captchaCode.update { code }
} }
override fun onCaptchaCodeCleared() { fun onCaptchaCodeCleared() {
isNeedToClearCaptchaCode.update { false } _isNeedToClearCaptchaCode.update { false }
} }
private fun login(forceSms: Boolean = false) { private fun login(forceSms: Boolean = false) {
@@ -223,7 +203,7 @@ class LoginViewModelImpl(
processValidation() processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return if (!validationState.value.contains(LoginValidationResult.Valid)) return
screenState.updateValue { copy(isLoading = true) } _screenState.updateValue { copy(isLoading = true) }
val currentValidationSid = validationSid.value val currentValidationSid = validationSid.value
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null } val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
@@ -242,7 +222,7 @@ class LoginViewModelImpl(
error = { error -> error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error") Log.d("LoginViewModelImpl", "login: error: $error")
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
captchaSid.setValue { null } captchaSid.setValue { null }
parseError(error) parseError(error)
@@ -250,8 +230,8 @@ class LoginViewModelImpl(
success = { response -> success = { response ->
val exceptionHandler = val exceptionHandler =
CoroutineExceptionHandler { _, _ -> CoroutineExceptionHandler { _, _ ->
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() } _loginDialog.setValue { LoginDialog.Error() }
} }
viewModelScope.launch(Dispatchers.IO + exceptionHandler) { viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
@@ -277,8 +257,8 @@ class LoginViewModelImpl(
} }
if (exchangeToken == null) { if (exchangeToken == null) {
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() } _loginDialog.setValue { LoginDialog.Error() }
return@launch return@launch
} }
@@ -316,15 +296,15 @@ class LoginViewModelImpl(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
any = { any = {
screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
}, },
error = ::parseError, error = ::parseError,
success = { user -> success = { user ->
if (user == null) { if (user == null) {
loginDialog.update { LoginDialog.Error() } _loginDialog.update { LoginDialog.Error() }
} else { } else {
screenState.updateValue { copy(login = "", password = "") } _screenState.updateValue { copy(login = "", password = "") }
isNeedToOpenMain.update { true } _isNeedToOpenMain.update { true }
} }
} }
) )
@@ -347,7 +327,7 @@ class LoginViewModelImpl(
validationType = error.validationType.value, validationType = error.validationType.value,
canResendSms = error.validationResend == "sms" canResendSms = error.validationResend == "sms"
) )
validationArguments.update { arguments } _validationArguments.update { arguments }
validationSid.update { error.validationSid } validationSid.update { error.validationSid }
} }
@@ -356,12 +336,12 @@ class LoginViewModelImpl(
captchaSid = error.captchaSid, captchaSid = error.captchaSid,
captchaImageUrl = error.captchaImageUrl captchaImageUrl = error.captchaImageUrl
) )
captchaArguments.update { arguments } _captchaArguments.update { arguments }
captchaSid.update { error.captchaSid } captchaSid.update { error.captchaSid }
} }
OAuthErrorDomain.InvalidCredentialsError -> { OAuthErrorDomain.InvalidCredentialsError -> {
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.") LoginDialog.Error(errorText = "Wrong login or password.")
} }
} }
@@ -373,33 +353,33 @@ class LoginViewModelImpl(
restoreUrl = error.restoreUrl, restoreUrl = error.restoreUrl,
accessToken = error.accessToken accessToken = error.accessToken
) )
userBannedArguments.update { arguments } _userBannedArguments.update { arguments }
} }
OAuthErrorDomain.WrongValidationCode -> { OAuthErrorDomain.WrongValidationCode -> {
isNeedToClearValidationCode.update { true } _isNeedToClearValidationCode.update { true }
validationCode.update { null } validationCode.update { null }
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.") LoginDialog.Error(errorText = "Wrong validation code.")
} }
} }
OAuthErrorDomain.WrongValidationCodeFormat -> { OAuthErrorDomain.WrongValidationCodeFormat -> {
isNeedToClearValidationCode.update { true } _isNeedToClearValidationCode.update { true }
validationCode.update { null } validationCode.update { null }
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.") LoginDialog.Error(errorText = "Wrong validation code format.")
} }
} }
OAuthErrorDomain.TooManyTriesError -> { OAuthErrorDomain.TooManyTriesError -> {
loginDialog.setValue { _loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.") LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
} }
} }
OAuthErrorDomain.UnknownError -> { OAuthErrorDomain.UnknownError -> {
loginDialog.setValue { LoginDialog.Error() } _loginDialog.setValue { LoginDialog.Error() }
} }
} }
} }
@@ -412,11 +392,11 @@ class LoginViewModelImpl(
validationState.value.forEach { result -> validationState.value.forEach { result ->
when (result) { when (result) {
LoginValidationResult.LoginEmpty -> { LoginValidationResult.LoginEmpty -> {
screenState.setValue { old -> old.copy(loginError = true) } _screenState.setValue { old -> old.copy(loginError = true) }
} }
LoginValidationResult.PasswordEmpty -> { LoginValidationResult.PasswordEmpty -> {
screenState.setValue { old -> old.copy(passwordError = true) } _screenState.setValue { old -> old.copy(passwordError = true) }
} }
LoginValidationResult.Empty -> Unit LoginValidationResult.Empty -> Unit
@@ -1,9 +1,9 @@
package dev.meloda.fast.auth.login.di package dev.meloda.fast.auth.login.di
import dev.meloda.fast.auth.login.LoginViewModelImpl import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.validation.LoginValidator
import dev.meloda.fast.domain.OAuthUseCase import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.domain.OAuthUseCaseImpl import dev.meloda.fast.domain.OAuthUseCaseImpl
import dev.meloda.fast.auth.login.validation.LoginValidator
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind import org.koin.dsl.bind
@@ -11,6 +11,6 @@ import org.koin.dsl.module
val loginModule = module { val loginModule = module {
singleOf(::LoginValidator) singleOf(::LoginValidator)
viewModelOf(::LoginViewModelImpl) bind dev.meloda.fast.auth.login.LoginViewModel::class viewModelOf(::LoginViewModel)
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
} }
@@ -8,7 +8,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -24,11 +23,12 @@ fun NavGraphBuilder.loginScreen(
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController navController: NavController
) { ) {
composable<Login> { backStackEntry -> composable<Login> { backStackEntry ->
val viewModel: LoginViewModel = val viewModel: LoginViewModel =
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController) backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle() val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle() val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
@@ -55,6 +55,7 @@ fun NavGraphBuilder.loginScreen(
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha, onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToValidation = onNavigateToValidation, onNavigateToValidation = onNavigateToValidation,
onNavigateToSettings = onNavigateToSettings,
validationCode = validationCode, validationCode = validationCode,
captchaCode = captchaCode, captchaCode = captchaCode,
viewModel = viewModel viewModel = viewModel
@@ -39,6 +39,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -56,7 +58,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
@@ -76,9 +77,10 @@ fun LoginRoute(
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToSettings: () -> Unit,
validationCode: String?, validationCode: String?,
captchaCode: String?, captchaCode: String?,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>() viewModel: LoginViewModel = koinViewModel()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
@@ -132,7 +134,8 @@ fun LoginRoute(
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked, onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked onLogoClicked = viewModel::onLogoClicked,
onLogoLongClicked = onNavigateToSettings
) )
HandleDialogs( HandleDialogs(
@@ -151,7 +154,8 @@ fun LoginScreen(
onPasswordVisibilityButtonClicked: () -> Unit = {}, onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {}, onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {}, onSignInButtonClicked: () -> Unit = {},
onLogoClicked: () -> Unit = {} onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val size = LocalSizeConfig.current val size = LocalSizeConfig.current
@@ -185,7 +189,10 @@ fun LoginScreen(
exit = fadeOut(), exit = fadeOut(),
label = "Logo visibility" label = "Logo visibility"
) { ) {
Logo(onLogoClicked = onLogoClicked) Logo(
onLogoClicked = onLogoClicked,
onLogoLongClicked = onLogoLongClicked
)
} }
AnimatedVisibility( AnimatedVisibility(
@@ -30,7 +30,8 @@ import dev.meloda.fast.ui.theme.LocalSizeConfig
@Composable @Composable
fun Logo( fun Logo(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLogoClicked: () -> Unit = {} onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) { ) {
val size = LocalSizeConfig.current val size = LocalSizeConfig.current
@@ -60,7 +61,7 @@ fun Logo(
.combinedClickable( .combinedClickable(
interactionSource = null, interactionSource = null,
indication = null, indication = null,
onLongClick = null, onLongClick = onLogoLongClicked,
onClick = onLogoClicked onClick = onLogoClicked
) )
) )
@@ -38,54 +38,15 @@ import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
interface ConversationsViewModel { class ConversationsViewModel(
updatesParser: LongPollUpdatesParser,
val screenState: StateFlow<ConversationsScreenState>
val navigation: StateFlow<ConversationNavigation?>
val dialog: StateFlow<ConversationDialog?>
val conversations: StateFlow<List<VkConversation>>
val uiConversations: StateFlow<List<UiConversation>>
val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onNavigationConsumed()
fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle)
fun onDialogDismissed(dialog: ConversationDialog)
fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle)
fun onErrorButtonClicked()
fun onPaginationConditionsMet()
fun onOptionClicked(conversation: UiConversation, option: ConversationOption)
fun onRefresh()
fun onConversationItemClick(conversation: UiConversation)
fun onConversationItemLongClick(conversation: UiConversation)
fun onErrorConsumed()
fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int)
fun onCreateChatButtonClicked()
}
class ConversationsViewModelImpl(
private val filter: ConversationsFilter, private val filter: ConversationsFilter,
private val updatesParser: LongPollUpdatesParser,
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
@@ -93,23 +54,34 @@ class ConversationsViewModelImpl(
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val applicationContext: Context, private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ConversationsViewModel, ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY) private val _navigation = MutableStateFlow<ConversationNavigation?>(null)
override val navigation = MutableStateFlow<ConversationNavigation?>(null) val navigation = _navigation.asStateFlow()
override val dialog = MutableStateFlow<ConversationDialog?>(null)
override val conversations = MutableStateFlow<List<VkConversation>>(emptyList()) private val _dialog = MutableStateFlow<ConversationDialog?>(null)
override val uiConversations = MutableStateFlow<List<UiConversation>>(emptyList()) val dialog = _dialog.asStateFlow()
private val _conversations = MutableStateFlow<List<VkConversation>>(emptyList())
val conversations = _conversations.asStateFlow()
private val _uiConversations = MutableStateFlow<List<UiConversation>>(emptyList())
val uiConversations = _uiConversations.asStateFlow()
private val pinnedConversationsCount = conversations.map { conversations -> private val pinnedConversationsCount = conversations.map { conversations ->
conversations.count(VkConversation::isPinned) conversations.count(VkConversation::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0) }.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
override val baseError = MutableStateFlow<BaseError?>(null) private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
override val currentOffset = MutableStateFlow(0) private val _currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConversationId = MutableStateFlow(0L) private val expandedConversationId = MutableStateFlow(0L)
@@ -118,7 +90,7 @@ class ConversationsViewModelImpl(
private val interactionsTimers = hashMapOf<Long, InteractionJob?>() private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init { init {
screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) } _screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) }
loadConversations() loadConversations()
@@ -137,11 +109,11 @@ class ConversationsViewModelImpl(
} }
} }
override fun onNavigationConsumed() { fun onNavigationConsumed() {
navigation.setValue { null } _navigation.setValue { null }
} }
override fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) { fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) {
onDialogDismissed(dialog) onDialogDismissed(dialog)
when (dialog) { when (dialog) {
@@ -170,11 +142,11 @@ class ConversationsViewModelImpl(
syncUiConversation() syncUiConversation()
} }
override fun onDialogDismissed(dialog: ConversationDialog) { fun onDialogDismissed(dialog: ConversationDialog) {
this.dialog.setValue { null } _dialog.setValue { null }
} }
override fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) { fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) {
when (dialog) { when (dialog) {
is ConversationDialog.ConversationDelete -> Unit is ConversationDialog.ConversationDelete -> Unit
is ConversationDialog.ConversationPin -> Unit is ConversationDialog.ConversationPin -> Unit
@@ -184,7 +156,7 @@ class ConversationsViewModelImpl(
} }
} }
override fun onErrorButtonClicked() { fun onErrorButtonClicked() {
when (baseError.value) { when (baseError.value) {
null -> Unit null -> Unit
@@ -197,22 +169,22 @@ class ConversationsViewModelImpl(
} }
} }
override fun onPaginationConditionsMet() { fun onPaginationConditionsMet() {
currentOffset.update { conversations.value.size } _currentOffset.update { conversations.value.size }
loadConversations() loadConversations()
} }
override fun onRefresh() { fun onRefresh() {
onErrorConsumed() onErrorConsumed()
loadConversations(offset = 0) loadConversations(offset = 0)
} }
override fun onConversationItemClick(conversation: UiConversation) { fun onConversationItemClick(conversation: UiConversation) {
collapseConversations() collapseConversations()
navigation.setValue { ConversationNavigation.MessagesHistory(peerId = conversation.id) } _navigation.setValue { ConversationNavigation.MessagesHistory(peerId = conversation.id) }
} }
override fun onConversationItemLongClick(conversation: UiConversation) { fun onConversationItemLongClick(conversation: UiConversation) {
expandedConversationId.setValue { expandedConversationId.setValue {
if (conversation.isExpanded) 0 if (conversation.isExpanded) 0
else conversation.id else conversation.id
@@ -220,13 +192,13 @@ class ConversationsViewModelImpl(
syncUiConversation() syncUiConversation()
} }
override fun onOptionClicked( fun onOptionClicked(
conversation: UiConversation, conversation: UiConversation,
option: ConversationOption option: ConversationOption
) { ) {
when (option) { when (option) {
ConversationOption.Delete -> { ConversationOption.Delete -> {
dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) } _dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) }
} }
ConversationOption.MarkAsRead -> { ConversationOption.MarkAsRead -> {
@@ -240,37 +212,37 @@ class ConversationsViewModelImpl(
} }
ConversationOption.Pin -> { ConversationOption.Pin -> {
dialog.setValue { ConversationDialog.ConversationPin(conversation.id) } _dialog.setValue { ConversationDialog.ConversationPin(conversation.id) }
} }
ConversationOption.Unpin -> { ConversationOption.Unpin -> {
dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) } _dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) }
} }
ConversationOption.Archive -> { ConversationOption.Archive -> {
dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) } _dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) }
} }
ConversationOption.Unarchive -> { ConversationOption.Unarchive -> {
dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) } _dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) }
} }
} }
} }
override fun onErrorConsumed() { fun onErrorConsumed() {
baseError.setValue { null } _baseError.setValue { null }
} }
override fun setScrollIndex(index: Int) { fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) } _screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
override fun setScrollOffset(offset: Int) { fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) } _screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
override fun onCreateChatButtonClicked() { fun onCreateChatButtonClicked() {
navigation.setValue { ConversationNavigation.CreateChat } _navigation.setValue { ConversationNavigation.CreateChat }
} }
private fun collapseConversations() { private fun collapseConversations() {
@@ -289,7 +261,7 @@ class ConversationsViewModelImpl(
state.processState( state.processState(
error = { error -> error = { error ->
val newBaseError = VkUtils.parseError(error) val newBaseError = VkUtils.parseError(error)
baseError.update { newBaseError } _baseError.update { newBaseError }
}, },
success = { response -> success = { response ->
val conversations = response val conversations = response
@@ -304,7 +276,7 @@ class ConversationsViewModelImpl(
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient &&
this.conversations.value.isNotEmpty() this.conversations.value.isNotEmpty()
screenState.updateValue { _screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted) copy(isPaginationExhausted = paginationExhausted)
} }
@@ -321,13 +293,13 @@ class ConversationsViewModelImpl(
conversationsUseCase.storeConversations(response) conversationsUseCase.storeConversations(response)
this.conversations.emit(fullConversations) _conversations.emit(fullConversations)
syncUiConversation() syncUiConversation()
canPaginate.setValue { itemsCountSufficient } _canPaginate.setValue { itemsCountSufficient }
} }
) )
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading() isPaginating = offset > 0 && state.isLoading()
@@ -347,11 +319,11 @@ class ConversationsViewModelImpl(
?: return@processState ?: return@processState
newConversations.removeAt(conversationIndex) newConversations.removeAt(conversationIndex)
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
) )
screenState.emit(screenState.value.copy(isLoading = state.isLoading())) _screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
} }
} }
@@ -374,7 +346,7 @@ class ConversationsViewModelImpl(
} }
) )
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) } _screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
} }
} }
@@ -420,7 +392,7 @@ class ConversationsViewModelImpl(
.copy(lastMessage = message) .copy(lastMessage = message)
newConversations.add(pinnedConversationsCount.value, conversation) newConversations.add(pinnedConversationsCount.value, conversation)
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
) )
@@ -461,7 +433,7 @@ class ConversationsViewModelImpl(
newConversations.add(toPosition, newConversation) newConversations.add(toPosition, newConversation)
} }
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -480,7 +452,7 @@ class ConversationsViewModelImpl(
lastMessageId = message.id, lastMessageId = message.id,
lastCmId = message.cmId lastCmId = message.cmId
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
} }
@@ -500,7 +472,7 @@ class ConversationsViewModelImpl(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
} }
@@ -520,7 +492,7 @@ class ConversationsViewModelImpl(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
} }
@@ -541,7 +513,7 @@ class ConversationsViewModelImpl(
interactionIds = userIds interactionIds = userIds
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
interactionsTimers[peerId]?.let { interactionJob -> interactionsTimers[peerId]?.let { interactionJob ->
@@ -583,7 +555,7 @@ class ConversationsViewModelImpl(
interactionIds = emptyList() interactionIds = emptyList()
) )
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
interactionJob.timerJob.cancel() interactionJob.timerJob.cancel()
@@ -601,7 +573,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy(majorId = event.majorId) newConversations[conversationIndex].copy(majorId = event.majorId)
conversations.setValue { newConversations.sorted() } _conversations.setValue { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -617,7 +589,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy(minorId = event.minorId) newConversations[conversationIndex].copy(minorId = event.minorId)
conversations.setValue { newConversations.sorted() } _conversations.setValue { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -632,7 +604,7 @@ class ConversationsViewModelImpl(
} else { } else {
newConversations.removeAt(conversationIndex) newConversations.removeAt(conversationIndex)
conversations.setValue { newConversations.sorted() } _conversations.setValue { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -655,7 +627,7 @@ class ConversationsViewModelImpl(
newConversations.removeAt(index) newConversations.removeAt(index)
} }
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
@@ -669,7 +641,7 @@ class ConversationsViewModelImpl(
newConversations.add(pinnedConversationsCount.value, conversation) newConversations.add(pinnedConversationsCount.value, conversation)
} }
conversations.update { newConversations.sorted() } _conversations.update { newConversations.sorted() }
syncUiConversation() syncUiConversation()
} }
} }
@@ -691,7 +663,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] = newConversations[conversationIndex] =
newConversations[conversationIndex].copy(inRead = startMessageId) newConversations[conversationIndex].copy(inRead = startMessageId)
conversations.update { newConversations } _conversations.update { newConversations }
syncUiConversation() syncUiConversation()
} }
) )
@@ -763,7 +735,7 @@ class ConversationsViewModelImpl(
options = options.toImmutableList() options = options.toImmutableList()
) )
} }
uiConversations.setValue { newUiConversations } _uiConversations.setValue { newUiConversations }
return newUiConversations return newUiConversations
} }
@@ -1,6 +1,6 @@
package dev.meloda.fast.conversations.di package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.domain.ConversationsUseCase import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.ConversationsUseCaseImpl import dev.meloda.fast.domain.ConversationsUseCaseImpl
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConversationsFilter
@@ -22,8 +22,8 @@ val conversationsModule = module {
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
} }
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl { private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModel {
return ConversationsViewModelImpl( return ConversationsViewModel(
filter = filter, filter = filter,
updatesParser = get(), updatesParser = get(),
conversationsUseCase = get(), conversationsUseCase = get(),
@@ -4,7 +4,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import dev.meloda.fast.conversations.ConversationsViewModelImpl import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.presentation.ConversationsRoute import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConversationsFilter import dev.meloda.fast.model.ConversationsFilter
@@ -33,7 +33,7 @@ fun NavGraphBuilder.conversationsGraph(
navigation<ConversationsGraph>( navigation<ConversationsGraph>(
startDestination = Conversations startDestination = Conversations
) { ) {
val conversationsViewModel: ConversationsViewModelImpl = with(activity) { val conversationsViewModel: ConversationsViewModel = with(activity) {
getViewModel(qualifier = named(ConversationsFilter.ALL)) getViewModel(qualifier = named(ConversationsFilter.ALL))
} }
composable<Conversations> { composable<Conversations> {
@@ -53,7 +53,7 @@ fun NavGraphBuilder.conversationsGraph(
ConversationsRoute( ConversationsRoute(
viewModel = with(activity) { viewModel = with(activity) {
getViewModel<ConversationsViewModelImpl>( getViewModel<ConversationsViewModel>(
qualifier = named(ConversationsFilter.ARCHIVE) qualifier = named(ConversationsFilter.ARCHIVE)
) )
}, },
@@ -2,11 +2,14 @@ package dev.meloda.fast.settings
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel import dev.meloda.fast.common.model.LogLevel
@@ -15,52 +18,47 @@ import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.db.AccountsRepository import dev.meloda.fast.data.db.AccountsRepository
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.SettingsKeys import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.AuthUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsItem import dev.meloda.fast.settings.model.SettingsItem
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.SettingsShowOptions
import dev.meloda.fast.settings.model.TextProvider import dev.meloda.fast.settings.model.TextProvider
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
interface SettingsViewModel { class SettingsViewModel(
private val loadUserByIdUseCase: LoadUserByIdUseCase,
val screenState: StateFlow<SettingsScreenState>
val hapticType: StateFlow<HapticType?>
fun onLogOutAlertDismissed()
suspend fun onLogOutAlertPositiveClick()
fun onPerformCrashAlertDismissed()
fun onPerformCrashPositiveButtonClicked()
fun onSettingsItemClicked(key: String)
fun onSettingsItemLongClicked(key: String)
fun onSettingsItemChanged(key: String, newValue: Any?)
fun onHapticPerformed()
}
class SettingsViewModelImpl(
private val authUseCase: AuthUseCase,
private val accountsRepository: AccountsRepository, private val accountsRepository: AccountsRepository,
private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val resources: Resources, private val resources: Resources,
private val longPollController: LongPollController private val longPollController: LongPollController
) : SettingsViewModel, ViewModel() { ) : ViewModel() {
override val screenState = MutableStateFlow(SettingsScreenState.EMPTY) private val _screenState = MutableStateFlow(SettingsScreenState.EMPTY)
override val hapticType = MutableStateFlow<HapticType?>(null) val screenState = _screenState.asStateFlow()
private val _hapticType = MutableStateFlow<HapticType?>(null)
val hapticType = _hapticType.asStateFlow()
private val _dialog = MutableStateFlow<SettingsDialog?>(null)
val dialog = _dialog.asStateFlow()
private val _isNeedToRestart = MutableStateFlow(false)
val isNeedToRestart = _isNeedToRestart.asStateFlow()
private val settings = MutableStateFlow<List<SettingsItem<*>>>(emptyList()) private val settings = MutableStateFlow<List<SettingsItem<*>>>(emptyList())
@@ -68,24 +66,87 @@ class SettingsViewModelImpl(
createSettings() createSettings()
} }
override fun onLogOutAlertDismissed() { fun onDialogConfirmed(dialog: SettingsDialog, bundle: Bundle) {
emitShowOptions { old -> old.copy(showLogOut = false) } onDialogDismissed(dialog)
when (dialog) {
is SettingsDialog.LogOut -> onLogOutAlertPositiveClick()
is SettingsDialog.PerformCrash -> onPerformCrashPositiveButtonClicked()
is SettingsDialog.ImportAuthData -> {
val accessToken = bundle.getString("ACCESS_TOKEN") ?: return
val exchangeToken = bundle.getString("EXCHANGE_TOKEN")
val trustedHash = bundle.getString("TRUSTED_HASH")
viewModelScope.launch(Dispatchers.IO) {
val oldToken = UserConfig.accessToken
UserConfig.accessToken = accessToken
loadUserByIdUseCase(
userId = null,
fields = VkConstants.USER_FIELDS
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
UserConfig.accessToken = oldToken
},
success = { user ->
if (user == null) return@listenValue
UserConfig.currentUserId = user.id
val account = getCurrentAccountUseCase()
?.copy(
userId = user.id,
accessToken = accessToken,
fastToken = null,
exchangeToken = exchangeToken,
trustedHash = trustedHash
) ?: AccountEntity(
userId = user.id,
accessToken = accessToken,
fastToken = null,
trustedHash = trustedHash,
exchangeToken = exchangeToken
)
accountsRepository.storeAccounts(listOf(account))
_isNeedToRestart.setValue { true }
}
)
}
}
}
is SettingsDialog.ExportAuthData -> Unit
}
} }
override suspend fun onLogOutAlertPositiveClick() { fun onDialogDismissed(dialog: SettingsDialog) {
withContext(Dispatchers.IO) { when (dialog) {
is SettingsDialog.LogOut -> Unit
is SettingsDialog.PerformCrash -> Unit
is SettingsDialog.ImportAuthData -> Unit
is SettingsDialog.ExportAuthData -> Unit
}
_dialog.setValue { null }
}
fun onDialogItemPicked(dialog: SettingsDialog, bundle: Bundle) {
when (dialog) {
is SettingsDialog.LogOut -> Unit
is SettingsDialog.PerformCrash -> Unit
is SettingsDialog.ImportAuthData -> Unit
is SettingsDialog.ExportAuthData -> Unit
}
}
fun onLogOutAlertPositiveClick() {
viewModelScope.launch(Dispatchers.IO) {
val tasks = listOf( val tasks = listOf(
// async {
// suspendCoroutine { continuation ->
// authUseCase.logout().listenValue(viewModelScope) { state ->
// state.processState(
// any = { continuation.resume(Unit) },
// success = {},
// error = {}
// )
// }
// }
// },
async { async {
accountsRepository.storeAccounts( accountsRepository.storeAccounts(
listOf( listOf(
@@ -106,22 +167,32 @@ class SettingsViewModelImpl(
} }
} }
override fun onPerformCrashAlertDismissed() { fun onPerformCrashPositiveButtonClicked() {
emitShowOptions { old -> old.copy(showPerformCrash = false) }
}
override fun onPerformCrashPositiveButtonClicked() {
throw Exception("Test exception") throw Exception("Test exception")
} }
override fun onSettingsItemClicked(key: String) { fun onSettingsItemClicked(key: String) {
when (key) { when (key) {
SettingsKeys.KEY_ACCOUNT_LOGOUT -> { SettingsKeys.KEY_ACCOUNT_LOGOUT -> {
emitShowOptions { old -> old.copy(showLogOut = true) } _dialog.setValue { SettingsDialog.LogOut }
} }
SettingsKeys.KEY_DEBUG_PERFORM_CRASH -> { SettingsKeys.KEY_DEBUG_PERFORM_CRASH -> {
emitShowOptions { old -> old.copy(showPerformCrash = true) } _dialog.setValue { SettingsDialog.PerformCrash }
}
SettingsKeys.KEY_DEBUG_IMPORT_AUTH_DATA -> {
_dialog.setValue { SettingsDialog.ImportAuthData }
}
SettingsKeys.KEY_DEBUG_EXPORT_AUTH_DATA -> {
_dialog.setValue {
SettingsDialog.ExportAuthData(
accessToken = UserConfig.accessToken,
exchangeToken = UserConfig.exchangeToken,
trustedHash = UserConfig.trustedHash
)
}
} }
SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST -> { SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST -> {
@@ -132,13 +203,13 @@ class SettingsViewModelImpl(
createSettings() createSettings()
hapticType.update { HapticType.REJECT } _hapticType.update { HapticType.REJECT }
screenState.setValue { old -> old.copy(showDebugOptions = false) } _screenState.setValue { old -> old.copy(showDebugOptions = false) }
} }
} }
} }
override fun onSettingsItemLongClicked(key: String) { fun onSettingsItemLongClicked(key: String) {
when (key) { when (key) {
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> { SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
if (AppSettings.Debug.showDebugCategory) return if (AppSettings.Debug.showDebugCategory) return
@@ -148,18 +219,18 @@ class SettingsViewModelImpl(
createSettings() createSettings()
hapticType.update { HapticType.LONG_PRESS } _hapticType.update { HapticType.LONG_PRESS }
screenState.setValue { old -> old.copy(showDebugOptions = true) } _screenState.setValue { old -> old.copy(showDebugOptions = true) }
} }
} }
} }
override fun onSettingsItemChanged(key: String, newValue: Any?) { fun onSettingsItemChanged(key: String, newValue: Any?) {
settings.value.findWithIndex { it.key == key }?.let { (index, item) -> settings.value.findWithIndex { it.key == key }?.let { (index, item) ->
item.updateValue(newValue) item.updateValue(newValue)
item.updateText() item.updateText()
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
settings = old.settings.toMutableList().apply { settings = old.settings.toMutableList().apply {
this[index] = item.asPresentation(resources) this[index] = item.asPresentation(resources)
@@ -240,13 +311,8 @@ class SettingsViewModelImpl(
} }
} }
override fun onHapticPerformed() { fun onHapticPerformed() {
hapticType.update { null } _hapticType.update { null }
}
private fun emitShowOptions(function: (SettingsShowOptions) -> SettingsShowOptions) {
val newShowOptions = function.invoke(screenState.value.showOptions)
screenState.setValue { old -> old.copy(showOptions = newShowOptions) }
} }
private fun createSettings() { private fun createSettings() {
@@ -446,6 +512,18 @@ class SettingsViewModelImpl(
} }
} }
val debugImportAuthData = SettingsItem.TitleText(
key = SettingsKeys.KEY_DEBUG_IMPORT_AUTH_DATA,
title = UiText.Simple("Import auth data"),
text = UiText.Simple("App will be restarted")
)
val debugExportAuthData = SettingsItem.TitleText(
key = SettingsKeys.KEY_DEBUG_EXPORT_AUTH_DATA,
title = UiText.Simple("Export auth data"),
text = UiText.Simple("Be careful with this data. If another person gets it, your account will be at risk"),
isVisible = UserConfig.isLoggedIn()
)
val debugHideDebugList = SettingsItem.TitleText( val debugHideDebugList = SettingsItem.TitleText(
key = SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST, key = SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST,
title = UiText.Simple("Hide debug list") title = UiText.Simple("Hide debug list")
@@ -492,6 +570,8 @@ class SettingsViewModelImpl(
debugPerformCrash, debugPerformCrash,
debugShowCrashAlert, debugShowCrashAlert,
debugNetworkLogLevel, debugNetworkLogLevel,
debugImportAuthData,
debugExportAuthData
).forEach(debugList::add) ).forEach(debugList::add)
debugList += debugHideDebugList debugList += debugHideDebugList
@@ -521,15 +601,6 @@ class SettingsViewModelImpl(
item.asPresentation(resources) item.asPresentation(resources)
} }
screenState.setValue { old -> old.copy(settings = uiSettings) } _screenState.setValue { old -> old.copy(settings = uiSettings) }
}
}
enum class HapticType {
LONG_PRESS, REJECT;
fun getHaptic(): Int = when (this) {
LONG_PRESS -> HapticFeedbackConstantsCompat.LONG_PRESS
REJECT -> HapticFeedbackConstantsCompat.REJECT
} }
} }
@@ -1,11 +1,9 @@
package dev.meloda.fast.settings.di package dev.meloda.fast.settings.di
import dev.meloda.fast.settings.SettingsViewModel import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.SettingsViewModelImpl
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
val settingsModule = module { val settingsModule = module {
viewModelOf(::SettingsViewModelImpl) bind SettingsViewModel::class viewModelOf(::SettingsViewModel)
} }
@@ -0,0 +1,12 @@
package dev.meloda.fast.settings.model;
import androidx.core.view.HapticFeedbackConstantsCompat
enum class HapticType {
LONG_PRESS, REJECT;
fun getHaptic(): Int = when (this) {
LONG_PRESS -> HapticFeedbackConstantsCompat.LONG_PRESS
REJECT -> HapticFeedbackConstantsCompat.REJECT
}
}
@@ -0,0 +1,16 @@
package dev.meloda.fast.settings.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class SettingsDialog {
data object LogOut : SettingsDialog()
data object PerformCrash : SettingsDialog()
data object ImportAuthData : SettingsDialog()
data class ExportAuthData(
val accessToken: String,
val exchangeToken: String?,
val trustedHash: String?
) : SettingsDialog()
}
@@ -5,14 +5,12 @@ import dev.meloda.fast.datastore.AppSettings
@Immutable @Immutable
data class SettingsScreenState( data class SettingsScreenState(
val showOptions: SettingsShowOptions,
val settings: List<UiItem>, val settings: List<UiItem>,
val showDebugOptions: Boolean val showDebugOptions: Boolean
) { ) {
companion object { companion object {
val EMPTY: SettingsScreenState = SettingsScreenState( val EMPTY: SettingsScreenState = SettingsScreenState(
showOptions = SettingsShowOptions.EMPTY,
settings = emptyList(), settings = emptyList(),
showDebugOptions = AppSettings.Debug.showDebugCategory showDebugOptions = AppSettings.Debug.showDebugCategory
) )
@@ -1,14 +0,0 @@
package dev.meloda.fast.settings.model
data class SettingsShowOptions(
val showLogOut: Boolean,
val showPerformCrash: Boolean,
) {
companion object {
val EMPTY: SettingsShowOptions = SettingsShowOptions(
showLogOut = false,
showPerformCrash = false,
)
}
}
@@ -12,13 +12,15 @@ object Settings
fun NavGraphBuilder.settingsScreen( fun NavGraphBuilder.settingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit, onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
) { ) {
composable<Settings> { composable<Settings> {
SettingsRoute( SettingsRoute(
onBack = onBack, onBack = onBack,
onLogOutButtonClicked = onLogOutButtonClicked, onLogOutButtonClicked = onLogOutButtonClicked,
onLanguageItemClicked = onLanguageItemClicked onLanguageItemClicked = onLanguageItemClicked,
onRestartRequired = onRestartRequired
) )
} }
} }
@@ -0,0 +1,282 @@
package dev.meloda.fast.settings.presentation
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R as UiR
@Composable
fun HandleDialogs(
screenState: SettingsScreenState,
dialog: SettingsDialog?,
onConfirmed: (SettingsDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (SettingsDialog) -> Unit = {},
onItemPicked: (SettingsDialog, Bundle) -> Unit = { _, _ -> }
) {
if (dialog == null) return
val context = LocalContext.current
when (dialog) {
is SettingsDialog.LogOut -> {
val isEasterEgg = UserConfig.userId == SettingsKeys.ID_DMITRY
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(
id = if (isEasterEgg) UiR.string.easter_egg_log_out_dmitry
else UiR.string.sign_out_confirm_title
),
text = stringResource(id = UiR.string.sign_out_confirm),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(
id = if (isEasterEgg) UiR.string.easter_egg_log_out_dmitry
else UiR.string.action_sign_out
),
cancelText = stringResource(id = UiR.string.no),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
is SettingsDialog.PerformCrash -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = "Perform crash",
text = "App will be crashed. Are you sure?",
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = UiR.string.yes),
cancelText = stringResource(id = UiR.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
is SettingsDialog.ImportAuthData -> {
var accessToken by rememberSaveable {
mutableStateOf("")
}
var exchangeToken by rememberSaveable {
mutableStateOf("")
}
var trustedHash by rememberSaveable {
mutableStateOf("")
}
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = "Import auth data",
confirmAction = {
onConfirmed(
dialog,
bundleOf(
"ACCESS_TOKEN" to accessToken,
"EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null },
"TRUSTED_HASH" to trustedHash.ifEmpty { null }
)
)
},
confirmText = "Import",
cancelText = stringResource(UiR.string.cancel)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = accessToken,
onValueChange = { accessToken = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(text = "Access token") }
)
TextField(
value = exchangeToken,
onValueChange = { exchangeToken = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(text = "Exchange token") }
)
TextField(
value = trustedHash,
onValueChange = { trustedHash = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(text = "Trusted hash") }
)
Button(
onClick = {
val manager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
manager.primaryClip?.let { data ->
val importedData = try {
val data = data.getItemAt(0).text.trim()
if (data.isEmpty()) {
null
} else {
val split = data.split("\n")
if (split.isEmpty() || split.size < 3) {
null
} else {
val (newAccessToken) = split
val newExchangeToken = split[1].trim().ifEmpty { null }
val newTrustedHash = split[2].trim().ifEmpty { null }
accessToken = newAccessToken
exchangeToken = newExchangeToken.orEmpty()
trustedHash = newTrustedHash.orEmpty()
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
if (importedData == null) {
Toast.makeText(
context,
"Invalid data format. Can\'t import",
Toast.LENGTH_SHORT
).show()
return@let
}
Toast.makeText(
context,
"Success",
Toast.LENGTH_SHORT
).show()
}
},
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(text = "Import from clipboard")
}
}
}
}
is SettingsDialog.ExportAuthData -> {
var accessToken by rememberSaveable {
mutableStateOf(dialog.accessToken)
}
var exchangeToken by rememberSaveable {
mutableStateOf(dialog.exchangeToken.orEmpty())
}
var trustedHash by rememberSaveable {
mutableStateOf(dialog.trustedHash.orEmpty())
}
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = "Export auth data",
confirmAction = {
onConfirmed(
dialog,
bundleOf(
"ACCESS_TOKEN" to accessToken,
"EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null },
"TRUSTED_HASH" to trustedHash.ifEmpty { null }
)
)
},
confirmText = stringResource(UiR.string.ok),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = accessToken,
onValueChange = { accessToken = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(text = "Access token") }
)
TextField(
value = exchangeToken,
onValueChange = { exchangeToken = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(text = "Exchange token") }
)
TextField(
value = trustedHash,
onValueChange = { trustedHash = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(text = "Trusted hash") }
)
Button(
onClick = {
val manager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val textToCopy = buildString {
append(accessToken)
append("\n")
if (exchangeToken.isNotEmpty()) {
append(exchangeToken)
}
append("\n")
if (trustedHash.isNotEmpty()) {
append(trustedHash)
}
}
manager.setPrimaryClip(
ClipData.newPlainText("Fast auth data", textToCopy)
)
Toast.makeText(
context,
"Auth data copied to clipboard. Be careful with this data. If another person gets it, your account will be at risk",
Toast.LENGTH_LONG
).show()
onDismissed(dialog)
},
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(text = "Copy to clipboard")
}
}
}
}
}
}
@@ -0,0 +1,65 @@
package dev.meloda.fast.settings.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.model.SettingsDialog
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun SettingsRoute(
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
viewModel: SettingsViewModel = koinViewModel()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val hapticType by viewModel.hapticType.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val isNeedToRestart by viewModel.isNeedToRestart.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToRestart) {
if (isNeedToRestart) {
onRestartRequired()
}
}
SettingsScreen(
screenState = screenState,
hapticType = hapticType,
onBack = onBack,
onHapticPerformed = viewModel::onHapticPerformed,
onSettingsItemClicked = { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onLanguageItemClicked()
}
else -> viewModel.onSettingsItemClicked(key)
}
},
onSettingsItemLongClicked = viewModel::onSettingsItemLongClicked,
onSettingsItemValueChanged = viewModel::onSettingsItemChanged
)
HandleDialogs(
screenState = screenState,
dialog = dialog,
onConfirmed = { dialog, bundle ->
when (dialog) {
is SettingsDialog.LogOut -> {
onLogOutButtonClicked()
}
else -> Unit
}
viewModel.onDialogConfirmed(dialog, bundle)
},
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -22,27 +22,20 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.SettingsKeys import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.HapticType
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.SettingsViewModelImpl
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.UiItem import dev.meloda.fast.settings.model.UiItem
import dev.meloda.fast.settings.presentation.item.ListItem import dev.meloda.fast.settings.presentation.item.ListItem
@@ -50,56 +43,9 @@ import dev.meloda.fast.settings.presentation.item.SwitchItem
import dev.meloda.fast.settings.presentation.item.TextFieldItem import dev.meloda.fast.settings.presentation.item.TextFieldItem
import dev.meloda.fast.settings.presentation.item.TitleItem import dev.meloda.fast.settings.presentation.item.TitleItem
import dev.meloda.fast.settings.presentation.item.TitleTextItem import dev.meloda.fast.settings.presentation.item.TitleTextItem
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@Composable
fun SettingsRoute(
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
viewModel: SettingsViewModel = koinViewModel<SettingsViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val hapticType by viewModel.hapticType.collectAsStateWithLifecycle()
SettingsScreen(
screenState = screenState,
hapticType = hapticType,
onBack = onBack,
onHapticPerformed = viewModel::onHapticPerformed,
onSettingsItemClicked = { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onLanguageItemClicked()
}
else -> viewModel.onSettingsItemClicked(key)
}
},
onSettingsItemLongClicked = viewModel::onSettingsItemLongClicked,
onSettingsItemValueChanged = viewModel::onSettingsItemChanged
)
val scope = rememberCoroutineScope()
HandlePopups(
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
logoutPositiveClick = {
scope.launch {
viewModel.onLogOutAlertPositiveClick()
onLogOutButtonClicked()
}
},
logoutDismissed = viewModel::onLogOutAlertDismissed,
screenState = screenState
)
}
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
@@ -248,72 +194,3 @@ fun SettingsScreen(
} }
} }
} }
@Composable
fun HandlePopups(
performCrashPositiveClick: () -> Unit,
performCrashDismissed: () -> Unit,
logoutPositiveClick: () -> Unit,
logoutDismissed: () -> Unit,
screenState: SettingsScreenState
) {
val showOptions = screenState.showOptions
PerformCrashDialog(
positiveClick = performCrashPositiveClick,
dismiss = performCrashDismissed,
show = showOptions.showPerformCrash
)
LogOutDialog(
positiveClick = logoutPositiveClick,
dismiss = logoutDismissed,
show = showOptions.showLogOut
)
}
@Composable
fun PerformCrashDialog(
positiveClick: () -> Unit,
dismiss: () -> Unit,
show: Boolean,
) {
if (show) {
MaterialDialog(
onDismissRequest = dismiss,
title = "Perform crash",
text = "App will be crashed. Are you sure?",
confirmAction = positiveClick,
confirmText = stringResource(id = UiR.string.yes),
cancelText = stringResource(id = UiR.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
}
@Composable
fun LogOutDialog(
positiveClick: () -> Unit,
dismiss: () -> Unit,
show: Boolean
) {
if (show) {
val isEasterEgg = UserConfig.userId == SettingsKeys.ID_DMITRY
MaterialDialog(
onDismissRequest = dismiss,
title = stringResource(
id = if (isEasterEgg) UiR.string.easter_egg_log_out_dmitry
else UiR.string.sign_out_confirm_title
),
text = stringResource(id = UiR.string.sign_out_confirm),
confirmAction = positiveClick,
confirmText = stringResource(
id = if (isEasterEgg) UiR.string.easter_egg_log_out_dmitry
else UiR.string.action_sign_out
),
cancelText = stringResource(id = UiR.string.no),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
}