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:
2025-04-04 20:43:59 +03:00
committed by GitHub
parent add67b6f8d
commit 89748b72ed
237 changed files with 4896 additions and 3289 deletions
@@ -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()
}
)
@@ -34,7 +34,7 @@ fun NavController.navigateToCaptcha(captchaImageUrl: String) {
}
fun NavController.setCaptchaResult(code: String?) {
this.currentBackStackEntry
this.previousBackStackEntry
?.savedStateHandle
?.set("captcha_code", code)
}
@@ -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()
@@ -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") }
)
}
}
}
@@ -38,7 +38,7 @@ fun NavController.navigateToValidation(arguments: ValidationArguments) {
}
fun NavController.setValidationResult(code: String?) {
this.currentBackStackEntry
this.previousBackStackEntry
?.savedStateHandle
?.set("validation_code", code)
}
@@ -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),
@@ -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
}
)
@@ -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
)
}
}
@@ -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,
@@ -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,
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
@@ -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()
)
}
@@ -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()
}
@@ -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()
}
@@ -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
)
}
}
@@ -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
)
@@ -0,0 +1,5 @@
package dev.meloda.fast.conversations.model
import kotlinx.coroutines.CancellationException
class NewInteractionException : CancellationException()
@@ -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
)
}
}
}
@@ -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)
)
}
}
}
@@ -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(
}
}
}
@@ -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) {
@@ -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
)
}
@@ -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)
)
}
}
@@ -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("&ndash;", "-")
.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,
@@ -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)) {
@@ -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 {
@@ -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 = {
@@ -34,7 +34,7 @@ fun CreateChatItem(
friend: UiFriend,
maxLines: Int,
isSelected: Boolean,
onItemClicked: (Int) -> Unit
onItemClicked: (Long) -> Unit
) {
Row(
modifier = modifier
@@ -39,7 +39,7 @@ fun CreateChatList(
maxLines: Int,
modifier: Modifier,
padding: PaddingValues,
onItemClicked: (Int) -> Unit,
onItemClicked: (Long) -> Unit,
onTitleTextInputChanged: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
@@ -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()
@@ -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) {
@@ -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
}
@@ -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()
@@ -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(
@@ -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()
@@ -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()
}
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
@@ -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,
@@ -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)
}
@@ -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)))
}
@@ -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
)
}
}
}
}
@@ -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)
)
}
@@ -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
)
}
@@ -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)
}
}
}
@@ -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) {
@@ -38,7 +38,7 @@ fun OutgoingMessageBubble(
) {
MessageBubble(
modifier = Modifier,
text = message.text.orDots(),
text = message.text,
isOut = true,
date = message.date,
edited = message.isEdited,
@@ -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
@@ -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,
+1
View File
@@ -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()
}
}
@@ -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