Update API version (#147)
* Bump VK Api version to 5.238 * Implemented new authorization flow (at the moment, without auto re-requesting token) * Add support for sticker pack preview attachments * Bump LongPoll to version 19 * Improved messages handling * Fixed coloring issues * Cache improvements * Archive screen with full functionality * Recomposition fixes * Markdown support for messages bubbles * Adjust app name font size based on screen width * Navigation related improvements * Add logout functionality
This commit is contained in:
@@ -59,23 +59,23 @@ fun NavGraphBuilder.authNavGraph(
|
||||
|
||||
validationScreen(
|
||||
onBack = {
|
||||
navController.navigateUp()
|
||||
navController.setValidationResult(null)
|
||||
navController.navigateUp()
|
||||
},
|
||||
onResult = { code ->
|
||||
navController.popBackStack()
|
||||
navController.setValidationResult(code)
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
|
||||
captchaScreen(
|
||||
onBack = {
|
||||
navController.navigateUp()
|
||||
navController.setCaptchaResult(null)
|
||||
navController.navigateUp()
|
||||
},
|
||||
onResult = { code ->
|
||||
navController.popBackStack()
|
||||
navController.setCaptchaResult(code)
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ fun NavController.navigateToCaptcha(captchaImageUrl: String) {
|
||||
}
|
||||
|
||||
fun NavController.setCaptchaResult(code: String?) {
|
||||
this.currentBackStackEntry
|
||||
this.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("captcha_code", code)
|
||||
}
|
||||
|
||||
+7
-1
@@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -24,6 +27,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -123,7 +127,9 @@ fun CaptchaScreen(
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Scaffold { padding ->
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -15,18 +15,21 @@ import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.VkConstants
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
import dev.meloda.fast.common.extensions.updateValue
|
||||
import dev.meloda.fast.common.model.LongPollState
|
||||
import dev.meloda.fast.data.State
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.data.api.auth.AuthRepository
|
||||
import dev.meloda.fast.data.db.AccountsRepository
|
||||
import dev.meloda.fast.data.processState
|
||||
import dev.meloda.fast.data.success
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.domain.LoadUserByIdUseCase
|
||||
import dev.meloda.fast.domain.OAuthUseCase
|
||||
import dev.meloda.fast.model.database.AccountEntity
|
||||
import dev.meloda.fast.network.OAuthErrorDomain
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -39,13 +42,14 @@ interface LoginViewModel {
|
||||
val screenState: StateFlow<LoginScreenState>
|
||||
val loginDialog: StateFlow<LoginDialog?>
|
||||
|
||||
val validationCode: StateFlow<String?>
|
||||
val validationArguments: StateFlow<LoginValidationArguments?>
|
||||
val captchaCode: StateFlow<String?>
|
||||
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)
|
||||
|
||||
@@ -63,14 +67,15 @@ interface LoginViewModel {
|
||||
fun onNavigatedToCaptcha()
|
||||
fun onNavigatedToValidation()
|
||||
|
||||
fun onValidationCodeReceived(code: String)
|
||||
fun onCaptchaCodeReceived(code: String)
|
||||
|
||||
fun onLogoLongClicked()
|
||||
fun onValidationCodeReceived(code: String?)
|
||||
fun onValidationCodeCleared()
|
||||
fun onCaptchaCodeReceived(code: String?)
|
||||
fun onCaptchaCodeCleared()
|
||||
}
|
||||
|
||||
class LoginViewModelImpl(
|
||||
private val oAuthUseCase: OAuthUseCase,
|
||||
private val authRepository: AuthRepository,
|
||||
private val loadUserByIdUseCase: LoadUserByIdUseCase,
|
||||
private val accountsRepository: AccountsRepository,
|
||||
private val loginValidator: LoginValidator,
|
||||
@@ -80,27 +85,41 @@ class LoginViewModelImpl(
|
||||
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
|
||||
override val loginDialog = MutableStateFlow<LoginDialog?>(null)
|
||||
|
||||
override val validationCode = MutableStateFlow<String?>(null)
|
||||
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
|
||||
override val captchaCode = MutableStateFlow<String?>(null)
|
||||
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
|
||||
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
|
||||
override val isNeedToOpenMain = MutableStateFlow(false)
|
||||
|
||||
override val isNeedToClearCaptchaCode = MutableStateFlow(false)
|
||||
override val isNeedToClearValidationCode = MutableStateFlow(false)
|
||||
|
||||
private val validationState: StateFlow<List<LoginValidationResult>> =
|
||||
screenState.map(loginValidator::validate)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty))
|
||||
|
||||
private val captchaSid = MutableStateFlow<String?>(null)
|
||||
private val captchaCode = MutableStateFlow<String?>(null)
|
||||
private val validationSid = MutableStateFlow<String?>(null)
|
||||
private val validationCode = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
captchaCode.listenValue(viewModelScope) {
|
||||
if (it != null) {
|
||||
login()
|
||||
}
|
||||
}
|
||||
validationCode.listenValue(viewModelScope) {
|
||||
if (it != null) {
|
||||
login()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
|
||||
onDialogDismissed(dialog)
|
||||
|
||||
when (dialog) {
|
||||
is LoginDialog.Error -> Unit
|
||||
|
||||
LoginDialog.FastAuth -> {
|
||||
val token = bundle.getString("token")?.trim() ?: return
|
||||
fastAuth(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,68 +180,20 @@ class LoginViewModelImpl(
|
||||
validationArguments.update { null }
|
||||
}
|
||||
|
||||
override fun onValidationCodeReceived(code: String) {
|
||||
override fun onValidationCodeReceived(code: String?) {
|
||||
validationCode.update { code }
|
||||
|
||||
login()
|
||||
}
|
||||
|
||||
override fun onCaptchaCodeReceived(code: String) {
|
||||
override fun onValidationCodeCleared() {
|
||||
isNeedToClearValidationCode.update { false }
|
||||
}
|
||||
|
||||
override fun onCaptchaCodeReceived(code: String?) {
|
||||
captchaCode.update { code }
|
||||
|
||||
login()
|
||||
}
|
||||
|
||||
override fun onLogoLongClicked() {
|
||||
loginDialog.setValue { LoginDialog.FastAuth }
|
||||
}
|
||||
|
||||
private fun fastAuth(token: String) {
|
||||
var currentAccount = AccountEntity(
|
||||
userId = -1,
|
||||
accessToken = token,
|
||||
fastToken = null,
|
||||
trustedHash = null
|
||||
).also { account ->
|
||||
UserConfig.currentUserId = account.userId
|
||||
UserConfig.userId = account.userId
|
||||
UserConfig.accessToken = account.accessToken
|
||||
UserConfig.fastToken = account.fastToken
|
||||
UserConfig.trustedHash = account.trustedHash
|
||||
}
|
||||
|
||||
loadUserByIdUseCase(
|
||||
userId = null,
|
||||
fields = VkConstants.USER_FIELDS,
|
||||
nomCase = null
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = {
|
||||
UserConfig.currentUserId = -1
|
||||
UserConfig.userId = -1
|
||||
UserConfig.accessToken = ""
|
||||
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
},
|
||||
success = { response ->
|
||||
val actualUserId = requireNotNull(response).id
|
||||
|
||||
currentAccount = currentAccount.copy(userId = actualUserId)
|
||||
|
||||
UserConfig.userId = actualUserId
|
||||
UserConfig.currentUserId = actualUserId
|
||||
|
||||
startLongPoll()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountsRepository.storeAccounts(listOf(currentAccount))
|
||||
delay(350)
|
||||
isNeedToOpenMain.update { true }
|
||||
}
|
||||
}
|
||||
)
|
||||
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
|
||||
}
|
||||
override fun onCaptchaCodeCleared() {
|
||||
isNeedToClearCaptchaCode.update { false }
|
||||
}
|
||||
|
||||
private fun login(forceSms: Boolean = false) {
|
||||
@@ -239,77 +210,120 @@ class LoginViewModelImpl(
|
||||
processValidation()
|
||||
if (!validationState.value.contains(LoginValidationResult.Valid)) return
|
||||
|
||||
oAuthUseCase.auth(
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
|
||||
val currentValidationSid = validationSid.value
|
||||
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
|
||||
val currentCaptchaSid = captchaSid.value
|
||||
val currentCaptchaCode = captchaCode.value?.takeIf { currentCaptchaSid != null }
|
||||
|
||||
oAuthUseCase.getSilentToken(
|
||||
login = currentState.login,
|
||||
password = currentState.password,
|
||||
forceSms = forceSms,
|
||||
validationCode = validationCode.value,
|
||||
captchaSid = captchaArguments.value?.captchaSid,
|
||||
captchaKey = captchaCode.value
|
||||
validationCode = currentValidationCode,
|
||||
captchaSid = currentCaptchaSid,
|
||||
captchaKey = currentCaptchaCode
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = { error ->
|
||||
Log.d("LoginViewModelImpl", "login: error: $error")
|
||||
|
||||
validationCode.update { null }
|
||||
captchaCode.update { null }
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
captchaSid.setValue { null }
|
||||
|
||||
parseError(error)
|
||||
},
|
||||
success = { response ->
|
||||
val userId = response.userId
|
||||
val accessToken = response.accessToken
|
||||
val exceptionHandler =
|
||||
CoroutineExceptionHandler { _, _ ->
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
}
|
||||
|
||||
if (userId == null || accessToken == null) {
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
return@processState
|
||||
viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
|
||||
val (anonymToken) = authRepository.getAnonymToken(
|
||||
VkConstants.MESSENGER_APP_ID.toString(),
|
||||
VkConstants.MESSENGER_APP_SECRET
|
||||
).success()
|
||||
|
||||
val exchangeSilentTokenResponse = authRepository.exchangeSilentToken(
|
||||
anonymToken = anonymToken,
|
||||
silentToken = response.silentToken,
|
||||
silentUuid = response.silentTokenUuid
|
||||
).success()
|
||||
|
||||
|
||||
val getExchangeTokenResponse =
|
||||
authRepository.getExchangeToken(exchangeSilentTokenResponse.accessToken)
|
||||
.success()
|
||||
|
||||
val exchangeToken =
|
||||
getExchangeTokenResponse.usersTokens.firstOrNull {
|
||||
it.userId == exchangeSilentTokenResponse.userId
|
||||
}
|
||||
|
||||
if (exchangeToken == null) {
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val userId = exchangeSilentTokenResponse.userId
|
||||
val accessToken = exchangeSilentTokenResponse.accessToken
|
||||
|
||||
// TODO: 30-Mar-25, Danil Nikolaev: get fast's app token
|
||||
|
||||
val currentAccount = AccountEntity(
|
||||
userId = userId,
|
||||
accessToken = accessToken,
|
||||
fastToken = null,
|
||||
trustedHash = response.trustedHash,
|
||||
exchangeToken = exchangeToken.commonToken
|
||||
).also { account ->
|
||||
UserConfig.currentUserId = account.userId
|
||||
UserConfig.userId = account.userId
|
||||
UserConfig.accessToken = account.accessToken
|
||||
UserConfig.fastToken = account.fastToken
|
||||
UserConfig.trustedHash = account.trustedHash
|
||||
UserConfig.exchangeToken = account.exchangeToken
|
||||
}
|
||||
|
||||
accountsRepository.storeAccounts(listOf(currentAccount))
|
||||
|
||||
startLongPoll()
|
||||
|
||||
captchaSid.update { null }
|
||||
validationSid.update { null }
|
||||
|
||||
loadUserByIdUseCase(
|
||||
userId = userId,
|
||||
fields = VkConstants.USER_FIELDS,
|
||||
nomCase = null
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
any = {
|
||||
screenState.updateValue { copy(isLoading = false) }
|
||||
},
|
||||
error = ::parseError,
|
||||
success = { user ->
|
||||
if (user == null) {
|
||||
loginDialog.update { LoginDialog.Error() }
|
||||
} else {
|
||||
screenState.updateValue { copy(login = "", password = "") }
|
||||
isNeedToOpenMain.update { true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
loadUserByIdUseCase(
|
||||
userId = userId,
|
||||
fields = VkConstants.USER_FIELDS,
|
||||
nomCase = null
|
||||
)
|
||||
|
||||
val currentAccount = AccountEntity(
|
||||
userId = userId,
|
||||
accessToken = accessToken,
|
||||
fastToken = null,
|
||||
trustedHash = response.validationHash
|
||||
).also { account ->
|
||||
UserConfig.currentUserId = account.userId
|
||||
UserConfig.userId = account.userId
|
||||
UserConfig.accessToken = account.accessToken
|
||||
UserConfig.fastToken = account.fastToken
|
||||
UserConfig.trustedHash = account.trustedHash
|
||||
}
|
||||
|
||||
startLongPoll()
|
||||
|
||||
accountsRepository.storeAccounts(listOf(currentAccount))
|
||||
|
||||
captchaArguments.update { null }
|
||||
captchaCode.update { null }
|
||||
|
||||
validationArguments.update { null }
|
||||
validationCode.update { null }
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
login = "",
|
||||
password = "",
|
||||
)
|
||||
}
|
||||
|
||||
isNeedToOpenMain.update { true }
|
||||
}
|
||||
)
|
||||
screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseError(stateError: State.Error): Boolean {
|
||||
return when (stateError) {
|
||||
private fun parseError(stateError: State.Error) {
|
||||
when (stateError) {
|
||||
is State.Error.OAuthError -> {
|
||||
when (val error = stateError.error) {
|
||||
is OAuthErrorDomain.ValidationRequiredError -> {
|
||||
@@ -321,6 +335,7 @@ class LoginViewModelImpl(
|
||||
canResendSms = error.validationResend == "sms"
|
||||
)
|
||||
validationArguments.update { arguments }
|
||||
validationSid.update { error.validationSid }
|
||||
}
|
||||
|
||||
is OAuthErrorDomain.CaptchaRequiredError -> {
|
||||
@@ -329,6 +344,7 @@ class LoginViewModelImpl(
|
||||
captchaImageUrl = error.captchaImageUrl
|
||||
)
|
||||
captchaArguments.update { arguments }
|
||||
captchaSid.update { error.captchaSid }
|
||||
}
|
||||
|
||||
OAuthErrorDomain.InvalidCredentialsError -> {
|
||||
@@ -348,12 +364,16 @@ class LoginViewModelImpl(
|
||||
}
|
||||
|
||||
OAuthErrorDomain.WrongValidationCode -> {
|
||||
isNeedToClearValidationCode.update { true }
|
||||
validationCode.update { null }
|
||||
loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = "Wrong validation code.")
|
||||
}
|
||||
}
|
||||
|
||||
OAuthErrorDomain.WrongValidationCodeFormat -> {
|
||||
isNeedToClearValidationCode.update { true }
|
||||
validationCode.update { null }
|
||||
loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = "Wrong validation code format.")
|
||||
}
|
||||
@@ -369,18 +389,9 @@ class LoginViewModelImpl(
|
||||
loginDialog.setValue { LoginDialog.Error() }
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
is State.Error.TestError -> {
|
||||
loginDialog.setValue {
|
||||
LoginDialog.Error(errorText = stateError.message)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import androidx.compose.runtime.Immutable
|
||||
@Immutable
|
||||
sealed class LoginDialog {
|
||||
|
||||
data object FastAuth : LoginDialog()
|
||||
|
||||
data class Error(
|
||||
val errorText: String? = null,
|
||||
val errorTextResId: Int? = null
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package dev.meloda.fast.auth.login.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class LoginError {
|
||||
data object Unknown : LoginError()
|
||||
data object WrongCredentials : LoginError()
|
||||
data object TooManyTries : LoginError()
|
||||
data object WrongValidationCode : LoginError()
|
||||
data object WrongValidationCodeFormat : LoginError()
|
||||
data class SimpleError(val message: String): LoginError()
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package dev.meloda.fast.auth.login.navigation
|
||||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
@@ -27,6 +30,23 @@ fun NavGraphBuilder.loginScreen(
|
||||
val viewModel: LoginViewModel =
|
||||
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
|
||||
|
||||
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
|
||||
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(clearValidationCode) {
|
||||
if (clearValidationCode) {
|
||||
backStackEntry.savedStateHandle["validation_code"] = null
|
||||
viewModel.onValidationCodeCleared()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(clearCaptchaCode) {
|
||||
if (clearCaptchaCode) {
|
||||
backStackEntry.savedStateHandle["captcha_code"] = null
|
||||
viewModel.onCaptchaCodeCleared()
|
||||
}
|
||||
}
|
||||
|
||||
val validationCode = backStackEntry.getValidationResult()
|
||||
val captchaCode = backStackEntry.getCaptchaResult()
|
||||
|
||||
|
||||
+20
-99
@@ -4,7 +4,6 @@ import android.os.Bundle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -35,7 +34,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.ContentType
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -56,16 +54,17 @@ 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.LoginError
|
||||
import dev.meloda.fast.auth.login.model.LoginScreenState
|
||||
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
|
||||
import dev.meloda.fast.auth.login.model.LoginValidationArguments
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.components.TextFieldErrorText
|
||||
import dev.meloda.fast.ui.theme.LocalSizeConfig
|
||||
import dev.meloda.fast.ui.util.handleEnterKey
|
||||
import dev.meloda.fast.ui.util.handleTabKey
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
@@ -90,41 +89,36 @@ fun LoginRoute(
|
||||
onBack = viewModel::onBackPressed
|
||||
)
|
||||
|
||||
LaunchedEffect(
|
||||
isNeedToOpenMain,
|
||||
userBannedArguments,
|
||||
captchaArguments,
|
||||
validationArguments,
|
||||
validationCode,
|
||||
captchaCode
|
||||
) {
|
||||
LaunchedEffect(isNeedToOpenMain) {
|
||||
if (isNeedToOpenMain) {
|
||||
viewModel.onNavigatedToMain()
|
||||
onNavigateToMain()
|
||||
}
|
||||
|
||||
}
|
||||
LaunchedEffect(userBannedArguments) {
|
||||
userBannedArguments?.let { arguments ->
|
||||
viewModel.onNavigatedToUserBanned()
|
||||
onNavigateToUserBanned(arguments)
|
||||
}
|
||||
|
||||
}
|
||||
LaunchedEffect(captchaArguments) {
|
||||
captchaArguments?.let { arguments ->
|
||||
viewModel.onNavigatedToCaptcha()
|
||||
onNavigateToCaptcha(arguments)
|
||||
}
|
||||
|
||||
}
|
||||
LaunchedEffect(validationArguments) {
|
||||
validationArguments?.let { arguments ->
|
||||
viewModel.onNavigatedToValidation()
|
||||
onNavigateToValidation(arguments)
|
||||
}
|
||||
|
||||
if (validationCode != null) {
|
||||
viewModel.onValidationCodeReceived(validationCode)
|
||||
}
|
||||
|
||||
if (captchaCode != null) {
|
||||
viewModel.onCaptchaCodeReceived(captchaCode)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(validationCode) {
|
||||
viewModel.onValidationCodeReceived(validationCode)
|
||||
}
|
||||
LaunchedEffect(captchaCode) {
|
||||
viewModel.onCaptchaCodeReceived(captchaCode)
|
||||
}
|
||||
|
||||
LoginScreen(
|
||||
@@ -134,8 +128,7 @@ fun LoginRoute(
|
||||
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
|
||||
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
|
||||
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
|
||||
onSignInButtonClicked = viewModel::onSignInButtonClicked,
|
||||
onLogoLongClicked = viewModel::onLogoLongClicked
|
||||
onSignInButtonClicked = viewModel::onSignInButtonClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
@@ -153,8 +146,7 @@ fun LoginScreen(
|
||||
onPasswordFieldEnterKeyClicked: () -> Unit = {},
|
||||
onPasswordVisibilityButtonClicked: () -> Unit = {},
|
||||
onPasswordFieldGoAction: () -> Unit = {},
|
||||
onSignInButtonClicked: () -> Unit = {},
|
||||
onLogoLongClicked: () -> Unit = {}
|
||||
onSignInButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
val size = LocalSizeConfig.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -181,7 +173,7 @@ fun LoginScreen(
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Logo(onLogoLongClicked = onLogoLongClicked)
|
||||
Logo()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -371,79 +363,8 @@ fun HandleDialogs(
|
||||
onDismissRequest = { onDismissed(loginDialog) },
|
||||
title = stringResource(UiR.string.title_error),
|
||||
text = loginDialog.errorTextResId?.let { stringResource(it) }
|
||||
?: loginDialog.errorText.orEmpty(),
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginDialog.FastAuth -> {
|
||||
SignInAlert(
|
||||
onDismissRequest = { onDismissed(loginDialog) },
|
||||
onConfirmClick = { onConfirmed(loginDialog, bundleOf("token" to it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HandleError(
|
||||
onDismiss: () -> Unit,
|
||||
error: LoginError?,
|
||||
) {
|
||||
when (error) {
|
||||
null -> Unit
|
||||
|
||||
LoginError.Unknown -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Unknown error",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginError.WrongCredentials -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Wrong login or password.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginError.TooManyTries -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Too many tries. Try in another hour or later.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
LoginError.WrongValidationCode -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Wrong validation code.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
LoginError.WrongValidationCodeFormat -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = "Wrong validation code format.",
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
is LoginError.SimpleError -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = "Error",
|
||||
text = error.message,
|
||||
?: loginDialog.errorText
|
||||
?: stringResource(UiR.string.unknown_error_occurred),
|
||||
confirmText = stringResource(id = UiR.string.ok)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.meloda.fast.auth.login.presentation
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -24,21 +25,22 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.theme.LocalSizeConfig
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun Logo(
|
||||
modifier: Modifier = Modifier,
|
||||
onLogoLongClicked: () -> Unit = {}
|
||||
) {
|
||||
fun Logo(modifier: Modifier = Modifier) {
|
||||
val size = LocalSizeConfig.current
|
||||
|
||||
val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp)
|
||||
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 40 else 40)
|
||||
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 40)
|
||||
val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -61,8 +63,14 @@ fun Logo(
|
||||
.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onLongClick = onLogoLongClicked,
|
||||
onClick = {}
|
||||
onLongClick = null,
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
userSettings.onEnableDynamicColorsChanged(
|
||||
!userSettings.enableDynamicColors.value
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package dev.meloda.fast.auth.login.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.auth.BuildConfig
|
||||
import dev.meloda.fast.ui.components.ActionInvokeDismiss
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun SignInAlert(
|
||||
onDismissRequest: () -> Unit = {},
|
||||
onConfirmClick: (token: String) -> Unit = {}
|
||||
) {
|
||||
var tokenText by rememberSaveable {
|
||||
mutableStateOf(BuildConfig.debugToken)
|
||||
}
|
||||
|
||||
val maxWidthModifier = Modifier.fillMaxWidth()
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = "Fast authorization",
|
||||
confirmText = stringResource(id = UiR.string.action_authorize),
|
||||
confirmAction = { onConfirmClick(tokenText) },
|
||||
cancelText = stringResource(id = UiR.string.cancel),
|
||||
actionInvokeDismiss = ActionInvokeDismiss.Always
|
||||
) {
|
||||
Column(modifier = maxWidthModifier) {
|
||||
OutlinedTextField(
|
||||
modifier = maxWidthModifier.padding(horizontal = 16.dp),
|
||||
value = tokenText,
|
||||
onValueChange = { tokenText = it },
|
||||
placeholder = { Text(text = "Access token") },
|
||||
label = { Text(text = "Access token") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ fun NavController.navigateToValidation(arguments: ValidationArguments) {
|
||||
}
|
||||
|
||||
fun NavController.setValidationResult(code: String?) {
|
||||
this.currentBackStackEntry
|
||||
this.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("validation_code", code)
|
||||
}
|
||||
|
||||
+12
-2
@@ -7,10 +7,13 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -23,6 +26,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -35,10 +39,13 @@ 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.autofill.ContentType
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentType
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -146,7 +153,9 @@ fun ValidationScreen(
|
||||
|
||||
var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) }
|
||||
|
||||
Scaffold { padding ->
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -210,7 +219,8 @@ fun ValidationScreen(
|
||||
placeholder = { Text(text = "Code") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.semantics { contentType = ContentType.SmsOtpCode },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_qr_code_24),
|
||||
|
||||
+4
-5
@@ -17,7 +17,6 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
|
||||
import dev.meloda.fast.network.VkErrorCode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
interface ChatMaterialsViewModel {
|
||||
val screenState: StateFlow<ChatMaterialsScreenState>
|
||||
@@ -54,7 +53,7 @@ class ChatMaterialsViewModelImpl(
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
peerId = arguments.peerId,
|
||||
conversationMessageId = arguments.conversationMessageId
|
||||
cmId = arguments.conversationMessageId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,7 +84,7 @@ class ChatMaterialsViewModelImpl(
|
||||
count = LOAD_COUNT,
|
||||
offset = offset,
|
||||
attachmentTypes = listOf(materialType.toString()),
|
||||
conversationMessageId = screenState.value.conversationMessageId
|
||||
cmId = screenState.value.cmId
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
@@ -100,11 +99,11 @@ class ChatMaterialsViewModelImpl(
|
||||
|
||||
val newState = screenState.value.copy(
|
||||
isPaginationExhausted = paginationExhausted,
|
||||
conversationMessageId = if (loadedMaterials.size + offset > 200) {
|
||||
cmId = if (loadedMaterials.size + offset > 200) {
|
||||
currentOffset.setValue { 0 }
|
||||
loadedMaterials.lastOrNull()?.conversationMessageId ?: -1
|
||||
} else {
|
||||
screenState.value.conversationMessageId
|
||||
screenState.value.cmId
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ data class ChatMaterialsScreenState(
|
||||
val attachmentType: String,
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val peerId: Int,
|
||||
val conversationMessageId: Int
|
||||
val peerId: Long,
|
||||
val cmId: Long
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -21,7 +21,7 @@ data class ChatMaterialsScreenState(
|
||||
isPaginating = false,
|
||||
isPaginationExhausted = false,
|
||||
peerId = -1,
|
||||
conversationMessageId = -1
|
||||
cmId = -1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,16 +1,16 @@
|
||||
package dev.meloda.fast.chatmaterials.model
|
||||
|
||||
sealed class UiChatMaterial(
|
||||
open val conversationMessageId: Int
|
||||
open val conversationMessageId: Long
|
||||
) {
|
||||
|
||||
data class Photo(
|
||||
override val conversationMessageId: Int,
|
||||
override val conversationMessageId: Long,
|
||||
val previewUrl: String
|
||||
) : UiChatMaterial(conversationMessageId)
|
||||
|
||||
data class Video(
|
||||
override val conversationMessageId: Int,
|
||||
override val conversationMessageId: Long,
|
||||
val previewUrl: String?,
|
||||
val title: String,
|
||||
val views: Int,
|
||||
@@ -18,7 +18,7 @@ sealed class UiChatMaterial(
|
||||
) : UiChatMaterial(conversationMessageId)
|
||||
|
||||
data class Audio(
|
||||
override val conversationMessageId: Int,
|
||||
override val conversationMessageId: Long,
|
||||
val previewUrl: String?,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
@@ -26,7 +26,7 @@ sealed class UiChatMaterial(
|
||||
) : UiChatMaterial(conversationMessageId)
|
||||
|
||||
data class File(
|
||||
override val conversationMessageId: Int,
|
||||
override val conversationMessageId: Long,
|
||||
val previewUrl: String?,
|
||||
val title: String,
|
||||
val size: String,
|
||||
@@ -34,7 +34,7 @@ sealed class UiChatMaterial(
|
||||
) : UiChatMaterial(conversationMessageId)
|
||||
|
||||
data class Link(
|
||||
override val conversationMessageId: Int,
|
||||
override val conversationMessageId: Long,
|
||||
val previewUrl: String?,
|
||||
val title: String?,
|
||||
val url: String,
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ChatMaterials(
|
||||
val peerId: Int,
|
||||
val conversationMessageId: Int
|
||||
val peerId: Long,
|
||||
val conversationMessageId: Long
|
||||
) {
|
||||
companion object {
|
||||
fun from(savedStateHandle: SavedStateHandle) =
|
||||
@@ -31,7 +31,7 @@ fun NavGraphBuilder.chatMaterialsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToChatMaterials(peerId: Int, conversationMessageId: Int) {
|
||||
fun NavController.navigateToChatMaterials(peerId: Long, conversationMessageId: Long) {
|
||||
this.navigate(
|
||||
ChatMaterials(
|
||||
peerId = peerId,
|
||||
|
||||
+2
-18
@@ -53,9 +53,9 @@ import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -102,23 +102,7 @@ fun AudioMaterialsScreen(
|
||||
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(UiR.string.session_expired),
|
||||
buttonText = stringResource(UiR.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(UiR.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
|
||||
|
||||
+2
-18
@@ -63,9 +63,9 @@ import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -111,23 +111,7 @@ fun FileMaterialsScreen(
|
||||
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(R.string.session_expired),
|
||||
buttonText = stringResource(R.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(R.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
|
||||
|
||||
+2
-18
@@ -63,9 +63,9 @@ import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -111,23 +111,7 @@ fun LinkMaterialsScreen(
|
||||
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(R.string.session_expired),
|
||||
buttonText = stringResource(R.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(R.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
|
||||
|
||||
+2
-18
@@ -46,9 +46,9 @@ import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
|
||||
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -95,23 +95,7 @@ fun PhotoMaterialsScreen(
|
||||
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(R.string.session_expired),
|
||||
buttonText = stringResource(R.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(R.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
|
||||
|
||||
+2
-18
@@ -56,9 +56,9 @@ import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -104,23 +104,7 @@ fun VideoMaterialsScreen(
|
||||
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(R.string.session_expired),
|
||||
buttonText = stringResource(R.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(R.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
|
||||
|
||||
+393
-425
File diff suppressed because it is too large
Load Diff
+25
-2
@@ -3,12 +3,35 @@ package dev.meloda.fast.conversations.di
|
||||
import dev.meloda.fast.conversations.ConversationsViewModelImpl
|
||||
import dev.meloda.fast.domain.ConversationsUseCase
|
||||
import dev.meloda.fast.domain.ConversationsUseCaseImpl
|
||||
import dev.meloda.fast.model.ConversationsFilter
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val conversationsModule = module {
|
||||
viewModel(named(ConversationsFilter.ALL)) {
|
||||
createConversationsViewModel(ConversationsFilter.ALL)
|
||||
}
|
||||
viewModel(named(ConversationsFilter.ARCHIVE)) {
|
||||
createConversationsViewModel(ConversationsFilter.ARCHIVE)
|
||||
}
|
||||
|
||||
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
|
||||
viewModelOf(::ConversationsViewModelImpl)
|
||||
}
|
||||
|
||||
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl {
|
||||
return ConversationsViewModelImpl(
|
||||
filter = filter,
|
||||
updatesParser = get(),
|
||||
conversationsUseCase = get(),
|
||||
messagesUseCase = get(),
|
||||
resources = get(),
|
||||
userSettings = get(),
|
||||
imageLoader = get(),
|
||||
applicationContext = get(),
|
||||
loadConversationsByIdUseCase = get()
|
||||
)
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class ConversationDialog {
|
||||
data class ConversationPin(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationUnpin(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationDelete(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationArchive(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationUnarchive(val conversationId: Long) : ConversationDialog()
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class ConversationNavigation {
|
||||
|
||||
data class MessagesHistory(val peerId: Long) : ConversationNavigation()
|
||||
|
||||
data object CreateChat : ConversationNavigation()
|
||||
}
|
||||
+2
-5
@@ -1,31 +1,28 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
|
||||
import dev.meloda.fast.ui.model.api.UiConversation
|
||||
|
||||
@Immutable
|
||||
data class ConversationsScreenState(
|
||||
val showOptions: ConversationsShowOptions,
|
||||
val conversations: List<UiConversation>,
|
||||
val isLoading: Boolean,
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val profileImageUrl: String?,
|
||||
val scrollIndex: Int,
|
||||
val scrollOffset: Int,
|
||||
val isArchive: Boolean
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY: ConversationsScreenState = ConversationsScreenState(
|
||||
showOptions = ConversationsShowOptions.EMPTY,
|
||||
conversations = emptyList(),
|
||||
isLoading = true,
|
||||
isPaginating = false,
|
||||
isPaginationExhausted = false,
|
||||
profileImageUrl = null,
|
||||
scrollIndex = 0,
|
||||
scrollOffset = 0,
|
||||
isArchive = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import dev.meloda.fast.model.InteractionType
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
data class InteractionJob(
|
||||
val interactionType: InteractionType,
|
||||
val timerJob: Job
|
||||
)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class NewInteractionException : CancellationException()
|
||||
+58
-20
@@ -1,35 +1,73 @@
|
||||
package dev.meloda.fast.conversations.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.conversations.ConversationsViewModel
|
||||
import androidx.navigation.navigation
|
||||
import dev.meloda.fast.conversations.ConversationsViewModelImpl
|
||||
import dev.meloda.fast.conversations.presentation.ConversationsRoute
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.extensions.sharedViewModel
|
||||
import dev.meloda.fast.model.ConversationsFilter
|
||||
import dev.meloda.fast.ui.theme.LocalNavController
|
||||
import dev.meloda.fast.ui.theme.getOrThrow
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
@Serializable
|
||||
object ConversationsGraph
|
||||
|
||||
@Serializable
|
||||
object Conversations
|
||||
|
||||
fun NavGraphBuilder.conversationsScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onConversationItemClicked: (id: Int) -> Unit,
|
||||
onCreateChatClicked: () -> Unit,
|
||||
onScrolledToTop: () -> Unit,
|
||||
navController: NavController,
|
||||
) {
|
||||
composable<Conversations> {
|
||||
val viewModel: ConversationsViewModel =
|
||||
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
|
||||
@Serializable
|
||||
object Archive
|
||||
|
||||
ConversationsRoute(
|
||||
onError = onError,
|
||||
onConversationItemClicked = onConversationItemClicked,
|
||||
onCreateChatButtonClicked = onCreateChatClicked,
|
||||
onScrolledToTop = onScrolledToTop,
|
||||
viewModel = viewModel
|
||||
)
|
||||
fun NavGraphBuilder.conversationsGraph(
|
||||
onError: (BaseError) -> Unit,
|
||||
onNavigateToMessagesHistory: (id: Long) -> Unit,
|
||||
onNavigateToCreateChat: () -> Unit,
|
||||
onScrolledToTop: () -> Unit
|
||||
) {
|
||||
navigation<ConversationsGraph>(
|
||||
startDestination = Conversations
|
||||
) {
|
||||
composable<Conversations> {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.getOrThrow()
|
||||
|
||||
val viewModel: ConversationsViewModelImpl = koinViewModel(
|
||||
qualifier = named(ConversationsFilter.ALL),
|
||||
viewModelStoreOwner = context as AppCompatActivity
|
||||
)
|
||||
|
||||
ConversationsRoute(
|
||||
viewModel = viewModel,
|
||||
onError = onError,
|
||||
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
|
||||
onNavigateToCreateChat = onNavigateToCreateChat,
|
||||
onNavigateToArchive = { navController.navigate(Archive) },
|
||||
onScrolledToTop = onScrolledToTop
|
||||
)
|
||||
}
|
||||
composable<Archive> {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.getOrThrow()
|
||||
|
||||
val viewModel: ConversationsViewModelImpl = koinViewModel(
|
||||
qualifier = named(ConversationsFilter.ARCHIVE),
|
||||
viewModelStoreOwner = context as AppCompatActivity
|
||||
)
|
||||
|
||||
ConversationsRoute(
|
||||
viewModel = viewModel,
|
||||
onBack = navController::navigateUp,
|
||||
onError = onError,
|
||||
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
|
||||
onScrolledToTop = onScrolledToTop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.meloda.fast.conversations.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.os.bundleOf
|
||||
import dev.meloda.fast.conversations.model.ConversationDialog
|
||||
import dev.meloda.fast.conversations.model.ConversationsScreenState
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: ConversationsScreenState,
|
||||
dialog: ConversationDialog?,
|
||||
onConfirmed: (ConversationDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (ConversationDialog) -> Unit = {},
|
||||
onItemPicked: (ConversationDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (dialog) {
|
||||
null -> Unit
|
||||
|
||||
is ConversationDialog.ConversationArchive -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_archive_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_archive),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationUnarchive -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_unarchive_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_unarchive),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationDelete -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_delete_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_delete),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationPin -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_pin_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_pin),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationUnpin -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_unpin_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_unpin),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -7,8 +7,6 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -20,7 +18,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ElevatedAssistChip
|
||||
@@ -61,7 +60,7 @@ val BirthdayColor = Color(0xffb00b69)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ConversationItem(
|
||||
onItemClick: (Int) -> Unit,
|
||||
onItemClick: (UiConversation) -> Unit,
|
||||
onItemLongClick: (conversation: UiConversation) -> Unit,
|
||||
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
|
||||
maxLines: Int,
|
||||
@@ -80,7 +79,7 @@ fun ConversationItem(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { onItemClick(conversation.id) },
|
||||
onClick = { onItemClick(conversation) },
|
||||
onLongClick = {
|
||||
onItemLongClick(conversation)
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
@@ -241,7 +240,7 @@ fun ConversationItem(
|
||||
text = conversation.title,
|
||||
minLines = 1,
|
||||
maxLines = maxLines,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp)
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
|
||||
)
|
||||
|
||||
Row {
|
||||
@@ -329,9 +328,13 @@ fun ConversationItem(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.defaultMinSize(
|
||||
minWidth = 20.dp,
|
||||
minHeight = 20.dp
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = if (count.length > 1) 2.dp else 0.dp)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
@@ -352,18 +355,19 @@ fun ConversationItem(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.padding(start = 8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp)
|
||||
) {
|
||||
conversation.options.forEach { option ->
|
||||
items(conversation.options.toList()) { option ->
|
||||
ElevatedAssistChip(
|
||||
onClick = { onOptionClicked(conversation, option) },
|
||||
leadingIcon = {
|
||||
@@ -379,6 +383,7 @@ fun ConversationItem(
|
||||
Text(text = option.title.getString().orEmpty())
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,5 +398,3 @@ fun ConversationItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+4
-2
@@ -28,13 +28,15 @@ import dev.meloda.fast.ui.model.api.ConversationOption
|
||||
import dev.meloda.fast.ui.model.api.UiConversation
|
||||
import dev.meloda.fast.ui.theme.LocalBottomPadding
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ConversationsList(
|
||||
modifier: Modifier = Modifier,
|
||||
onConversationsClick: (Int) -> Unit,
|
||||
conversations: ImmutableList<UiConversation>,
|
||||
onConversationsClick: (UiConversation) -> Unit,
|
||||
onConversationsLongClick: (UiConversation) -> Unit,
|
||||
screenState: ConversationsScreenState,
|
||||
state: LazyListState,
|
||||
@@ -54,7 +56,7 @@ fun ConversationsList(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
items(
|
||||
items = screenState.conversations,
|
||||
items = conversations.values,
|
||||
key = UiConversation::id,
|
||||
) { conversation ->
|
||||
val isUserAccount by remember(conversation) {
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.meloda.fast.conversations.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.conversations.ConversationsViewModel
|
||||
import dev.meloda.fast.conversations.model.ConversationNavigation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun ConversationsRoute(
|
||||
viewModel: ConversationsViewModel,
|
||||
onBack: (() -> Unit)? = null,
|
||||
onError: (BaseError) -> Unit,
|
||||
onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
|
||||
onNavigateToCreateChat: (() -> Unit)? = null,
|
||||
onNavigateToArchive: (() -> Unit)? = null,
|
||||
onScrolledToTop: () -> Unit,
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
|
||||
val conversations by viewModel.uiConversations.collectAsStateWithLifecycle()
|
||||
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(navigationEvent) {
|
||||
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
|
||||
null -> false
|
||||
|
||||
is ConversationNavigation.CreateChat -> {
|
||||
onNavigateToCreateChat?.invoke()
|
||||
true
|
||||
}
|
||||
|
||||
is ConversationNavigation.MessagesHistory -> {
|
||||
onNavigateToMessagesHistory(navigation.peerId)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBeConsumed) viewModel.onNavigationConsumed()
|
||||
}
|
||||
|
||||
ConversationsScreen(
|
||||
onBack = { onBack?.invoke() },
|
||||
screenState = screenState,
|
||||
conversations = conversations.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
onConversationItemClicked = viewModel::onConversationItemClick,
|
||||
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
|
||||
onOptionClicked = viewModel::onOptionClicked,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onRefreshDropdownItemClicked = viewModel::onRefresh,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
|
||||
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
|
||||
setScrollIndex = viewModel::setScrollIndex,
|
||||
setScrollOffset = viewModel::setScrollOffset,
|
||||
onConsumeReselection = onScrolledToTop,
|
||||
onErrorViewButtonClicked = {
|
||||
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
|
||||
onError(requireNotNull(baseError))
|
||||
} else {
|
||||
viewModel.onErrorButtonClicked()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
+75
-132
@@ -19,7 +19,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -56,68 +57,30 @@ import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.conversations.ConversationsViewModel
|
||||
import dev.meloda.fast.conversations.model.ConversationsScreenState
|
||||
import dev.meloda.fast.conversations.navigation.Conversations
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.model.api.ConversationOption
|
||||
import dev.meloda.fast.ui.model.api.UiConversation
|
||||
import dev.meloda.fast.ui.theme.LocalBottomPadding
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalScrollToTop
|
||||
import dev.meloda.fast.ui.theme.LocalReselectedTab
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import dev.meloda.fast.ui.util.emptyImmutableList
|
||||
import dev.meloda.fast.ui.util.isScrollingUp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun ConversationsRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onConversationItemClicked: (conversationId: Int) -> Unit,
|
||||
onCreateChatButtonClicked: () -> Unit,
|
||||
onScrolledToTop: () -> Unit,
|
||||
viewModel: ConversationsViewModel
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
|
||||
ConversationsScreen(
|
||||
screenState = screenState,
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onConversationItemClicked = { id ->
|
||||
onConversationItemClicked(id)
|
||||
viewModel.onConversationItemClick()
|
||||
},
|
||||
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
|
||||
onOptionClicked = viewModel::onOptionClicked,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onRefreshDropdownItemClicked = viewModel::onRefresh,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onCreateChatButtonClicked = onCreateChatButtonClicked,
|
||||
setScrollIndex = viewModel::setScrollIndex,
|
||||
setScrollOffset = viewModel::setScrollOffset,
|
||||
onScrolledToTop = onScrolledToTop
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
@@ -125,19 +88,22 @@ fun ConversationsRoute(
|
||||
@Composable
|
||||
fun ConversationsScreen(
|
||||
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
|
||||
conversations: ImmutableList<UiConversation> = emptyImmutableList(),
|
||||
baseError: BaseError? = null,
|
||||
canPaginate: Boolean = false,
|
||||
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
|
||||
onConversationItemClicked: (conversationId: Int) -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
onConversationItemClicked: (conversation: UiConversation) -> Unit = {},
|
||||
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
|
||||
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
|
||||
onPaginationConditionsMet: () -> Unit = {},
|
||||
onRefreshDropdownItemClicked: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
onCreateChatButtonClicked: () -> Unit = {},
|
||||
onArchiveActionClicked: () -> Unit = {},
|
||||
setScrollIndex: (Int) -> Unit = {},
|
||||
setScrollOffset: (Int) -> Unit = {},
|
||||
onScrolledToTop: () -> Unit = {}
|
||||
onConsumeReselection: () -> Unit = {},
|
||||
onErrorViewButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
val currentTheme = LocalThemeConfig.current
|
||||
|
||||
@@ -150,14 +116,18 @@ fun ConversationsScreen(
|
||||
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
|
||||
)
|
||||
|
||||
val scrollToTop = LocalScrollToTop.current[Conversations] ?: false
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop) {
|
||||
if (listState.firstVisibleItemIndex > 14) {
|
||||
listState.scrollToItem(14)
|
||||
val currentTabReselected = LocalReselectedTab.current[Conversations] ?: false
|
||||
LaunchedEffect(currentTabReselected) {
|
||||
if (currentTabReselected) {
|
||||
if (screenState.isArchive) {
|
||||
onBack.invoke()
|
||||
} else {
|
||||
if (listState.firstVisibleItemIndex > 14) {
|
||||
listState.scrollToItem(14)
|
||||
}
|
||||
listState.animateScrollToItem(0)
|
||||
onConsumeReselection()
|
||||
}
|
||||
listState.animateScrollToItem(0)
|
||||
onScrolledToTop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,22 +188,40 @@ fun ConversationsScreen(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = if (screenState.isLoading) UiR.string.title_loading
|
||||
else UiR.string.title_conversations
|
||||
id = when {
|
||||
screenState.isLoading -> UiR.string.title_loading
|
||||
screenState.isArchive -> UiR.string.title_archive
|
||||
else -> UiR.string.title_conversations
|
||||
}
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
dropDownMenuExpanded = true
|
||||
navigationIcon = {
|
||||
if (screenState.isArchive) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!screenState.isArchive) {
|
||||
IconButton(onClick = onArchiveActionClicked) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.outline_archive_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = { dropDownMenuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
imageVector = Icons.Rounded.MoreVert,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
@@ -279,7 +267,7 @@ fun ConversationsScreen(
|
||||
)
|
||||
|
||||
val showHorizontalProgressBar by remember(screenState) {
|
||||
derivedStateOf { screenState.isLoading && screenState.conversations.isNotEmpty() }
|
||||
derivedStateOf { screenState.isLoading && conversations.isNotEmpty() }
|
||||
}
|
||||
AnimatedVisibility(showHorizontalProgressBar) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
@@ -290,49 +278,38 @@ fun ConversationsScreen(
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
val offsetY by animateIntAsState(
|
||||
targetValue = if (listState.isScrollingUp()) 0 else 600
|
||||
)
|
||||
if (!screenState.isArchive) {
|
||||
val offsetY by animateIntAsState(
|
||||
targetValue = if (listState.isScrollingUp()) 0 else 600
|
||||
)
|
||||
|
||||
Column {
|
||||
FloatingActionButton(
|
||||
onClick = onCreateChatButtonClicked,
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(0, offsetY)
|
||||
Column {
|
||||
FloatingActionButton(
|
||||
onClick = onCreateChatButtonClicked,
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(0, offsetY)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_create_24),
|
||||
contentDescription = "Add chat button"
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_create_24),
|
||||
contentDescription = "Add chat button"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
|
||||
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(UiR.string.session_expired),
|
||||
buttonText = stringResource(UiR.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(UiR.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(
|
||||
baseError = baseError,
|
||||
onButtonClick = onErrorViewButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.conversations.isEmpty() -> FullScreenLoader()
|
||||
screenState.isLoading && conversations.isEmpty() -> FullScreenLoader()
|
||||
|
||||
else -> {
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
@@ -357,6 +334,7 @@ fun ConversationsScreen(
|
||||
}
|
||||
) {
|
||||
ConversationsList(
|
||||
conversations = conversations,
|
||||
onConversationsClick = onConversationItemClicked,
|
||||
onConversationsLongClick = onConversationItemLongClicked,
|
||||
screenState = screenState,
|
||||
@@ -371,7 +349,7 @@ fun ConversationsScreen(
|
||||
padding = padding
|
||||
)
|
||||
|
||||
if (screenState.conversations.isEmpty()) {
|
||||
if (conversations.isEmpty()) {
|
||||
NoItemsView(
|
||||
buttonText = stringResource(UiR.string.action_refresh),
|
||||
onButtonClick = onRefresh
|
||||
@@ -382,38 +360,3 @@ fun ConversationsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 26.08.2023, Danil Nikolaev: remove usage of viewModel
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: ConversationsScreenState,
|
||||
viewModel: ConversationsViewModel
|
||||
) {
|
||||
val showOptions = screenState.showOptions
|
||||
|
||||
if (showOptions.showDeleteDialog != null) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = viewModel::onDeleteDialogDismissed,
|
||||
title = stringResource(id = UiR.string.confirm_delete_conversation),
|
||||
confirmAction = viewModel::onDeleteDialogPositiveClick,
|
||||
confirmText = stringResource(id = UiR.string.action_delete),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
showOptions.showPinDialog?.let { conversation ->
|
||||
MaterialDialog(
|
||||
onDismissRequest = viewModel::onPinDialogDismissed,
|
||||
title = stringResource(
|
||||
id = if (conversation.isPinned) UiR.string.confirm_unpin_conversation
|
||||
else UiR.string.confirm_pin_conversation
|
||||
),
|
||||
confirmAction = viewModel::onPinDialogPositiveClick,
|
||||
confirmText = stringResource(
|
||||
id = if (conversation.isPinned) UiR.string.action_unpin
|
||||
else UiR.string.action_pin
|
||||
),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-17
@@ -23,8 +23,10 @@ import dev.meloda.fast.model.api.domain.VkAttachment
|
||||
import dev.meloda.fast.model.api.domain.VkConversation
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.model.api.ActionState
|
||||
import dev.meloda.fast.ui.model.api.ConversationOption
|
||||
import dev.meloda.fast.ui.model.api.UiConversation
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import dev.meloda.fast.ui.util.emptyImmutableList
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.math.ln
|
||||
@@ -33,7 +35,9 @@ import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
fun VkConversation.asPresentation(
|
||||
resources: Resources,
|
||||
useContactName: Boolean
|
||||
useContactName: Boolean,
|
||||
isExpanded: Boolean = false,
|
||||
options: ImmutableList<ConversationOption> = emptyImmutableList()
|
||||
): UiConversation = UiConversation(
|
||||
id = id,
|
||||
lastMessageId = lastMessageId,
|
||||
@@ -47,14 +51,15 @@ fun VkConversation.asPresentation(
|
||||
isPinned = majorId > 0,
|
||||
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
|
||||
isBirthday = extractBirthday(this),
|
||||
isUnread = extractReadCondition(this, lastMessage),
|
||||
isUnread = !isRead(),
|
||||
isAccount = isAccount(id),
|
||||
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
|
||||
lastMessage = lastMessage,
|
||||
peerType = peerType,
|
||||
interactionText = extractInteractionText(resources, this),
|
||||
isExpanded = false,
|
||||
options = ImmutableList.empty()
|
||||
isExpanded = isExpanded,
|
||||
isArchived = isArchived,
|
||||
options = options
|
||||
)
|
||||
|
||||
fun VkConversation.extractAvatar() = when (peerType) {
|
||||
@@ -101,7 +106,7 @@ private fun extractUnreadCount(
|
||||
lastMessage: VkMessage?,
|
||||
conversation: VkConversation
|
||||
): String? = when {
|
||||
lastMessage?.isOut == false && !conversation.isInUnread() -> null
|
||||
lastMessage?.isOut == false && conversation.isInRead() -> null
|
||||
conversation.unreadCount == 0 -> null
|
||||
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
|
||||
else -> {
|
||||
@@ -121,7 +126,7 @@ private fun extractUnreadCount(
|
||||
private fun extractMessage(
|
||||
resources: Resources,
|
||||
lastMessage: VkMessage?,
|
||||
peerId: Int,
|
||||
peerId: Long,
|
||||
peerType: PeerType
|
||||
): AnnotatedString {
|
||||
val youPrefix = UiText.Resource(UiR.string.you_message_prefix)
|
||||
@@ -210,7 +215,12 @@ private fun extractMessage(
|
||||
.replace("<br/>", " ")
|
||||
.replace("–", "-")
|
||||
.trim()
|
||||
.let { text -> getTextWithVisualizedMentions(text, Color.Red) }
|
||||
.let { text ->
|
||||
extractTextWithVisualizedMentions(
|
||||
isOut = lastMessage?.isOut == true,
|
||||
originalText = text
|
||||
)
|
||||
}
|
||||
.let { text -> prefix + text }
|
||||
}
|
||||
|
||||
@@ -612,6 +622,9 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
|
||||
AttachmentType.PODCAST -> null
|
||||
AttachmentType.NARRATIVE -> null
|
||||
AttachmentType.ARTICLE -> null
|
||||
AttachmentType.VIDEO_MESSAGE -> null
|
||||
AttachmentType.GROUP_CHAT_STICKER -> UiR.drawable.ic_attachment_sticker
|
||||
AttachmentType.STICKER_PACK_PREVIEW -> null
|
||||
}?.let(UiImage::Resource)
|
||||
}
|
||||
|
||||
@@ -649,10 +662,9 @@ private fun extractForwardsText(
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
private fun getTextWithVisualizedMentions(
|
||||
originalText: String,
|
||||
mentionColor: Color,
|
||||
fun extractTextWithVisualizedMentions(
|
||||
isOut: Boolean,
|
||||
originalText: String
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
|
||||
|
||||
@@ -676,7 +688,7 @@ private fun getTextWithVisualizedMentions(
|
||||
replacements.add(indexRange to replaced)
|
||||
|
||||
mentions += MentionIndex(
|
||||
id = id.toIntOrNull() ?: -1,
|
||||
id = id.toLongOrNull() ?: -1,
|
||||
idPrefix = idPrefix,
|
||||
indexRange = indexRange
|
||||
)
|
||||
@@ -693,7 +705,7 @@ private fun getTextWithVisualizedMentions(
|
||||
val endIndex = mention.indexRange.last
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(color = mentionColor),
|
||||
style = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
@@ -707,7 +719,7 @@ private fun getTextWithVisualizedMentions(
|
||||
}
|
||||
|
||||
data class MentionIndex(
|
||||
val id: Int,
|
||||
val id: Long,
|
||||
val idPrefix: String,
|
||||
val indexRange: IntRange
|
||||
)
|
||||
@@ -755,6 +767,9 @@ private fun getAttachmentUiText(
|
||||
AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
|
||||
AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative
|
||||
AttachmentType.ARTICLE -> UiR.string.message_attachments_article
|
||||
AttachmentType.VIDEO_MESSAGE -> UiR.string.message_attachments_video_message
|
||||
AttachmentType.GROUP_CHAT_STICKER -> UiR.string.message_attachments_group_sticker
|
||||
AttachmentType.STICKER_PACK_PREVIEW -> UiR.string.message_attachments_sticker_pack_preview
|
||||
}.let(UiText::Resource)
|
||||
}
|
||||
|
||||
@@ -796,10 +811,9 @@ private fun extractBirthday(conversation: VkConversation): Boolean {
|
||||
private fun extractReadCondition(
|
||||
conversation: VkConversation,
|
||||
lastMessage: VkMessage?
|
||||
): Boolean = (lastMessage?.isOut == true && conversation.isOutUnread()) ||
|
||||
(lastMessage?.isOut == false && conversation.isInUnread())
|
||||
): Boolean = !conversation.isRead(lastMessage)
|
||||
|
||||
private fun isAccount(peerId: Int) = peerId == UserConfig.userId
|
||||
private fun isAccount(peerId: Long) = peerId == UserConfig.userId
|
||||
|
||||
private fun extractInteractionText(
|
||||
resources: Resources,
|
||||
|
||||
+4
-4
@@ -33,13 +33,13 @@ interface CreateChatViewModel {
|
||||
val currentOffset: StateFlow<Int>
|
||||
val canPaginate: StateFlow<Boolean>
|
||||
|
||||
val isChatCreated: StateFlow<Int?>
|
||||
val isChatCreated: StateFlow<Long?>
|
||||
|
||||
fun onPaginationConditionsMet()
|
||||
fun onRefresh()
|
||||
fun onErrorConsumed()
|
||||
|
||||
fun toggleFriendSelection(userId: Int)
|
||||
fun toggleFriendSelection(userId: Long)
|
||||
|
||||
fun onTitleTextInputChanged(newTitle: String)
|
||||
|
||||
@@ -62,7 +62,7 @@ class CreateChatViewModelImpl(
|
||||
override val currentOffset = MutableStateFlow(0)
|
||||
override val canPaginate = MutableStateFlow(false)
|
||||
|
||||
override val isChatCreated = MutableStateFlow<Int?>(null)
|
||||
override val isChatCreated = MutableStateFlow<Long?>(null)
|
||||
|
||||
private val useContactNames: Boolean = userSettings.useContactNames.value
|
||||
|
||||
@@ -84,7 +84,7 @@ class CreateChatViewModelImpl(
|
||||
baseError.setValue { null }
|
||||
}
|
||||
|
||||
override fun toggleFriendSelection(userId: Int) {
|
||||
override fun toggleFriendSelection(userId: Long) {
|
||||
val newSelectionList = screenState.value.selectedFriendsIds.toMutableList()
|
||||
|
||||
if (newSelectionList.contains(userId)) {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ data class CreateChatScreenState(
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val friends: List<UiFriend>,
|
||||
val selectedFriendsIds: List<Int>,
|
||||
val selectedFriendsIds: List<Long>,
|
||||
val chatTitle: String
|
||||
) {
|
||||
companion object {
|
||||
|
||||
+8
-4
@@ -1,24 +1,28 @@
|
||||
package dev.meloda.fast.conversations.navigation
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.conversations.CreateChatViewModel
|
||||
import dev.meloda.fast.conversations.CreateChatViewModelImpl
|
||||
import dev.meloda.fast.conversations.presentation.CreateChatRoute
|
||||
import dev.meloda.fast.ui.extensions.sharedViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Serializable
|
||||
object CreateChat
|
||||
|
||||
fun NavGraphBuilder.createChatScreen(
|
||||
onChatCreated: (Int) -> Unit,
|
||||
onChatCreated: (Long) -> Unit,
|
||||
navController: NavController,
|
||||
) {
|
||||
composable<CreateChat> {
|
||||
val viewModel: CreateChatViewModel =
|
||||
it.sharedViewModel<CreateChatViewModelImpl>(navController = navController)
|
||||
val context = LocalContext.current
|
||||
val viewModel: CreateChatViewModel = koinViewModel<CreateChatViewModelImpl>(
|
||||
viewModelStoreOwner = context as AppCompatActivity
|
||||
)
|
||||
|
||||
CreateChatRoute(
|
||||
onError = {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ fun CreateChatItem(
|
||||
friend: UiFriend,
|
||||
maxLines: Int,
|
||||
isSelected: Boolean,
|
||||
onItemClicked: (Int) -> Unit
|
||||
onItemClicked: (Long) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ fun CreateChatList(
|
||||
maxLines: Int,
|
||||
modifier: Modifier,
|
||||
padding: PaddingValues,
|
||||
onItemClicked: (Int) -> Unit,
|
||||
onItemClicked: (Long) -> Unit,
|
||||
onTitleTextInputChanged: (String) -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
+5
-20
@@ -68,6 +68,7 @@ import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.IconButton
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.isScrollingUp
|
||||
@@ -77,7 +78,7 @@ import dev.meloda.fast.ui.R as UiR
|
||||
fun CreateChatRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatCreated: (Int) -> Unit,
|
||||
onChatCreated: (Long) -> Unit,
|
||||
viewModel: CreateChatViewModel
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
@@ -87,7 +88,7 @@ fun CreateChatRoute(
|
||||
|
||||
LaunchedEffect(isChatCreated) {
|
||||
if (isChatCreated != null) {
|
||||
onChatCreated(isChatCreated ?: -1)
|
||||
onChatCreated(isChatCreated ?: -1L)
|
||||
viewModel.onNavigatedBack()
|
||||
}
|
||||
}
|
||||
@@ -120,7 +121,7 @@ fun CreateChatScreen(
|
||||
onBack: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
onCreateChatButtonClicked: () -> Unit = {},
|
||||
onItemClicked: (Int) -> Unit = {},
|
||||
onItemClicked: (Long) -> Unit = {},
|
||||
onTitleTextInputChanged: (String) -> Unit = {}
|
||||
) {
|
||||
val currentTheme = LocalThemeConfig.current
|
||||
@@ -267,23 +268,7 @@ fun CreateChatScreen(
|
||||
) { padding ->
|
||||
when {
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(UiR.string.session_expired),
|
||||
buttonText = stringResource(UiR.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(UiR.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
|
||||
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ object Friends
|
||||
fun NavGraphBuilder.friendsScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onPhotoClicked: (url: String) -> Unit,
|
||||
onMessageClicked: (userId: Int) -> Unit,
|
||||
onMessageClicked: (userid: Long) -> Unit,
|
||||
onScrolledToTop: () -> Unit
|
||||
) {
|
||||
composable<Friends> {
|
||||
|
||||
@@ -36,7 +36,7 @@ fun FriendItem(
|
||||
friend: UiFriend,
|
||||
maxLines: Int,
|
||||
onPhotoClicked: (url: String) -> Unit,
|
||||
onMessageClicked: (userId: Int) -> Unit
|
||||
onMessageClicked: (userid: Long) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
|
||||
@@ -37,7 +37,7 @@ fun FriendsList(
|
||||
maxLines: Int,
|
||||
padding: PaddingValues,
|
||||
onPhotoClicked: (url: String) -> Unit,
|
||||
onMessageClicked: (userId: Int) -> Unit,
|
||||
onMessageClicked: (userid: Long) -> Unit,
|
||||
setCanScrollBackward: (Boolean) -> Unit
|
||||
) {
|
||||
LaunchedEffect(listState) {
|
||||
|
||||
+8
-25
@@ -1,6 +1,7 @@
|
||||
package dev.meloda.fast.friends.presentation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
@@ -30,13 +31,12 @@ import dev.meloda.fast.friends.FriendsViewModel
|
||||
import dev.meloda.fast.friends.FriendsViewModelImpl
|
||||
import dev.meloda.fast.friends.OnlineFriendsViewModelImpl
|
||||
import dev.meloda.fast.friends.navigation.Friends
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.FullScreenLoader
|
||||
import dev.meloda.fast.ui.components.NoItemsView
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalScrollToTop
|
||||
import dev.meloda.fast.ui.theme.LocalReselectedTab
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -52,16 +52,16 @@ fun FriendsScreen(
|
||||
tabIndex: Int,
|
||||
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
|
||||
onPhotoClicked: (url: String) -> Unit = {},
|
||||
onMessageClicked: (userId: Int) -> Unit = {},
|
||||
onMessageClicked: (userid: Long) -> Unit = {},
|
||||
setCanScrollBackward: (Boolean) -> Unit = {},
|
||||
onScrolledToTop: () -> Unit = {}
|
||||
) {
|
||||
val context: Context = LocalContext.current
|
||||
val viewModel: FriendsViewModel =
|
||||
if (tabIndex == 0) {
|
||||
koinViewModel<FriendsViewModelImpl>()
|
||||
koinViewModel<FriendsViewModelImpl>(viewModelStoreOwner = context as AppCompatActivity)
|
||||
} else {
|
||||
koinViewModel<OnlineFriendsViewModelImpl>()
|
||||
koinViewModel<OnlineFriendsViewModelImpl>(viewModelStoreOwner = context as AppCompatActivity)
|
||||
}
|
||||
|
||||
LaunchedEffect(orderType) {
|
||||
@@ -96,7 +96,7 @@ fun FriendsScreen(
|
||||
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
|
||||
)
|
||||
|
||||
val scrollToTop = LocalScrollToTop.current[Friends] ?: false
|
||||
val scrollToTop = LocalReselectedTab.current[Friends] ?: false
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop) {
|
||||
if (listState.firstVisibleItemIndex > 14) {
|
||||
@@ -136,24 +136,7 @@ fun FriendsScreen(
|
||||
val hazeState = LocalHazeState.current
|
||||
|
||||
baseError?.let { error ->
|
||||
when (error) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(R.string.session_expired),
|
||||
buttonText = stringResource(R.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = error.message,
|
||||
buttonText = stringResource(R.string.try_again),
|
||||
onButtonClick = viewModel::onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VkErrorView(baseError = error)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ import dev.meloda.fast.ui.R as UiR
|
||||
fun FriendsRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onPhotoClicked: (url: String) -> Unit,
|
||||
onMessageClicked: (userId: Int) -> Unit,
|
||||
onMessageClicked: (userid: Long) -> Unit,
|
||||
onScrolledToTop: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
+126
-84
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.conena.nanokt.collections.indexOfFirstOrNull
|
||||
import com.conena.nanokt.text.isEmptyOrBlank
|
||||
import com.conena.nanokt.text.isNotEmptyOrBlank
|
||||
import dev.meloda.fast.common.VkConstants
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
@@ -32,6 +33,7 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
|
||||
import dev.meloda.fast.domain.MessagesUseCase
|
||||
import dev.meloda.fast.messageshistory.model.ActionMode
|
||||
import dev.meloda.fast.messageshistory.model.MessageDialog
|
||||
import dev.meloda.fast.messageshistory.model.MessageNavigation
|
||||
import dev.meloda.fast.messageshistory.model.MessageOption
|
||||
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
@@ -51,15 +53,15 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
interface MessagesHistoryViewModel {
|
||||
|
||||
val screenState: StateFlow<MessagesHistoryScreenState>
|
||||
val navigation: StateFlow<MessageNavigation?>
|
||||
val messages: StateFlow<List<VkMessage>>
|
||||
val uiMessages: StateFlow<List<UiItem>>
|
||||
val messageDialog: StateFlow<MessageDialog?>
|
||||
val dialog: StateFlow<MessageDialog?>
|
||||
val selectedMessages: StateFlow<List<VkMessage>>
|
||||
|
||||
val isNeedToScrollToIndex: StateFlow<Int?>
|
||||
@@ -70,6 +72,10 @@ interface MessagesHistoryViewModel {
|
||||
val currentOffset: StateFlow<Int>
|
||||
val canPaginate: StateFlow<Boolean>
|
||||
|
||||
fun onNavigationConsumed()
|
||||
|
||||
fun onTopBarClicked()
|
||||
|
||||
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle)
|
||||
fun onDialogDismissed(dialog: MessageDialog)
|
||||
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle)
|
||||
@@ -85,10 +91,10 @@ interface MessagesHistoryViewModel {
|
||||
|
||||
fun onPaginationConditionsMet()
|
||||
|
||||
fun onMessageClicked(messageId: Int)
|
||||
fun onMessageLongClicked(messageId: Int)
|
||||
fun onMessageClicked(messageId: Long)
|
||||
fun onMessageLongClicked(messageId: Long)
|
||||
|
||||
fun onPinnedMessageClicked(messageId: Int)
|
||||
fun onPinnedMessageClicked(messageId: Long)
|
||||
fun onUnpinMessageClicked()
|
||||
|
||||
fun onDeleteSelectedMessagesClicked()
|
||||
@@ -106,7 +112,8 @@ class MessagesHistoryViewModelImpl(
|
||||
) : MessagesHistoryViewModel, ViewModel() {
|
||||
|
||||
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
|
||||
override val messageDialog = MutableStateFlow<MessageDialog?>(null)
|
||||
override val navigation = MutableStateFlow<MessageNavigation?>(null)
|
||||
override val dialog = MutableStateFlow<MessageDialog?>(null)
|
||||
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
|
||||
|
||||
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
|
||||
@@ -149,6 +156,21 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigationConsumed() {
|
||||
navigation.setValue { null }
|
||||
}
|
||||
|
||||
override fun onTopBarClicked() {
|
||||
val cmId = messages.value.firstOrNull()?.cmId ?: return
|
||||
|
||||
navigation.setValue {
|
||||
MessageNavigation.ChatMaterials(
|
||||
peerId = screenState.value.conversationId,
|
||||
cmId = cmId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) {
|
||||
onDialogDismissed(dialog)
|
||||
|
||||
@@ -223,7 +245,7 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
|
||||
override fun onDialogDismissed(dialog: MessageDialog) {
|
||||
messageDialog.setValue { null }
|
||||
this.dialog.setValue { null }
|
||||
}
|
||||
|
||||
override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
|
||||
@@ -241,13 +263,13 @@ class MessagesHistoryViewModelImpl(
|
||||
MessageOption.Forward -> {}
|
||||
|
||||
MessageOption.Pin -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessagePin(dialog.message.id)
|
||||
}
|
||||
}
|
||||
|
||||
MessageOption.Unpin -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageUnpin(dialog.message.id)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +284,7 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
MessageOption.MarkAsImportant,
|
||||
MessageOption.UnmarkAsImportant -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageMarkImportance(
|
||||
message = dialog.message,
|
||||
isImportant = option is MessageOption.MarkAsImportant
|
||||
@@ -272,7 +294,7 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
MessageOption.MarkAsSpam,
|
||||
MessageOption.UnmarkAsSpam -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageSpam(
|
||||
message = dialog.message,
|
||||
isSpam = option is MessageOption.MarkAsSpam
|
||||
@@ -283,7 +305,7 @@ class MessagesHistoryViewModelImpl(
|
||||
MessageOption.Edit -> {}
|
||||
|
||||
MessageOption.Delete -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageDelete(dialog.message)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +384,7 @@ class MessagesHistoryViewModelImpl(
|
||||
loadMessagesHistory()
|
||||
}
|
||||
|
||||
override fun onMessageClicked(messageId: Int) {
|
||||
override fun onMessageClicked(messageId: Long) {
|
||||
val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return
|
||||
|
||||
if (selectedMessages.value.isNotEmpty()) {
|
||||
@@ -379,13 +401,13 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
syncUiMessages()
|
||||
} else {
|
||||
messageDialog.setValue {
|
||||
dialog.setValue {
|
||||
MessageDialog.MessageOptions(currentMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageLongClicked(messageId: Int) {
|
||||
override fun onMessageLongClicked(messageId: Long) {
|
||||
val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return
|
||||
|
||||
val isSelected = selectedMessages.value.contains(currentMessage)
|
||||
@@ -399,7 +421,7 @@ class MessagesHistoryViewModelImpl(
|
||||
syncUiMessages()
|
||||
}
|
||||
|
||||
override fun onPinnedMessageClicked(messageId: Int) {
|
||||
override fun onPinnedMessageClicked(messageId: Long) {
|
||||
val uiMessages = uiMessages.value
|
||||
val messageIndex = uiMessages.indexOfFirstOrNull {
|
||||
it is UiItem.Message && it.id == messageId
|
||||
@@ -414,13 +436,13 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
override fun onUnpinMessageClicked() {
|
||||
val pinnedMessageId = screenState.value.pinnedMessage?.id ?: return
|
||||
messageDialog.setValue {
|
||||
dialog.setValue {
|
||||
MessageDialog.MessageUnpin(pinnedMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteSelectedMessagesClicked() {
|
||||
messageDialog.setValue {
|
||||
dialog.setValue {
|
||||
MessageDialog.MessagesDelete(selectedMessages.value)
|
||||
}
|
||||
}
|
||||
@@ -434,7 +456,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return
|
||||
|
||||
val randomIds = messages.value.map(VkMessage::randomId)
|
||||
if (message.randomId != 0 && message.randomId in randomIds) return
|
||||
if (message.randomId != 0L && message.randomId in randomIds) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
newMessages.add(0, message)
|
||||
@@ -463,13 +485,13 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val messages = messages.value
|
||||
val index = messages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = messages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // диалога нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
val newConversation = screenState.value.conversation.copy(
|
||||
inRead = event.messageId
|
||||
inReadCmId = event.cmId
|
||||
)
|
||||
|
||||
screenState.setValue { old ->
|
||||
@@ -484,13 +506,13 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val messages = messages.value
|
||||
val index = messages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = messages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
val newConversation = screenState.value.conversation.copy(
|
||||
outRead = event.messageId
|
||||
outReadCmId = event.cmId
|
||||
)
|
||||
|
||||
screenState.setValue { old ->
|
||||
@@ -505,7 +527,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
@@ -520,10 +542,12 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.message.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val maxDate = newMessages.maxOf(VkMessage::date)
|
||||
val minDate = newMessages.minOf(VkMessage::date)
|
||||
|
||||
if (event.message.date !in minDate..maxDate) return
|
||||
if (event.message.date < minDate) { // сообщения не должно быть в списке
|
||||
// pizdets
|
||||
return
|
||||
}
|
||||
|
||||
newMessages.add(event.message)
|
||||
messages.setValue { newMessages.sorted() }
|
||||
@@ -534,7 +558,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
@@ -550,7 +574,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
@@ -578,30 +602,33 @@ class MessagesHistoryViewModelImpl(
|
||||
private fun loadConversation() {
|
||||
Log.d("MessagesHistoryViewModelImpl", "loadConversation()")
|
||||
|
||||
loadConversationsByIdUseCase(listOf(screenState.value.conversationId))
|
||||
.listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { response ->
|
||||
val conversation = response.firstOrNull() ?: return@listenValue
|
||||
val title = conversation.extractTitle(
|
||||
useContactName = AppSettings.General.useContactNames,
|
||||
resources = resourceProvider.resources
|
||||
loadConversationsByIdUseCase(
|
||||
peerIds = listOf(screenState.value.conversationId),
|
||||
extended = true,
|
||||
fields = VkConstants.ALL_FIELDS
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { response ->
|
||||
val conversation = response.firstOrNull() ?: return@listenValue
|
||||
val title = conversation.extractTitle(
|
||||
useContactName = AppSettings.General.useContactNames,
|
||||
resources = resourceProvider.resources
|
||||
)
|
||||
val avatar = conversation.extractAvatar()
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
conversation = conversation,
|
||||
title = title,
|
||||
avatar = avatar
|
||||
)
|
||||
val avatar = conversation.extractAvatar()
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
conversation = conversation,
|
||||
title = title,
|
||||
avatar = avatar
|
||||
)
|
||||
}
|
||||
|
||||
conversation.pinnedMessage?.let(::handlePinnedMessage)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
conversation.pinnedMessage?.let(::handlePinnedMessage)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinnedMessage(pinnedMessage: VkMessage?) {
|
||||
@@ -736,7 +763,7 @@ class MessagesHistoryViewModelImpl(
|
||||
dateDiff
|
||||
} else {
|
||||
val idDiff = m2.id - m1.id
|
||||
idDiff
|
||||
idDiff.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,14 +772,14 @@ class MessagesHistoryViewModelImpl(
|
||||
lastMessageText = screenState.value.message.text
|
||||
|
||||
val newMessage = VkMessage(
|
||||
id = -1 - sendingMessages.size,
|
||||
conversationMessageId = -1,
|
||||
id = -1L - sendingMessages.size,
|
||||
cmId = -1L - sendingMessages.size,
|
||||
text = lastMessageText,
|
||||
isOut = true,
|
||||
peerId = screenState.value.conversationId,
|
||||
fromId = UserConfig.userId,
|
||||
date = (System.currentTimeMillis() / 1000).toInt(),
|
||||
randomId = Random.nextInt(),
|
||||
randomId = Random.nextInt().toLong(),
|
||||
action = null,
|
||||
actionMemberId = null,
|
||||
actionText = null,
|
||||
@@ -769,7 +796,11 @@ class MessagesHistoryViewModelImpl(
|
||||
actionUser = null,
|
||||
actionGroup = null,
|
||||
isPinned = false,
|
||||
pinnedAt = null
|
||||
isSpam = false,
|
||||
pinnedAt = null,
|
||||
|
||||
// TODO: 04-Apr-25, Danil Nikolaev: implement
|
||||
formatData = null,
|
||||
)
|
||||
sendingMessages += newMessage
|
||||
messages.setValue { old -> listOf(newMessage).plus(old) }
|
||||
@@ -792,7 +823,7 @@ class MessagesHistoryViewModelImpl(
|
||||
state.processState(
|
||||
any = { sendingMessages.remove(newMessage) },
|
||||
error = { error ->
|
||||
val failedId = -500_000 - failedMessages.size
|
||||
val failedId = -500_000L - failedMessages.size
|
||||
val newFailedMessage = newMessage.copy(id = failedId)
|
||||
failedMessages += newFailedMessage
|
||||
|
||||
@@ -801,11 +832,13 @@ class MessagesHistoryViewModelImpl(
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
},
|
||||
success = { messageId ->
|
||||
success = { response ->
|
||||
val newMessages = messages.value.toMutableList()
|
||||
newMessages[newMessages.indexOf(newMessage)] = newMessage.copy(id = messageId)
|
||||
newMessages[newMessages.indexOf(newMessage)] = newMessage.copy(
|
||||
id = response.messageId,
|
||||
cmId = response.cmId
|
||||
)
|
||||
messages.setValue { newMessages }
|
||||
|
||||
syncUiMessages()
|
||||
}
|
||||
)
|
||||
@@ -813,7 +846,7 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
|
||||
private fun markAsImportant(
|
||||
messageIds: List<Int>,
|
||||
messageIds: List<Long>,
|
||||
important: Boolean,
|
||||
) {
|
||||
messagesUseCase.markAsImportant(
|
||||
@@ -841,7 +874,7 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
|
||||
private fun deleteMessage(
|
||||
messageIds: List<Int>,
|
||||
messageIds: List<Long>,
|
||||
spam: Boolean = false,
|
||||
deleteForAll: Boolean = false,
|
||||
onSuccess: () -> Unit = {}
|
||||
@@ -866,39 +899,48 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun pinMessage(messageId: Int) {
|
||||
private fun pinMessage(messageId: Long) {
|
||||
messagesUseCase.pin(
|
||||
peerId = screenState.value.conversationId,
|
||||
messageId = messageId,
|
||||
conversationMessageId = null
|
||||
cmId = null
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { pinnedMessage ->
|
||||
handlePinnedMessage(pinnedMessage)
|
||||
val newMessages = messages.value
|
||||
.toMutableList()
|
||||
.map { message ->
|
||||
message.copy(isPinned = message.id == messageId)
|
||||
}
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == messageId }
|
||||
|
||||
if (index == null) {// сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
newMessages[index] = pinnedMessage
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unpinMessage(messageId: Int) {
|
||||
private fun unpinMessage(messageId: Long) {
|
||||
messagesUseCase.unpin(screenState.value.conversationId)
|
||||
.listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = {
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirst { it.id == messageId }
|
||||
newMessages[index] = newMessages[index].copy(isPinned = false)
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == messageId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
newMessages[index] = newMessages[index].copy(isPinned = false)
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
}
|
||||
|
||||
handlePinnedMessage(null)
|
||||
}
|
||||
@@ -908,8 +950,8 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
fun editMessage(
|
||||
originalMessage: VkMessage,
|
||||
peerId: Int,
|
||||
messageId: Int,
|
||||
peerid: Long,
|
||||
messageid: Long,
|
||||
newText: String? = null,
|
||||
attachments: List<VkAttachment>? = null,
|
||||
) {
|
||||
@@ -1001,7 +1043,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// TODO: 25.08.2023, Danil Nikolaev: this and down below - rewrite
|
||||
|
||||
// suspend fun uploadPhoto(
|
||||
// peerId: Int,
|
||||
// peerid: Long,
|
||||
// photo: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
@@ -1021,7 +1063,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun getPhotoMessageUploadServer(peerId: Int) {
|
||||
// private suspend fun getPhotoMessageUploadServer(peerid: Long) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
@@ -1218,7 +1260,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// }
|
||||
|
||||
// suspend fun uploadFile(
|
||||
// peerId: Int,
|
||||
// peerid: Long,
|
||||
// file: File,
|
||||
// name: String,
|
||||
// type: FilesRepository.FileType,
|
||||
@@ -1235,7 +1277,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// }
|
||||
|
||||
// private suspend fun getFileMessageUploadServer(
|
||||
// peerId: Int,
|
||||
// peerid: Long,
|
||||
// type: FilesRepository.FileType,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
@@ -1314,14 +1356,14 @@ class MessagesHistoryViewModelImpl(
|
||||
//
|
||||
//object MessagesUnpinEvent : VkEvent()
|
||||
//
|
||||
//data class MessagesDeleteEvent(val peerId: Int, val messagesIds: List<Int>) : VkEvent()
|
||||
//data class MessagesDeleteEvent(val peerid: Long, val messagesIds: List<Int>) : VkEvent()
|
||||
//
|
||||
//data class MessagesEditEvent(val message: VkMessageDomain) : VkEvent()
|
||||
//
|
||||
//data class MessagesReadEvent(
|
||||
// val isOut: Boolean,
|
||||
// val peerId: Int,
|
||||
// val messageId: Int,
|
||||
// val peerid: Long,
|
||||
// val messageid: Long,
|
||||
//) : VkEvent()
|
||||
//
|
||||
//data class MessagesNewEvent(
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import dev.meloda.fast.model.api.domain.VkMessage
|
||||
@Immutable
|
||||
sealed class MessageDialog {
|
||||
data class MessageOptions(val message: VkMessage) : MessageDialog()
|
||||
data class MessagePin(val messageId: Int) : MessageDialog()
|
||||
data class MessageUnpin(val messageId: Int) : MessageDialog()
|
||||
data class MessagePin(val messageId: Long) : MessageDialog()
|
||||
data class MessageUnpin(val messageId: Long) : MessageDialog()
|
||||
data class MessageDelete(val message: VkMessage) : MessageDialog()
|
||||
data class MessagesDelete(val messages: List<VkMessage>) : MessageDialog()
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class MessageNavigation {
|
||||
|
||||
data class ChatMaterials(
|
||||
val peerId: Long,
|
||||
val cmId: Long
|
||||
) : MessageNavigation()
|
||||
}
|
||||
+1
-1
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
|
||||
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import dev.meloda.fast.model.api.domain.VkMessage
|
||||
|
||||
@Immutable
|
||||
data class MessagesHistoryScreenState(
|
||||
val conversationId: Int,
|
||||
val conversationId: Long,
|
||||
val title: String,
|
||||
val status: String?,
|
||||
val avatar: UiImage,
|
||||
|
||||
+10
-10
@@ -4,18 +4,18 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
|
||||
sealed class UiItem(
|
||||
open val id: Int,
|
||||
val cmId: Int
|
||||
open val id: Long,
|
||||
val cmId: Long
|
||||
) {
|
||||
|
||||
data class Message(
|
||||
override val id: Int,
|
||||
val conversationMessageId: Int,
|
||||
val text: String?,
|
||||
override val id: Long,
|
||||
val conversationMessageId: Long,
|
||||
val text: AnnotatedString?,
|
||||
val isOut: Boolean,
|
||||
val fromId: Int,
|
||||
val fromId: Long,
|
||||
val date: String,
|
||||
val randomId: Int,
|
||||
val randomId: Long,
|
||||
val isInChat: Boolean,
|
||||
val name: String,
|
||||
val showDate: Boolean,
|
||||
@@ -31,9 +31,9 @@ sealed class UiItem(
|
||||
) : UiItem(id, conversationMessageId)
|
||||
|
||||
data class ActionMessage(
|
||||
override val id: Int,
|
||||
val conversationMessageId: Int,
|
||||
override val id: Long,
|
||||
val conversationMessageId: Long,
|
||||
val text: AnnotatedString,
|
||||
val actionCmId: Int?
|
||||
val actionCmId: Long?
|
||||
) : UiItem(id, conversationMessageId)
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,17 +27,17 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
|
||||
fun NavGraphBuilder.messagesHistoryScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
|
||||
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit
|
||||
) {
|
||||
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
|
||||
MessagesHistoryRoute(
|
||||
onError = onError,
|
||||
onBack = onBack,
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onNavigateToChatMaterials = onNavigateToChatMaterials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Int) {
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Long) {
|
||||
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
|
||||
}
|
||||
|
||||
+140
-104
@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
@@ -38,7 +40,7 @@ import dev.meloda.fast.ui.R as UiR
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String?,
|
||||
text: AnnotatedString?,
|
||||
isOut: Boolean,
|
||||
date: String?,
|
||||
edited: Boolean,
|
||||
@@ -55,122 +57,156 @@ fun MessageBubble(
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
|
||||
val textColor = if (!isOut) {
|
||||
val contentColor = if (!isOut) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
val minDateContainerWidth by remember(edited, isOut, pinned, important) {
|
||||
derivedStateOf {
|
||||
val mainPart = if (edited) 50.dp else 30.dp
|
||||
val readIndicatorPart = if (isOut) 14.dp else 0.dp
|
||||
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
|
||||
val importantIndicatorPart = if (important) 14.dp else 0.dp
|
||||
|
||||
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
|
||||
if (text != null) {
|
||||
val textLambda: @Composable () -> Unit = remember {
|
||||
{
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.align(Alignment.Center)
|
||||
.padding(end = 4.dp)
|
||||
.padding(end = dateContainerWidth)
|
||||
.padding(end = 4.dp)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
SelectionContainer {
|
||||
textLambda.invoke()
|
||||
}
|
||||
} else {
|
||||
textLambda.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = dateContainerWidth)
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
if (important) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.round_star_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (pinned) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
val minDateContainerWidth by remember(edited, isOut, pinned, important) {
|
||||
derivedStateOf {
|
||||
val mainPart = if (edited) 50.dp else 30.dp
|
||||
val readIndicatorPart = if (isOut) 14.dp else 0.dp
|
||||
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
|
||||
val importantIndicatorPart = if (important) 14.dp else 0.dp
|
||||
|
||||
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
}
|
||||
if (text != null) {
|
||||
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
|
||||
{
|
||||
Text(
|
||||
text = kotlin.run {
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
text.spanStyles.map { spanStyleRange ->
|
||||
val updatedSpanStyle =
|
||||
if (spanStyleRange.item.color == Color.Red) {
|
||||
spanStyleRange.item.copy(color =
|
||||
if (isOut) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
} else {
|
||||
spanStyleRange.item
|
||||
}
|
||||
|
||||
builder.addStyle(
|
||||
style = updatedSpanStyle,
|
||||
start = spanStyleRange.start,
|
||||
end = spanStyleRange.end
|
||||
)
|
||||
}
|
||||
|
||||
text.paragraphStyles.forEach { style ->
|
||||
builder.addStyle(
|
||||
style = style.item,
|
||||
start = style.start,
|
||||
end = style.end
|
||||
)
|
||||
}
|
||||
|
||||
builder.toAnnotatedString()
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.align(Alignment.Center)
|
||||
.padding(end = 4.dp)
|
||||
.padding(end = dateContainerWidth)
|
||||
.padding(end = 4.dp)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
SelectionContainer {
|
||||
textLambda.invoke()
|
||||
}
|
||||
} else {
|
||||
textLambda.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = dateContainerWidth)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
if (important) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.round_star_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (pinned) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
}
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.messageshistory.model.MessageDialog
|
||||
import dev.meloda.fast.messageshistory.model.MessageOption
|
||||
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
dialog: MessageDialog?,
|
||||
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (MessageDialog) -> Unit = {},
|
||||
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (dialog) {
|
||||
null -> Unit
|
||||
|
||||
is MessageDialog.MessageOptions -> {
|
||||
MessageOptionsDialog(
|
||||
screenState = screenState,
|
||||
message = dialog.message,
|
||||
onDismissed = { onDismissed(dialog) },
|
||||
onItemPicked = { bundle -> onItemPicked(dialog, bundle) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = listOf(dialog.message),
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagesDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = dialog.messages,
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagePin,
|
||||
is MessageDialog.MessageUnpin -> {
|
||||
MessagePinStateDialog(
|
||||
pin = dialog is MessageDialog.MessagePin,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageMarkImportance -> {
|
||||
MessageImportanceDialog(
|
||||
important = dialog.isImportant,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageSpam -> {
|
||||
MessageSpamDialog(
|
||||
spam = dialog.isSpam,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MessageOptionsDialog(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
message: VkMessage,
|
||||
onDismissed: () -> Unit = {},
|
||||
onItemPicked: (Bundle) -> Unit
|
||||
) {
|
||||
val options = mutableListOf<MessageOption>()
|
||||
if (message.isFailed()) {
|
||||
options += MessageOption.Retry
|
||||
} else {
|
||||
options += MessageOption.Reply
|
||||
options += MessageOption.ForwardHere
|
||||
options += MessageOption.Forward
|
||||
|
||||
if (message.isPeerChat() && screenState.conversation.canChangePin) {
|
||||
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
|
||||
}
|
||||
|
||||
if (!message.isRead(screenState.conversation)) {
|
||||
options += MessageOption.Read
|
||||
}
|
||||
|
||||
options += MessageOption.Copy
|
||||
|
||||
if (message.isOut) {
|
||||
val diff = System.currentTimeMillis() - message.date * 1000L
|
||||
if (diff - TimeUnit.DAYS.toMillis(1) <= 0) {
|
||||
options += MessageOption.Edit
|
||||
}
|
||||
}
|
||||
|
||||
options += if (message.isImportant) MessageOption.UnmarkAsImportant
|
||||
else MessageOption.MarkAsImportant
|
||||
|
||||
|
||||
if (!message.isOut) {
|
||||
options += if (message.isSpam) MessageOption.UnmarkAsSpam
|
||||
else MessageOption.MarkAsSpam
|
||||
}
|
||||
}
|
||||
|
||||
options += MessageOption.Delete
|
||||
|
||||
val messageOptions = options.map { option ->
|
||||
Triple(
|
||||
stringResource(option.titleResId),
|
||||
painterResource(option.iconResId),
|
||||
when {
|
||||
option in listOf(
|
||||
MessageOption.Delete,
|
||||
MessageOption.MarkAsSpam
|
||||
) -> MaterialTheme.colorScheme.error
|
||||
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(onDismissRequest = onDismissed) {
|
||||
messageOptions
|
||||
.forEachIndexed { index, (title, painter, tintColor) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row {
|
||||
Text(text = title)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
tint = tintColor
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onDismissed()
|
||||
val pickedOption = options[index]
|
||||
onItemPicked(bundleOf("option" to pickedOption))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageDeleteDialog(
|
||||
messages: List<VkMessage>,
|
||||
onConfirmed: (Bundle) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
var forEveryone by remember {
|
||||
mutableStateOf(
|
||||
!messages.any { it.peerId == UserConfig.userId }
|
||||
&& messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldBeDisabled by remember(messages) {
|
||||
mutableStateOf(
|
||||
messages.any { it.peerId == UserConfig.userId }
|
||||
|| messages.all(VkMessage::isFailed)
|
||||
|| !messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(R.string.delete_message_title),
|
||||
confirmText = stringResource(R.string.action_delete),
|
||||
confirmAction = {
|
||||
onConfirmed(
|
||||
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
|
||||
)
|
||||
},
|
||||
cancelText = stringResource(R.string.cancel),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!shouldBeDisabled) {
|
||||
Modifier.clickable { forEveryone = !forEveryone }
|
||||
} else Modifier)
|
||||
.fillMaxWidth()
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = forEveryone,
|
||||
onCheckedChange = null,
|
||||
enabled = !shouldBeDisabled
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
LocalContentAlpha(
|
||||
alpha = if (shouldBeDisabled) ContentAlpha.disabled
|
||||
else ContentAlpha.high
|
||||
) {
|
||||
Text(text = stringResource(R.string.delete_message_for_everyone))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagePinStateDialog(
|
||||
pin: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (pin) R.string.pin_message_title
|
||||
else R.string.unpin_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (pin) R.string.pin_message_text
|
||||
else R.string.unpin_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (pin) R.string.action_pin
|
||||
else R.string.action_unpin
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageImportanceDialog(
|
||||
important: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (important) R.string.important_message_title
|
||||
else R.string.unimportant_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (important) R.string.important_message_text
|
||||
else R.string.unimportant_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (important) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageSpamDialog(
|
||||
spam: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (spam) R.string.spam_message_title
|
||||
else R.string.unspam_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (spam) R.string.spam_message_text
|
||||
else R.string.unspam_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (spam) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.meloda.fast.messageshistory.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.UserSettings
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import dev.meloda.fast.messageshistory.model.MessageNavigation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
|
||||
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
|
||||
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(navigationEvent) {
|
||||
val needToConsume = when (val navigation = navigationEvent) {
|
||||
null -> false
|
||||
|
||||
is MessageNavigation.ChatMaterials -> {
|
||||
val (peerId, cmId) = navigation
|
||||
onNavigateToChatMaterials(peerId, cmId)
|
||||
true
|
||||
}
|
||||
}
|
||||
if (needToConsume) viewModel.onNavigationConsumed()
|
||||
}
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
messages = messages.toImmutableList(),
|
||||
uiMessages = uiMessages.toImmutableList(),
|
||||
scrollIndex = scrollIndex,
|
||||
selectedMessages = selectedMessages.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onClose = viewModel::onCloseButtonClicked,
|
||||
onScrolledToIndex = viewModel::onScrolledToIndex,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onTopBarClicked = viewModel::onTopBarClicked,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
|
||||
onMessageClicked = viewModel::onMessageClicked,
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
+61
-438
@@ -1,6 +1,5 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -41,7 +40,6 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -56,7 +54,6 @@ import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -82,9 +79,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.HapticFeedbackConstantsCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
@@ -93,377 +88,21 @@ import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import dev.meloda.fast.messageshistory.model.ActionMode
|
||||
import dev.meloda.fast.messageshistory.model.MessageDialog
|
||||
import dev.meloda.fast.messageshistory.model.MessageOption
|
||||
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.messageshistory.util.firstMessage
|
||||
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.ErrorView
|
||||
import dev.meloda.fast.ui.components.IconButton
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
import dev.meloda.fast.ui.util.emptyImmutableList
|
||||
import dev.meloda.fast.ui.util.getImage
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
|
||||
val messageDialog by viewModel.messageDialog.collectAsStateWithLifecycle()
|
||||
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
messages = messages.toImmutableList(),
|
||||
uiMessages = uiMessages.toImmutableList(),
|
||||
scrollIndex = scrollIndex,
|
||||
selectedMessages = selectedMessages.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onClose = viewModel::onCloseButtonClicked,
|
||||
onScrolledToIndex = viewModel::onScrolledToIndex,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
|
||||
onMessageClicked = viewModel::onMessageClicked,
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
messageDialog = messageDialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
messageDialog: MessageDialog?,
|
||||
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (MessageDialog) -> Unit = {},
|
||||
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (messageDialog) {
|
||||
null -> Unit
|
||||
|
||||
is MessageDialog.MessageOptions -> {
|
||||
MessageOptionsDialog(
|
||||
screenState = screenState,
|
||||
message = messageDialog.message,
|
||||
onDismissed = { onDismissed(messageDialog) },
|
||||
onItemPicked = { bundle -> onItemPicked(messageDialog, bundle) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = listOf(messageDialog.message),
|
||||
onConfirmed = { onConfirmed(messageDialog, it) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagesDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = messageDialog.messages,
|
||||
onConfirmed = { onConfirmed(messageDialog, it) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagePin,
|
||||
is MessageDialog.MessageUnpin -> {
|
||||
MessagePinStateDialog(
|
||||
pin = messageDialog is MessageDialog.MessagePin,
|
||||
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageMarkImportance -> {
|
||||
MessageImportanceDialog(
|
||||
important = messageDialog.isImportant,
|
||||
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageSpam -> {
|
||||
MessageSpamDialog(
|
||||
spam = messageDialog.isSpam,
|
||||
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MessageOptionsDialog(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
message: VkMessage,
|
||||
onDismissed: () -> Unit = {},
|
||||
onItemPicked: (Bundle) -> Unit
|
||||
) {
|
||||
val options = mutableListOf<MessageOption>()
|
||||
if (message.isFailed()) {
|
||||
options += MessageOption.Retry
|
||||
} else {
|
||||
options += MessageOption.Reply
|
||||
options += MessageOption.ForwardHere
|
||||
options += MessageOption.Forward
|
||||
|
||||
if (message.isPeerChat() && screenState.conversation.canChangePin) {
|
||||
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
|
||||
}
|
||||
|
||||
if (!message.isRead(screenState.conversation)) {
|
||||
options += MessageOption.Read
|
||||
}
|
||||
|
||||
options += MessageOption.Copy
|
||||
|
||||
if (message.isOut) {
|
||||
val diff = System.currentTimeMillis() - message.date * 1000L
|
||||
if (diff - TimeUnit.DAYS.toMillis(1) <= 0) {
|
||||
options += MessageOption.Edit
|
||||
}
|
||||
}
|
||||
|
||||
options += if (message.isImportant) MessageOption.UnmarkAsImportant
|
||||
else MessageOption.MarkAsImportant
|
||||
|
||||
|
||||
if (!message.isOut) {
|
||||
options += if (message.isSpam) MessageOption.UnmarkAsSpam
|
||||
else MessageOption.MarkAsSpam
|
||||
}
|
||||
}
|
||||
|
||||
options += MessageOption.Delete
|
||||
|
||||
val messageOptions = options.map { option ->
|
||||
Triple(
|
||||
stringResource(option.titleResId),
|
||||
painterResource(option.iconResId),
|
||||
when {
|
||||
option in listOf(
|
||||
MessageOption.Delete,
|
||||
MessageOption.MarkAsSpam
|
||||
) -> MaterialTheme.colorScheme.error
|
||||
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(onDismissRequest = onDismissed) {
|
||||
messageOptions
|
||||
.forEachIndexed { index, (title, painter, tintColor) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row {
|
||||
Text(text = title)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
tint = tintColor
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onDismissed()
|
||||
val pickedOption = options[index]
|
||||
onItemPicked(bundleOf("option" to pickedOption))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageDeleteDialog(
|
||||
messages: List<VkMessage>,
|
||||
onConfirmed: (Bundle) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
var forEveryone by remember {
|
||||
mutableStateOf(
|
||||
!messages.any { it.peerId == UserConfig.userId }
|
||||
&& messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldBeDisabled by remember(messages) {
|
||||
mutableStateOf(
|
||||
messages.any { it.peerId == UserConfig.userId }
|
||||
|| messages.all(VkMessage::isFailed)
|
||||
|| !messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(UiR.string.delete_message_title),
|
||||
confirmText = stringResource(UiR.string.action_delete),
|
||||
confirmAction = {
|
||||
onConfirmed(
|
||||
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
|
||||
)
|
||||
},
|
||||
cancelText = stringResource(UiR.string.cancel),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!shouldBeDisabled) {
|
||||
Modifier.clickable { forEveryone = !forEveryone }
|
||||
} else Modifier)
|
||||
.fillMaxWidth()
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = forEveryone,
|
||||
onCheckedChange = null,
|
||||
enabled = !shouldBeDisabled
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
LocalContentAlpha(
|
||||
alpha = if (shouldBeDisabled) ContentAlpha.disabled
|
||||
else ContentAlpha.high
|
||||
) {
|
||||
Text(text = stringResource(UiR.string.delete_message_for_everyone))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagePinStateDialog(
|
||||
pin: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (pin) UiR.string.pin_message_title
|
||||
else UiR.string.unpin_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (pin) UiR.string.pin_message_text
|
||||
else UiR.string.unpin_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (pin) UiR.string.action_pin
|
||||
else UiR.string.action_unpin
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageImportanceDialog(
|
||||
important: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (important) UiR.string.important_message_title
|
||||
else UiR.string.unimportant_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (important) UiR.string.important_message_text
|
||||
else UiR.string.unimportant_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (important) UiR.string.action_mark
|
||||
else UiR.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageSpamDialog(
|
||||
spam: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (spam) UiR.string.spam_message_title
|
||||
else UiR.string.unspam_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (spam) UiR.string.spam_message_text
|
||||
else UiR.string.unspam_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (spam) UiR.string.action_mark
|
||||
else UiR.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
@@ -483,16 +122,16 @@ fun MessagesHistoryScreen(
|
||||
onClose: () -> Unit = {},
|
||||
onScrolledToIndex: () -> Unit = {},
|
||||
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
|
||||
onTopBarClicked: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
onPaginationConditionsMet: () -> Unit = {},
|
||||
onMessageInputChanged: (TextFieldValue) -> Unit = {},
|
||||
onAttachmentButtonClicked: () -> Unit = {},
|
||||
onActionButtonClicked: () -> Unit = {},
|
||||
onEmojiButtonLongClicked: () -> Unit = {},
|
||||
onMessageClicked: (Int) -> Unit = {},
|
||||
onMessageLongClicked: (Int) -> Unit = {},
|
||||
onPinnedMessageClicked: (Int) -> Unit = {},
|
||||
onMessageClicked: (Long) -> Unit = {},
|
||||
onMessageLongClicked: (Long) -> Unit = {},
|
||||
onPinnedMessageClicked: (Long) -> Unit = {},
|
||||
onUnpinMessageButtonClicked: () -> Unit = {},
|
||||
onDeleteSelectedButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
@@ -516,12 +155,7 @@ fun MessagesHistoryScreen(
|
||||
onBack = onClose
|
||||
)
|
||||
|
||||
val pinnedMessage by remember(screenState) {
|
||||
derivedStateOf {
|
||||
screenState.conversation.pinnedMessage
|
||||
}
|
||||
}
|
||||
|
||||
val pinnedMessage = screenState.pinnedMessage
|
||||
|
||||
val paginationConditionMet by remember(canPaginate, listState) {
|
||||
derivedStateOf {
|
||||
@@ -598,7 +232,13 @@ fun MessagesHistoryScreen(
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (screenState.isLoading && messages.isEmpty()) Modifier
|
||||
else Modifier.clickable {
|
||||
onTopBarClicked()
|
||||
}
|
||||
),
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -606,23 +246,41 @@ fun MessagesHistoryScreen(
|
||||
) {
|
||||
if (selectedMessages.isEmpty()) {
|
||||
val avatar = screenState.avatar.getImage()
|
||||
if (avatar is Painter) {
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
if (screenState.conversationId == UserConfig.userId) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(24.dp),
|
||||
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24),
|
||||
contentDescription = "Favorites icon",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.avatar.getImage(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
|
||||
)
|
||||
if (avatar is Painter) {
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.avatar.getImage(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
@@ -705,9 +363,6 @@ fun MessagesHistoryScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (screenState.isLoading) {
|
||||
return@TopAppBar
|
||||
}
|
||||
IconButton(
|
||||
onClick = { dropDownMenuExpanded = true }
|
||||
) {
|
||||
@@ -725,28 +380,6 @@ fun MessagesHistoryScreen(
|
||||
},
|
||||
offset = DpOffset(x = (-4).dp, y = (-60).dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
|
||||
// TODO: 11/07/2024, Danil Nikolaev: to VM
|
||||
|
||||
// TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat)
|
||||
onChatMaterialsDropdownItemClicked(
|
||||
screenState.conversationId,
|
||||
uiMessages.values.firstMessage().conversationMessageId
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(UiR.string.chat_materials_action_title))
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.ic_multimedia),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onRefresh()
|
||||
@@ -808,10 +441,13 @@ fun MessagesHistoryScreen(
|
||||
isPaginating = screenState.isPaginating,
|
||||
messageBarHeight = messageBarHeight,
|
||||
onRequestScrollToCmId = { cmId ->
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(
|
||||
index = uiMessages.values.indexOfMessageByCmId(cmId)
|
||||
)
|
||||
val index = uiMessages.values.indexOfMessageByCmId(cmId)
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(index = index)
|
||||
}
|
||||
}
|
||||
},
|
||||
onMessageClicked = { id ->
|
||||
@@ -847,12 +483,15 @@ fun MessagesHistoryScreen(
|
||||
.clip(RoundedCornerShape(36.dp))
|
||||
.then(
|
||||
if (theme.enableBlur) {
|
||||
Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.ultraThin()
|
||||
).border(1.dp, MaterialTheme.colorScheme.outlineVariant,
|
||||
RoundedCornerShape(36.dp)
|
||||
)
|
||||
Modifier
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.ultraThin()
|
||||
)
|
||||
.border(
|
||||
1.dp, MaterialTheme.colorScheme.outlineVariant,
|
||||
RoundedCornerShape(36.dp)
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.animateContentSize()
|
||||
@@ -1042,23 +681,7 @@ fun MessagesHistoryScreen(
|
||||
}
|
||||
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(UiR.string.session_expired),
|
||||
buttonText = stringResource(UiR.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(UiR.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -45,13 +45,10 @@ fun MessagesList(
|
||||
uiMessages: ImmutableList<UiItem>,
|
||||
isPaginating: Boolean,
|
||||
messageBarHeight: Dp,
|
||||
onRequestScrollToCmId: (cmId: Int) -> Unit = {},
|
||||
onMessageClicked: (Int) -> Unit = {},
|
||||
onMessageLongClicked: (Int) -> Unit = {}
|
||||
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
|
||||
onMessageClicked: (Long) -> Unit = {},
|
||||
onMessageLongClicked: (Long) -> Unit = {}
|
||||
) {
|
||||
val messages = remember(uiMessages) {
|
||||
uiMessages.toList()
|
||||
}
|
||||
val theme = LocalThemeConfig.current
|
||||
val view = LocalView.current
|
||||
|
||||
@@ -77,7 +74,7 @@ fun MessagesList(
|
||||
}
|
||||
|
||||
items(
|
||||
items = messages,
|
||||
items = uiMessages.values,
|
||||
key = UiItem::id,
|
||||
contentType = { item ->
|
||||
when (item) {
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ fun OutgoingMessageBubble(
|
||||
) {
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text.orDots(),
|
||||
text = message.text,
|
||||
isOut = true,
|
||||
date = message.date,
|
||||
edited = message.isEdited,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ fun PinnedMessageContainer(
|
||||
title: String,
|
||||
summary: AnnotatedString?,
|
||||
canChangePin: Boolean,
|
||||
onPinnedMessageClicked: (Int) -> Unit = {},
|
||||
onPinnedMessageClicked: (Long) -> Unit = {},
|
||||
onUnpinMessageButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package dev.meloda.fast.messageshistory.util
|
||||
|
||||
import com.conena.nanokt.collections.indexOfFirstOrNull
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
|
||||
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
|
||||
|
||||
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
|
||||
|
||||
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
|
||||
fun List<UiItem>.indexOfMessageById(messageId: Long): Int =
|
||||
indexOfFirst { it.id == messageId }
|
||||
|
||||
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
|
||||
fun List<UiItem>.findMessageById(messageId: Long): UiItem.Message? =
|
||||
firstOrNull { it.id == messageId } as UiItem.Message?
|
||||
|
||||
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
|
||||
indexOfFirst { it.cmId == cmId }
|
||||
fun List<UiItem>.indexOfMessageByCmId(cmId: Long): Int? =
|
||||
indexOfFirstOrNull { it.cmId == cmId }
|
||||
|
||||
fun List<UiItem>.findMessageByCmId(cmId: Int): UiItem.Message =
|
||||
fun List<UiItem>.findMessageByCmId(cmId: Long): UiItem.Message =
|
||||
first { it.cmId == cmId } as UiItem.Message
|
||||
|
||||
+155
-4
@@ -1,10 +1,15 @@
|
||||
package dev.meloda.fast.messageshistory.util
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.AnnotatedString.Annotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.StringAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
import dev.meloda.fast.common.model.UiText
|
||||
@@ -15,6 +20,7 @@ import dev.meloda.fast.data.VkMemoryCache
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.model.api.PeerType
|
||||
import dev.meloda.fast.model.api.domain.FormatDataType
|
||||
import dev.meloda.fast.model.api.domain.VkConversation
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.R
|
||||
@@ -22,7 +28,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
private fun isAccount(fromId: Int) = fromId == UserConfig.userId
|
||||
private fun isAccount(fromId: Long) = fromId == UserConfig.userId
|
||||
|
||||
fun VkMessage.extractAvatar() = when {
|
||||
isUser() -> {
|
||||
@@ -101,7 +107,7 @@ fun VkMessage.asPresentation(
|
||||
): UiItem = when {
|
||||
action != null -> UiItem.ActionMessage(
|
||||
id = id,
|
||||
conversationMessageId = conversationMessageId,
|
||||
conversationMessageId = cmId,
|
||||
text = extractActionText(
|
||||
resources = resourceProvider.resources,
|
||||
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
|
||||
@@ -112,8 +118,12 @@ fun VkMessage.asPresentation(
|
||||
|
||||
else -> UiItem.Message(
|
||||
id = id,
|
||||
conversationMessageId = conversationMessageId,
|
||||
text = text,
|
||||
conversationMessageId = cmId,
|
||||
text = extractTextWithVisualizedMentions(
|
||||
isOut = isOut,
|
||||
originalText = text,
|
||||
formatData = formatData
|
||||
),
|
||||
isOut = isOut,
|
||||
fromId = fromId,
|
||||
date = extractDate(),
|
||||
@@ -542,3 +552,144 @@ fun VkMessage.extractActionText(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 04-Apr-25, Danil Nikolaev: get rid of method duplication
|
||||
fun extractTextWithVisualizedMentions(
|
||||
isOut: Boolean,
|
||||
originalText: String?,
|
||||
formatData: VkMessage.FormatData?
|
||||
): AnnotatedString? {
|
||||
if (originalText == null) return null
|
||||
|
||||
val annotations =
|
||||
mutableListOf<AnnotatedString.Range<out Annotation>>()
|
||||
|
||||
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
|
||||
|
||||
val mentions = mutableListOf<MentionIndex>()
|
||||
|
||||
var currentIndex = 0
|
||||
val replacements = mutableListOf<Pair<IntRange, String>>()
|
||||
|
||||
val newText = regex.replace(originalText) { matchResult ->
|
||||
val idPrefix = matchResult.groups[1]?.value.orEmpty()
|
||||
val startIndex = matchResult.range.first
|
||||
val endIndex = matchResult.range.last
|
||||
|
||||
val id = matchResult.groups[2]?.value ?: ""
|
||||
|
||||
val replaced = matchResult.groups[3]?.value.orEmpty()
|
||||
|
||||
val indexRange =
|
||||
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
|
||||
|
||||
replacements.add(indexRange to replaced)
|
||||
|
||||
mentions += MentionIndex(
|
||||
id = id.toLongOrNull() ?: -1,
|
||||
idPrefix = idPrefix,
|
||||
indexRange = indexRange
|
||||
)
|
||||
|
||||
currentIndex += replaced.length - (endIndex - startIndex + 1)
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
mentions.forEach { mention ->
|
||||
val startIndex = mention.indexRange.first
|
||||
val endIndex = mention.indexRange.last
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(mention.id.toString()),
|
||||
tag = mention.idPrefix,
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
}
|
||||
|
||||
if (formatData == null) return AnnotatedString(text = newText, annotations = annotations)
|
||||
|
||||
var current = 0
|
||||
|
||||
val newOffsets = formatData.items.map { (offset, length) ->
|
||||
val r = replacements.filter { (range, _) ->
|
||||
(range - current) collidesWith (offset..<offset + length) || offset > range.first
|
||||
}
|
||||
|
||||
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
|
||||
|
||||
offset + current
|
||||
}
|
||||
|
||||
formatData.items.forEachIndexed { index, item ->
|
||||
val offset = newOffsets[index]
|
||||
|
||||
val spanStyle = when (item.type) {
|
||||
FormatDataType.BOLD -> {
|
||||
SpanStyle(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
FormatDataType.ITALIC -> {
|
||||
SpanStyle(fontStyle = FontStyle.Italic)
|
||||
}
|
||||
|
||||
FormatDataType.UNDERLINE -> {
|
||||
SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
|
||||
FormatDataType.URL -> {
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(item.url.orEmpty()),
|
||||
start = offset,
|
||||
end = offset + item.length,
|
||||
tag = newText.substring(offset, offset + item.length)
|
||||
)
|
||||
|
||||
if (isOut) {
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
} else {
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = spanStyle,
|
||||
start = offset,
|
||||
end = offset + item.length
|
||||
)
|
||||
}
|
||||
|
||||
return AnnotatedString(text = newText, annotations = annotations)
|
||||
}
|
||||
|
||||
data class MentionIndex(
|
||||
val id: Long,
|
||||
val idPrefix: String,
|
||||
val indexRange: IntRange
|
||||
)
|
||||
|
||||
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
|
||||
return this.start < other.endInclusive && other.start < this.endInclusive
|
||||
}
|
||||
|
||||
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
|
||||
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
|
||||
}
|
||||
|
||||
operator fun ClosedRange<Int>.minus(other: Int): ClosedRange<Int> {
|
||||
return (this.start - other)..(this.endInclusive - other)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package dev.meloda.fast.profile.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.profile.ProfileViewModel
|
||||
import dev.meloda.fast.profile.ProfileViewModelImpl
|
||||
import dev.meloda.fast.profile.presentation.ProfileRoute
|
||||
import dev.meloda.fast.ui.extensions.sharedViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Serializable
|
||||
object Profile
|
||||
@@ -16,12 +17,13 @@ object Profile
|
||||
fun NavGraphBuilder.profileScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onSettingsButtonClicked: () -> Unit,
|
||||
onPhotoClicked: (url: String) -> Unit,
|
||||
navController: NavController
|
||||
onPhotoClicked: (url: String) -> Unit
|
||||
) {
|
||||
composable<Profile> {
|
||||
val viewModel: ProfileViewModel =
|
||||
it.sharedViewModel<ProfileViewModelImpl>(navController = navController)
|
||||
val context = LocalContext.current
|
||||
val viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>(
|
||||
viewModelStoreOwner = context as AppCompatActivity
|
||||
)
|
||||
|
||||
ProfileRoute(
|
||||
onError = onError,
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.domain)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Build
|
||||
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.extensions.findWithIndex
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
@@ -19,16 +18,19 @@ import dev.meloda.fast.data.db.AccountsRepository
|
||||
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.model.database.AccountEntity
|
||||
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.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
interface SettingsViewModel {
|
||||
@@ -37,7 +39,7 @@ interface SettingsViewModel {
|
||||
val hapticType: StateFlow<HapticType?>
|
||||
|
||||
fun onLogOutAlertDismissed()
|
||||
fun onLogOutAlertPositiveClick()
|
||||
suspend fun onLogOutAlertPositiveClick()
|
||||
|
||||
fun onPerformCrashAlertDismissed()
|
||||
fun onPerformCrashPositiveButtonClicked()
|
||||
@@ -50,6 +52,7 @@ interface SettingsViewModel {
|
||||
}
|
||||
|
||||
class SettingsViewModelImpl(
|
||||
private val authUseCase: AuthUseCase,
|
||||
private val accountsRepository: AccountsRepository,
|
||||
private val userSettings: UserSettings,
|
||||
private val resources: Resources,
|
||||
@@ -69,20 +72,37 @@ class SettingsViewModelImpl(
|
||||
emitShowOptions { old -> old.copy(showLogOut = false) }
|
||||
}
|
||||
|
||||
override fun onLogOutAlertPositiveClick() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountsRepository.storeAccounts(
|
||||
listOf(
|
||||
AccountEntity(
|
||||
userId = UserConfig.userId,
|
||||
accessToken = "",
|
||||
fastToken = UserConfig.fastToken,
|
||||
trustedHash = UserConfig.trustedHash
|
||||
override suspend fun onLogOutAlertPositiveClick() {
|
||||
withContext(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(
|
||||
AccountEntity(
|
||||
userId = UserConfig.userId,
|
||||
accessToken = "",
|
||||
fastToken = UserConfig.fastToken,
|
||||
trustedHash = UserConfig.trustedHash,
|
||||
exchangeToken = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
async { UserConfig.clear() }
|
||||
)
|
||||
|
||||
UserConfig.clear()
|
||||
tasks.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -24,6 +24,7 @@ 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
|
||||
@@ -52,6 +53,7 @@ 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
|
||||
|
||||
@@ -83,12 +85,16 @@ fun SettingsRoute(
|
||||
onSettingsItemValueChanged = viewModel::onSettingsItemChanged
|
||||
)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
HandlePopups(
|
||||
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
|
||||
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
|
||||
logoutPositiveClick = {
|
||||
viewModel.onLogOutAlertPositiveClick()
|
||||
onLogOutButtonClicked()
|
||||
scope.launch {
|
||||
viewModel.onLogOutAlertPositiveClick()
|
||||
onLogOutButtonClicked()
|
||||
}
|
||||
},
|
||||
logoutDismissed = viewModel::onLogOutAlertDismissed,
|
||||
screenState = screenState
|
||||
|
||||
Reference in New Issue
Block a user