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
@@ -23,6 +23,7 @@ object AuthGraph
fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController
) {
navigation<AuthGraph>(startDestination = Login) {
@@ -54,6 +55,7 @@ fun NavGraphBuilder.authNavGraph(
)
)
},
onNavigateToSettings = onNavigateToSettings,
navController = navController
)
@@ -35,49 +35,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
interface 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(
class LoginViewModel(
private val oAuthUseCase: OAuthUseCase,
private val authRepository: AuthRepository,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
@@ -85,18 +49,30 @@ class LoginViewModelImpl(
private val loginValidator: LoginValidator,
private val longPollController: LongPollController,
private val userSettings: UserSettings
) : ViewModel(), LoginViewModel {
) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
override val loginDialog = MutableStateFlow<LoginDialog?>(null)
private val _loginDialog = MutableStateFlow<LoginDialog?>(null)
val loginDialog = _loginDialog.asStateFlow()
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
val validationArguments = _validationArguments.asStateFlow()
override val isNeedToClearCaptchaCode = MutableStateFlow(false)
override val isNeedToClearValidationCode = MutableStateFlow(false)
private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
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>> =
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)
when (dialog) {
@@ -128,20 +104,24 @@ class LoginViewModelImpl(
}
}
override fun onDialogDismissed(dialog: LoginDialog) {
loginDialog.setValue { null }
fun onDialogDismissed(dialog: LoginDialog) {
when (dialog) {
is LoginDialog.Error -> Unit
}
_loginDialog.setValue { null }
}
override fun onBackPressed() {
screenState.setValue { old -> old.copy(showLogo = true) }
fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) }
}
override fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
fun onPasswordVisibilityButtonClicked() {
_screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
}
override fun onLoginInputChanged(newLogin: String) {
screenState.setValue { old ->
fun onLoginInputChanged(newLogin: String) {
_screenState.setValue { old ->
old.copy(
login = newLogin.trim(),
loginError = false
@@ -149,8 +129,8 @@ class LoginViewModelImpl(
}
}
override fun onPasswordInputChanged(newPassword: String) {
screenState.setValue { old ->
fun onPasswordInputChanged(newPassword: String) {
_screenState.setValue { old ->
old.copy(
password = newPassword.trim(),
passwordError = false
@@ -158,18 +138,18 @@ class LoginViewModelImpl(
}
}
override fun onSignInButtonClicked() {
fun onSignInButtonClicked() {
if (screenState.value.isLoading) return
if (screenState.value.showLogo) {
screenState.setValue { old -> old.copy(showLogo = false) }
_screenState.setValue { old -> old.copy(showLogo = false) }
return
}
login()
}
override fun onLogoClicked() {
fun onLogoClicked() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value
@@ -177,36 +157,36 @@ class LoginViewModelImpl(
}
}
override fun onNavigatedToMain() {
isNeedToOpenMain.update { false }
fun onNavigatedToMain() {
_isNeedToOpenMain.update { false }
}
override fun onNavigatedToUserBanned() {
userBannedArguments.update { null }
fun onNavigatedToUserBanned() {
_userBannedArguments.update { null }
}
override fun onNavigatedToCaptcha() {
captchaArguments.update { null }
fun onNavigatedToCaptcha() {
_captchaArguments.update { null }
}
override fun onNavigatedToValidation() {
validationArguments.update { null }
fun onNavigatedToValidation() {
_validationArguments.update { null }
}
override fun onValidationCodeReceived(code: String?) {
fun onValidationCodeReceived(code: String?) {
validationCode.update { code }
}
override fun onValidationCodeCleared() {
isNeedToClearValidationCode.update { false }
fun onValidationCodeCleared() {
_isNeedToClearValidationCode.update { false }
}
override fun onCaptchaCodeReceived(code: String?) {
fun onCaptchaCodeReceived(code: String?) {
captchaCode.update { code }
}
override fun onCaptchaCodeCleared() {
isNeedToClearCaptchaCode.update { false }
fun onCaptchaCodeCleared() {
_isNeedToClearCaptchaCode.update { false }
}
private fun login(forceSms: Boolean = false) {
@@ -223,7 +203,7 @@ class LoginViewModelImpl(
processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return
screenState.updateValue { copy(isLoading = true) }
_screenState.updateValue { copy(isLoading = true) }
val currentValidationSid = validationSid.value
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
@@ -242,7 +222,7 @@ class LoginViewModelImpl(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
screenState.updateValue { copy(isLoading = false) }
_screenState.updateValue { copy(isLoading = false) }
captchaSid.setValue { null }
parseError(error)
@@ -250,8 +230,8 @@ class LoginViewModelImpl(
success = { response ->
val exceptionHandler =
CoroutineExceptionHandler { _, _ ->
screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() }
_screenState.updateValue { copy(isLoading = false) }
_loginDialog.setValue { LoginDialog.Error() }
}
viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
@@ -277,8 +257,8 @@ class LoginViewModelImpl(
}
if (exchangeToken == null) {
screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() }
_screenState.updateValue { copy(isLoading = false) }
_loginDialog.setValue { LoginDialog.Error() }
return@launch
}
@@ -316,15 +296,15 @@ class LoginViewModelImpl(
).listenValue(viewModelScope) { state ->
state.processState(
any = {
screenState.updateValue { copy(isLoading = false) }
_screenState.updateValue { copy(isLoading = false) }
},
error = ::parseError,
success = { user ->
if (user == null) {
loginDialog.update { LoginDialog.Error() }
_loginDialog.update { LoginDialog.Error() }
} else {
screenState.updateValue { copy(login = "", password = "") }
isNeedToOpenMain.update { true }
_screenState.updateValue { copy(login = "", password = "") }
_isNeedToOpenMain.update { true }
}
}
)
@@ -347,7 +327,7 @@ class LoginViewModelImpl(
validationType = error.validationType.value,
canResendSms = error.validationResend == "sms"
)
validationArguments.update { arguments }
_validationArguments.update { arguments }
validationSid.update { error.validationSid }
}
@@ -356,12 +336,12 @@ class LoginViewModelImpl(
captchaSid = error.captchaSid,
captchaImageUrl = error.captchaImageUrl
)
captchaArguments.update { arguments }
_captchaArguments.update { arguments }
captchaSid.update { error.captchaSid }
}
OAuthErrorDomain.InvalidCredentialsError -> {
loginDialog.setValue {
_loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.")
}
}
@@ -373,33 +353,33 @@ class LoginViewModelImpl(
restoreUrl = error.restoreUrl,
accessToken = error.accessToken
)
userBannedArguments.update { arguments }
_userBannedArguments.update { arguments }
}
OAuthErrorDomain.WrongValidationCode -> {
isNeedToClearValidationCode.update { true }
_isNeedToClearValidationCode.update { true }
validationCode.update { null }
loginDialog.setValue {
_loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.")
}
}
OAuthErrorDomain.WrongValidationCodeFormat -> {
isNeedToClearValidationCode.update { true }
_isNeedToClearValidationCode.update { true }
validationCode.update { null }
loginDialog.setValue {
_loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.")
}
}
OAuthErrorDomain.TooManyTriesError -> {
loginDialog.setValue {
_loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
}
}
OAuthErrorDomain.UnknownError -> {
loginDialog.setValue { LoginDialog.Error() }
_loginDialog.setValue { LoginDialog.Error() }
}
}
}
@@ -412,11 +392,11 @@ class LoginViewModelImpl(
validationState.value.forEach { result ->
when (result) {
LoginValidationResult.LoginEmpty -> {
screenState.setValue { old -> old.copy(loginError = true) }
_screenState.setValue { old -> old.copy(loginError = true) }
}
LoginValidationResult.PasswordEmpty -> {
screenState.setValue { old -> old.copy(passwordError = true) }
_screenState.setValue { old -> old.copy(passwordError = true) }
}
LoginValidationResult.Empty -> Unit
@@ -1,9 +1,9 @@
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.OAuthUseCaseImpl
import dev.meloda.fast.auth.login.validation.LoginValidator
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
@@ -11,6 +11,6 @@ import org.koin.dsl.module
val loginModule = module {
singleOf(::LoginValidator)
viewModelOf(::LoginViewModelImpl) bind dev.meloda.fast.auth.login.LoginViewModel::class
viewModelOf(::LoginViewModel)
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
}
@@ -8,7 +8,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
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.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -24,11 +23,12 @@ fun NavGraphBuilder.loginScreen(
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController
) {
composable<Login> { backStackEntry ->
val viewModel: LoginViewModel =
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
@@ -55,6 +55,7 @@ fun NavGraphBuilder.loginScreen(
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToValidation = onNavigateToValidation,
onNavigateToSettings = onNavigateToSettings,
validationCode = validationCode,
captchaCode = captchaCode,
viewModel = viewModel
@@ -39,6 +39,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.clip
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -56,7 +58,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState
@@ -76,9 +77,10 @@ fun LoginRoute(
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToSettings: () -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
viewModel: LoginViewModel = koinViewModel()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
@@ -132,7 +134,8 @@ fun LoginRoute(
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked
onLogoClicked = viewModel::onLogoClicked,
onLogoLongClicked = onNavigateToSettings
)
HandleDialogs(
@@ -151,7 +154,8 @@ fun LoginScreen(
onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {},
onLogoClicked: () -> Unit = {}
onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) {
val context = LocalContext.current
val size = LocalSizeConfig.current
@@ -185,7 +189,10 @@ fun LoginScreen(
exit = fadeOut(),
label = "Logo visibility"
) {
Logo(onLogoClicked = onLogoClicked)
Logo(
onLogoClicked = onLogoClicked,
onLogoLongClicked = onLogoLongClicked
)
}
AnimatedVisibility(
@@ -30,7 +30,8 @@ import dev.meloda.fast.ui.theme.LocalSizeConfig
@Composable
fun Logo(
modifier: Modifier = Modifier,
onLogoClicked: () -> Unit = {}
onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) {
val size = LocalSizeConfig.current
@@ -60,7 +61,7 @@ fun Logo(
.combinedClickable(
interactionSource = null,
indication = null,
onLongClick = null,
onLongClick = onLogoLongClicked,
onClick = onLogoClicked
)
)
@@ -38,54 +38,15 @@ import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
interface ConversationsViewModel {
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(
class ConversationsViewModel(
updatesParser: LongPollUpdatesParser,
private val filter: ConversationsFilter,
private val updatesParser: LongPollUpdatesParser,
private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
@@ -93,23 +54,34 @@ class ConversationsViewModelImpl(
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ConversationsViewModel, ViewModel() {
) : ViewModel() {
private val _screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
override val navigation = MutableStateFlow<ConversationNavigation?>(null)
override val dialog = MutableStateFlow<ConversationDialog?>(null)
private val _navigation = MutableStateFlow<ConversationNavigation?>(null)
val navigation = _navigation.asStateFlow()
override val conversations = MutableStateFlow<List<VkConversation>>(emptyList())
override val uiConversations = MutableStateFlow<List<UiConversation>>(emptyList())
private val _dialog = MutableStateFlow<ConversationDialog?>(null)
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 ->
conversations.count(VkConversation::isPinned)
}.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)
override val canPaginate = MutableStateFlow(false)
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConversationId = MutableStateFlow(0L)
@@ -118,7 +90,7 @@ class ConversationsViewModelImpl(
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) }
_screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) }
loadConversations()
@@ -137,11 +109,11 @@ class ConversationsViewModelImpl(
}
}
override fun onNavigationConsumed() {
navigation.setValue { null }
fun onNavigationConsumed() {
_navigation.setValue { null }
}
override fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) {
fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
@@ -170,11 +142,11 @@ class ConversationsViewModelImpl(
syncUiConversation()
}
override fun onDialogDismissed(dialog: ConversationDialog) {
this.dialog.setValue { null }
fun onDialogDismissed(dialog: ConversationDialog) {
_dialog.setValue { null }
}
override fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) {
fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) {
when (dialog) {
is ConversationDialog.ConversationDelete -> Unit
is ConversationDialog.ConversationPin -> Unit
@@ -184,7 +156,7 @@ class ConversationsViewModelImpl(
}
}
override fun onErrorButtonClicked() {
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
@@ -197,22 +169,22 @@ class ConversationsViewModelImpl(
}
}
override fun onPaginationConditionsMet() {
currentOffset.update { conversations.value.size }
fun onPaginationConditionsMet() {
_currentOffset.update { conversations.value.size }
loadConversations()
}
override fun onRefresh() {
fun onRefresh() {
onErrorConsumed()
loadConversations(offset = 0)
}
override fun onConversationItemClick(conversation: UiConversation) {
fun onConversationItemClick(conversation: UiConversation) {
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 {
if (conversation.isExpanded) 0
else conversation.id
@@ -220,13 +192,13 @@ class ConversationsViewModelImpl(
syncUiConversation()
}
override fun onOptionClicked(
fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) {
ConversationOption.Delete -> {
dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) }
_dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) }
}
ConversationOption.MarkAsRead -> {
@@ -240,37 +212,37 @@ class ConversationsViewModelImpl(
}
ConversationOption.Pin -> {
dialog.setValue { ConversationDialog.ConversationPin(conversation.id) }
_dialog.setValue { ConversationDialog.ConversationPin(conversation.id) }
}
ConversationOption.Unpin -> {
dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) }
_dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) }
}
ConversationOption.Archive -> {
dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) }
_dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) }
}
ConversationOption.Unarchive -> {
dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) }
_dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) }
}
}
}
override fun onErrorConsumed() {
baseError.setValue { null }
fun onErrorConsumed() {
_baseError.setValue { null }
}
override fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) }
fun setScrollIndex(index: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) }
}
override fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) }
fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
override fun onCreateChatButtonClicked() {
navigation.setValue { ConversationNavigation.CreateChat }
fun onCreateChatButtonClicked() {
_navigation.setValue { ConversationNavigation.CreateChat }
}
private fun collapseConversations() {
@@ -289,7 +261,7 @@ class ConversationsViewModelImpl(
state.processState(
error = { error ->
val newBaseError = VkUtils.parseError(error)
baseError.update { newBaseError }
_baseError.update { newBaseError }
},
success = { response ->
val conversations = response
@@ -304,7 +276,7 @@ class ConversationsViewModelImpl(
val paginationExhausted = !itemsCountSufficient &&
this.conversations.value.isNotEmpty()
screenState.updateValue {
_screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
}
@@ -321,13 +293,13 @@ class ConversationsViewModelImpl(
conversationsUseCase.storeConversations(response)
this.conversations.emit(fullConversations)
_conversations.emit(fullConversations)
syncUiConversation()
canPaginate.setValue { itemsCountSufficient }
_canPaginate.setValue { itemsCountSufficient }
}
)
screenState.setValue { old ->
_screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
@@ -347,11 +319,11 @@ class ConversationsViewModelImpl(
?: return@processState
newConversations.removeAt(conversationIndex)
conversations.update { newConversations.sorted() }
_conversations.update { newConversations.sorted() }
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)
newConversations.add(pinnedConversationsCount.value, conversation)
conversations.update { newConversations.sorted() }
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
)
@@ -461,7 +433,7 @@ class ConversationsViewModelImpl(
newConversations.add(toPosition, newConversation)
}
conversations.update { newConversations.sorted() }
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
}
@@ -480,7 +452,7 @@ class ConversationsViewModelImpl(
lastMessageId = message.id,
lastCmId = message.cmId
)
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
}
}
@@ -500,7 +472,7 @@ class ConversationsViewModelImpl(
unreadCount = event.unreadCount
)
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
}
}
@@ -520,7 +492,7 @@ class ConversationsViewModelImpl(
unreadCount = event.unreadCount
)
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
}
}
@@ -541,7 +513,7 @@ class ConversationsViewModelImpl(
interactionIds = userIds
)
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
interactionsTimers[peerId]?.let { interactionJob ->
@@ -583,7 +555,7 @@ class ConversationsViewModelImpl(
interactionIds = emptyList()
)
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
interactionJob.timerJob.cancel()
@@ -601,7 +573,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(majorId = event.majorId)
conversations.setValue { newConversations.sorted() }
_conversations.setValue { newConversations.sorted() }
syncUiConversation()
}
}
@@ -617,7 +589,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(minorId = event.minorId)
conversations.setValue { newConversations.sorted() }
_conversations.setValue { newConversations.sorted() }
syncUiConversation()
}
}
@@ -632,7 +604,7 @@ class ConversationsViewModelImpl(
} else {
newConversations.removeAt(conversationIndex)
conversations.setValue { newConversations.sorted() }
_conversations.setValue { newConversations.sorted() }
syncUiConversation()
}
}
@@ -655,7 +627,7 @@ class ConversationsViewModelImpl(
newConversations.removeAt(index)
}
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
}
@@ -669,7 +641,7 @@ class ConversationsViewModelImpl(
newConversations.add(pinnedConversationsCount.value, conversation)
}
conversations.update { newConversations.sorted() }
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
}
@@ -691,7 +663,7 @@ class ConversationsViewModelImpl(
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(inRead = startMessageId)
conversations.update { newConversations }
_conversations.update { newConversations }
syncUiConversation()
}
)
@@ -763,7 +735,7 @@ class ConversationsViewModelImpl(
options = options.toImmutableList()
)
}
uiConversations.setValue { newUiConversations }
_uiConversations.setValue { newUiConversations }
return newUiConversations
}
@@ -1,6 +1,6 @@
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.ConversationsUseCaseImpl
import dev.meloda.fast.model.ConversationsFilter
@@ -22,8 +22,8 @@ val conversationsModule = module {
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
}
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl {
return ConversationsViewModelImpl(
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModel {
return ConversationsViewModel(
filter = filter,
updatesParser = get(),
conversationsUseCase = get(),
@@ -4,7 +4,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
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.model.BaseError
import dev.meloda.fast.model.ConversationsFilter
@@ -33,7 +33,7 @@ fun NavGraphBuilder.conversationsGraph(
navigation<ConversationsGraph>(
startDestination = Conversations
) {
val conversationsViewModel: ConversationsViewModelImpl = with(activity) {
val conversationsViewModel: ConversationsViewModel = with(activity) {
getViewModel(qualifier = named(ConversationsFilter.ALL))
}
composable<Conversations> {
@@ -53,7 +53,7 @@ fun NavGraphBuilder.conversationsGraph(
ConversationsRoute(
viewModel = with(activity) {
getViewModel<ConversationsViewModelImpl>(
getViewModel<ConversationsViewModel>(
qualifier = named(ConversationsFilter.ARCHIVE)
)
},
@@ -2,11 +2,14 @@ package dev.meloda.fast.settings
import android.content.res.Resources
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.DarkMode
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.data.UserConfig
import dev.meloda.fast.data.db.AccountsRepository
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.SettingsKeys
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.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsItem
import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.SettingsShowOptions
import dev.meloda.fast.settings.model.TextProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
interface SettingsViewModel {
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,
class SettingsViewModel(
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val accountsRepository: AccountsRepository,
private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val userSettings: UserSettings,
private val resources: Resources,
private val longPollController: LongPollController
) : SettingsViewModel, ViewModel() {
) : ViewModel() {
override val screenState = MutableStateFlow(SettingsScreenState.EMPTY)
override val hapticType = MutableStateFlow<HapticType?>(null)
private val _screenState = MutableStateFlow(SettingsScreenState.EMPTY)
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())
@@ -68,24 +66,87 @@ class SettingsViewModelImpl(
createSettings()
}
override fun onLogOutAlertDismissed() {
emitShowOptions { old -> old.copy(showLogOut = false) }
fun onDialogConfirmed(dialog: SettingsDialog, bundle: Bundle) {
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() {
withContext(Dispatchers.IO) {
fun onDialogDismissed(dialog: SettingsDialog) {
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(
// async {
// suspendCoroutine { continuation ->
// authUseCase.logout().listenValue(viewModelScope) { state ->
// state.processState(
// any = { continuation.resume(Unit) },
// success = {},
// error = {}
// )
// }
// }
// },
async {
accountsRepository.storeAccounts(
listOf(
@@ -106,22 +167,32 @@ class SettingsViewModelImpl(
}
}
override fun onPerformCrashAlertDismissed() {
emitShowOptions { old -> old.copy(showPerformCrash = false) }
}
override fun onPerformCrashPositiveButtonClicked() {
fun onPerformCrashPositiveButtonClicked() {
throw Exception("Test exception")
}
override fun onSettingsItemClicked(key: String) {
fun onSettingsItemClicked(key: String) {
when (key) {
SettingsKeys.KEY_ACCOUNT_LOGOUT -> {
emitShowOptions { old -> old.copy(showLogOut = true) }
_dialog.setValue { SettingsDialog.LogOut }
}
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 -> {
@@ -132,13 +203,13 @@ class SettingsViewModelImpl(
createSettings()
hapticType.update { HapticType.REJECT }
screenState.setValue { old -> old.copy(showDebugOptions = false) }
_hapticType.update { HapticType.REJECT }
_screenState.setValue { old -> old.copy(showDebugOptions = false) }
}
}
}
override fun onSettingsItemLongClicked(key: String) {
fun onSettingsItemLongClicked(key: String) {
when (key) {
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
if (AppSettings.Debug.showDebugCategory) return
@@ -148,18 +219,18 @@ class SettingsViewModelImpl(
createSettings()
hapticType.update { HapticType.LONG_PRESS }
screenState.setValue { old -> old.copy(showDebugOptions = true) }
_hapticType.update { HapticType.LONG_PRESS }
_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) ->
item.updateValue(newValue)
item.updateText()
screenState.setValue { old ->
_screenState.setValue { old ->
old.copy(
settings = old.settings.toMutableList().apply {
this[index] = item.asPresentation(resources)
@@ -240,13 +311,8 @@ class SettingsViewModelImpl(
}
}
override fun onHapticPerformed() {
hapticType.update { null }
}
private fun emitShowOptions(function: (SettingsShowOptions) -> SettingsShowOptions) {
val newShowOptions = function.invoke(screenState.value.showOptions)
screenState.setValue { old -> old.copy(showOptions = newShowOptions) }
fun onHapticPerformed() {
_hapticType.update { null }
}
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(
key = SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST,
title = UiText.Simple("Hide debug list")
@@ -492,6 +570,8 @@ class SettingsViewModelImpl(
debugPerformCrash,
debugShowCrashAlert,
debugNetworkLogLevel,
debugImportAuthData,
debugExportAuthData
).forEach(debugList::add)
debugList += debugHideDebugList
@@ -521,15 +601,6 @@ class SettingsViewModelImpl(
item.asPresentation(resources)
}
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
_screenState.setValue { old -> old.copy(settings = uiSettings) }
}
}
@@ -1,11 +1,9 @@
package dev.meloda.fast.settings.di
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.SettingsViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
import org.koin.dsl.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
data class SettingsScreenState(
val showOptions: SettingsShowOptions,
val settings: List<UiItem>,
val showDebugOptions: Boolean
) {
companion object {
val EMPTY: SettingsScreenState = SettingsScreenState(
showOptions = SettingsShowOptions.EMPTY,
settings = emptyList(),
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(
onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit
onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
) {
composable<Settings> {
SettingsRoute(
onBack = onBack,
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.HapticType
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.SettingsViewModelImpl
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.UiItem
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.TitleItem
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 kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
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(
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
)
}
}