Release 0.2.0 (#150)

Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-04-04 21:47:05 +03:00
committed by GitHub
parent 0eb3146428
commit 82fb78e9ea
279 changed files with 9171 additions and 4517 deletions
+7
View File
@@ -46,6 +46,13 @@ androidComponents {
}
}
// TODO: 29-Mar-25, Danil Nikolaev: remove when autofill changes will be in release
configurations.all {
resolutionStrategy {
force(libs.compose.ui)
}
}
android {
namespace = "dev.meloda.fast.auth"
@@ -6,9 +6,8 @@ import androidx.navigation.navigation
import dev.meloda.fast.auth.captcha.navigation.captchaScreen
import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha
import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult
import dev.meloda.fast.auth.login.navigation.Logo
import dev.meloda.fast.auth.login.navigation.Login
import dev.meloda.fast.auth.login.navigation.loginScreen
import dev.meloda.fast.auth.login.navigation.navigateToLogin
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
import dev.meloda.fast.auth.userbanned.navigation.navigateToUserBanned
import dev.meloda.fast.auth.userbanned.navigation.userBannedRoute
@@ -26,9 +25,7 @@ fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit,
navController: NavController
) {
navigation<AuthGraph>(
startDestination = Logo
) {
navigation<AuthGraph>(startDestination = Login) {
loginScreen(
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
@@ -57,29 +54,28 @@ fun NavGraphBuilder.authNavGraph(
)
)
},
onNavigateToCredentials = navController::navigateToLogin,
navController = navController
)
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()
@@ -1,10 +1,11 @@
package dev.meloda.fast.auth.login
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -14,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
@@ -36,15 +40,20 @@ import kotlinx.coroutines.launch
interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
val loginError: StateFlow<LoginError?>
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 isNeedToShowFastSignInAlert: StateFlow<Boolean>
val isNeedToClearCaptchaCode: StateFlow<Boolean>
val isNeedToClearValidationCode: StateFlow<Boolean>
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
fun onDialogDismissed(dialog: LoginDialog)
fun onBackPressed()
fun onPasswordVisibilityButtonClicked()
@@ -53,24 +62,20 @@ interface LoginViewModel {
fun onSignInButtonClicked()
fun onErrorDialogDismissed()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToValidation()
fun onValidationCodeReceived(code: String)
fun onCaptchaCodeReceived(code: String)
fun onLogoLongClicked()
fun onFastLogInAlertDismissed()
fun onFastLogInAlertConfirmClicked(token: String)
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,
@@ -78,47 +83,85 @@ class LoginViewModelImpl(
) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
override val loginError = MutableStateFlow<LoginError?>(null)
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 isNeedToShowFastSignInAlert = 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
}
}
override fun onDialogDismissed(dialog: LoginDialog) {
loginDialog.setValue { null }
}
override fun onBackPressed() {
screenState.setValue { old -> old.copy(showLogo = true) }
}
override fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
}
override fun onLoginInputChanged(newLogin: String) {
val newState = screenState.value.copy(
login = newLogin.trim(),
loginError = false
)
screenState.setValue { newState }
screenState.setValue { old ->
old.copy(
login = newLogin.trim(),
loginError = false
)
}
}
override fun onPasswordInputChanged(newPassword: String) {
val newState = screenState.value.copy(
password = newPassword.trim(),
passwordError = false
)
screenState.setValue { newState }
screenState.setValue { old ->
old.copy(
password = newPassword.trim(),
passwordError = false
)
}
}
override fun onSignInButtonClicked() {
if (screenState.value.isLoading) return
login()
}
override fun onErrorDialogDismissed() {
loginError.update { null }
if (screenState.value.showLogo) {
screenState.setValue { old -> old.copy(showLogo = false) }
return
}
login()
}
override fun onNavigatedToMain() {
@@ -137,72 +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() {
isNeedToShowFastSignInAlert.update { true }
}
override fun onFastLogInAlertDismissed() {
isNeedToShowFastSignInAlert.update { false }
}
override fun onFastLogInAlertConfirmClicked(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 = { error ->
UserConfig.currentUserId = -1
UserConfig.userId = -1
UserConfig.accessToken = ""
// TODO: 19/07/2024, Danil Nikolaev: show 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) {
@@ -219,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) {
loginError.update { LoginError.Unknown }
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 -> {
@@ -301,6 +335,7 @@ class LoginViewModelImpl(
canResendSms = error.validationResend == "sms"
)
validationArguments.update { arguments }
validationSid.update { error.validationSid }
}
is OAuthErrorDomain.CaptchaRequiredError -> {
@@ -309,10 +344,13 @@ class LoginViewModelImpl(
captchaImageUrl = error.captchaImageUrl
)
captchaArguments.update { arguments }
captchaSid.update { error.captchaSid }
}
OAuthErrorDomain.InvalidCredentialsError -> {
loginError.update { LoginError.WrongCredentials }
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.")
}
}
is OAuthErrorDomain.UserBannedError -> {
@@ -326,33 +364,34 @@ class LoginViewModelImpl(
}
OAuthErrorDomain.WrongValidationCode -> {
loginError.update { LoginError.WrongValidationCode }
isNeedToClearValidationCode.update { true }
validationCode.update { null }
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.")
}
}
OAuthErrorDomain.WrongValidationCodeFormat -> {
loginError.update { LoginError.WrongValidationCodeFormat }
isNeedToClearValidationCode.update { true }
validationCode.update { null }
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.")
}
}
OAuthErrorDomain.TooManyTriesError -> {
loginError.update { LoginError.TooManyTries }
loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
}
}
OAuthErrorDomain.UnknownError -> {
loginError.update { LoginError.Unknown }
loginDialog.setValue { LoginDialog.Error() }
}
}
true
}
is State.Error.TestError -> {
val message = stateError.message
val error = LoginError.SimpleError(message = message)
loginError.update { error }
true
}
else -> false
else -> Unit
}
}
@@ -0,0 +1,12 @@
package dev.meloda.fast.auth.login.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class LoginDialog {
data class Error(
val errorText: String? = null,
val errorTextResId: Int? = null
) : LoginDialog()
}
@@ -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()
}
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
@Immutable
data class LoginScreenState(
val showLogo: Boolean,
val login: String,
val password: String,
val isLoading: Boolean,
@@ -14,6 +15,7 @@ data class LoginScreenState(
companion object {
val EMPTY = LoginScreenState(
showLogo = true,
login = "",
password = "",
isLoading = false,
@@ -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
@@ -10,28 +13,40 @@ import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.auth.login.presentation.LoginRoute
import dev.meloda.fast.auth.login.presentation.LogoRoute
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable
@Serializable
object Login
@Serializable
object Logo
fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToCredentials: () -> Unit,
navController: NavController
) {
composable<Login> { backStackEntry ->
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()
@@ -45,17 +60,6 @@ fun NavGraphBuilder.loginScreen(
viewModel = viewModel
)
}
composable<Logo> {
LogoRoute(
onNavigateToMain = onNavigateToMain,
onGoNextButtonClicked = onNavigateToCredentials
)
}
}
fun NavController.navigateToLogin() {
this.navigate(route = Login)
}
fun NavBackStackEntry.getValidationResult(): String? {
@@ -1,6 +1,9 @@
package dev.meloda.fast.auth.login.presentation
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
@@ -28,13 +31,9 @@ import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -42,28 +41,30 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
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.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginDialog
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.ui.basic.autoFillRequestHandler
import dev.meloda.fast.ui.basic.connectNode
import dev.meloda.fast.ui.basic.defaultFocusChangeAutoFill
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
@@ -74,59 +75,54 @@ fun LoginRoute(
onNavigateToValidation: (LoginValidationArguments) -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>()
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
val loginError by viewModel.loginError.collectAsStateWithLifecycle()
val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle()
BackHandler(
enabled = !screenState.showLogo,
onBack = viewModel::onBackPressed
)
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)
}
}
LaunchedEffect(validationCode) {
if (validationCode != null) {
viewModel.onValidationCodeReceived(validationCode)
}
viewModel.onValidationCodeReceived(validationCode)
}
LaunchedEffect(captchaCode) {
if (captchaCode != null) {
viewModel.onCaptchaCodeReceived(captchaCode)
}
viewModel.onCaptchaCodeReceived(captchaCode)
}
LoginScreen(
screenState = screenState,
onLoginAutoFilled = viewModel::onLoginInputChanged,
onPasswordAutoFilled = viewModel::onPasswordInputChanged,
onLoginInputChanged = viewModel::onLoginInputChanged,
onPasswordInputChanged = viewModel::onPasswordInputChanged,
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
@@ -135,18 +131,16 @@ fun LoginRoute(
onSignInButtonClicked = viewModel::onSignInButtonClicked
)
HandleError(
onDismiss = viewModel::onErrorDialogDismissed,
error = loginError
HandleDialogs(
loginDialog = loginDialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginScreen(
screenState: LoginScreenState = LoginScreenState.EMPTY,
onLoginAutoFilled: (String) -> Unit = {},
onPasswordAutoFilled: (String) -> Unit = {},
onLoginInputChanged: (String) -> Unit = {},
onPasswordInputChanged: (String) -> Unit = {},
onPasswordFieldEnterKeyClicked: () -> Unit = {},
@@ -154,218 +148,193 @@ fun LoginScreen(
onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {}
) {
val currentSize = LocalSizeConfig.current
val size = LocalSizeConfig.current
val focusManager = LocalFocusManager.current
val (loginFocusable, passwordFocusable) = FocusRequester.createRefs()
var loginText by remember { mutableStateOf(TextFieldValue(screenState.login)) }
val showLoginError = screenState.loginError
val titleSpacerSize by animateDpAsState(if (size.isHeightSmall) 24.dp else 58.dp)
val bottomPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val autoFillEmailHandler = autoFillRequestHandler(
autofillTypes = listOf(AutofillType.EmailAddress),
onFill = { value ->
loginText = TextFieldValue(text = value, selection = TextRange(value.length))
onLoginAutoFilled(value)
}
)
var passwordText by remember { mutableStateOf(TextFieldValue(screenState.password)) }
val showPasswordError = screenState.passwordError
val autoFillPasswordHandler = autoFillRequestHandler(
autofillTypes = listOf(AutofillType.Password),
onFill = { value ->
passwordText = TextFieldValue(text = value, selection = TextRange(value.length))
onPasswordAutoFilled(value)
}
)
val titleStyle = if (currentSize.isWidthSmall) {
MaterialTheme.typography.displayMedium
} else {
MaterialTheme.typography.displayMedium
}
val titleSpacerSize = if (currentSize.isHeightSmall) {
24.dp
} else {
58.dp
}
val bottomPadding = if (currentSize.isHeightSmall) {
10.dp
} else {
30.dp
}
val (loginFocusable, passwordFocusable) =
FocusRequester.createRefs()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomPadding)
.fillMaxSize()
) {
Column(
AnimatedVisibility(
visible = screenState.showLogo,
enter = fadeIn(),
exit = fadeOut()
) {
Logo()
}
AnimatedVisibility(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
.align(Alignment.Center),
visible = !screenState.showLogo,
enter = fadeIn(),
exit = fadeOut()
) {
Text(
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = titleStyle
)
Spacer(modifier = Modifier.height(titleSpacerSize))
TextField(
Column(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
passwordFocusable.requestFocus()
true
}
.handleTabKey {
passwordFocusable.requestFocus()
true
}
.focusRequester(loginFocusable)
.connectNode(handler = autoFillEmailHandler)
.defaultFocusChangeAutoFill(handler = autoFillEmailHandler),
value = loginText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillEmailHandler.requestVerifyManual()
}
.align(Alignment.Center)
) {
Text(
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displayMedium
)
loginText = newText
onLoginInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon",
tint = if (showLoginError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
Spacer(modifier = Modifier.height(titleSpacerSize))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
passwordFocusable.requestFocus()
true
}
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
),
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
isError = showLoginError,
singleLine = true
)
AnimatedVisibility(visible = showLoginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
Spacer(modifier = Modifier.height(16.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
focusManager.clearFocus()
onPasswordFieldEnterKeyClicked()
true
}
.focusRequester(passwordFocusable)
.connectNode(handler = autoFillPasswordHandler)
.defaultFocusChangeAutoFill(handler = autoFillPasswordHandler),
value = passwordText,
onValueChange = { newText ->
val text = newText.text
if (text.isEmpty()) {
autoFillPasswordHandler.requestVerifyManual()
}
passwordText = newText
onPasswordInputChanged(text)
},
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon",
tint = if (showPasswordError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
.handleTabKey {
passwordFocusable.requestFocus()
true
}
)
},
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24
)
IconButton(onClick = onPasswordVisibilityButtonClicked) {
.focusRequester(loginFocusable)
.semantics {
contentType = ContentType.Username + ContentType.EmailAddress
},
value = screenState.login,
onValueChange = onLoginInputChanged,
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
else "Password invisible icon"
painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon",
tint = if (screenState.loginError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
onPasswordFieldGoAction()
}
),
isError = showPasswordError,
visualTransformation = if (screenState.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = true
)
AnimatedVisibility(visible = showPasswordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Next,
keyboardType = KeyboardType.Email
),
keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }),
isError = screenState.loginError,
singleLine = true
)
AnimatedVisibility(visible = screenState.loginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
Spacer(modifier = Modifier.height(16.dp))
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.handleEnterKey {
focusManager.clearFocus()
onPasswordFieldEnterKeyClicked()
true
}
.focusRequester(passwordFocusable)
.semantics { contentType = ContentType.Password },
value = screenState.password,
onValueChange = onPasswordInputChanged,
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon",
tint = if (screenState.passwordError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24
)
IconButton(onClick = onPasswordVisibilityButtonClicked) {
Icon(
painter = imagePainter,
contentDescription = if (screenState.passwordVisible) "Password visible icon"
else "Password invisible icon"
)
}
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Go,
keyboardType = KeyboardType.Password
),
keyboardActions = KeyboardActions(
onGo = {
focusManager.clearFocus()
onPasswordFieldGoAction()
}
),
isError = screenState.passwordError,
visualTransformation = if (screenState.passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = true
)
AnimatedVisibility(visible = screenState.passwordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
}
}
Box(
modifier = Modifier.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
FloatingActionButton(
onClick = {
if (!screenState.isLoading) {
focusManager.clearFocus()
onSignInButtonClicked()
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("sing_in_fab")
) {
FloatingActionButton(
onClick = {
if (!screenState.isLoading) {
focusManager.clearFocus()
onSignInButtonClicked()
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("sing_in_fab")
AnimatedVisibility(
visible = screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator()
}
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
@@ -374,77 +343,28 @@ fun LoginScreen(
)
}
}
AnimatedVisibility(
visible = screenState.isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
fun HandleError(
onDismiss: () -> Unit,
error: LoginError?,
fun HandleDialogs(
loginDialog: LoginDialog?,
onConfirmed: (LoginDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (LoginDialog) -> Unit = {},
) {
when (error) {
when (loginDialog) {
null -> Unit
LoginError.Unknown -> {
is LoginDialog.Error -> {
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,
onDismissRequest = { onDismissed(loginDialog) },
title = stringResource(UiR.string.title_error),
text = loginDialog.errorTextResId?.let { stringResource(it) }
?: loginDialog.errorText
?: stringResource(UiR.string.unknown_error_occurred),
confirmText = stringResource(id = UiR.string.ok)
)
}
@@ -0,0 +1,91 @@
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
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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) {
val size = LocalSizeConfig.current
val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp)
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()
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomAdditionalPadding)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = R.drawable.ic_logo_big),
contentDescription = "Application Logo",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.width(iconWidth)
.combinedClickable(
interactionSource = null,
indication = null,
onLongClick = null,
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value
)
}
}
)
)
Spacer(modifier = Modifier.height(46.dp))
Text(
text = stringResource(id = R.string.fast_messenger),
style = MaterialTheme.typography.displayMedium.copy(fontSize = appNameFontSize.sp),
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
@Preview
@Composable
private fun LogoPreview() {
Logo()
}
@@ -1,229 +0,0 @@
package dev.meloda.fast.auth.login.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.theme.LocalSizeConfig
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable
fun LogoRoute(
onNavigateToMain: () -> Unit,
onGoNextButtonClicked: () -> Unit,
viewModel: dev.meloda.fast.auth.login.LoginViewModel = koinViewModel<dev.meloda.fast.auth.login.LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val isNeedToShowSignInAlert by viewModel.isNeedToShowFastSignInAlert.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenMain) {
if (isNeedToOpenMain) {
viewModel.onNavigatedToMain()
onNavigateToMain()
}
}
LogoScreen(
isLoading = screenState.isLoading,
onLogoLongClicked = viewModel::onLogoLongClicked,
onGoNextButtonClicked = onGoNextButtonClicked
)
if (isNeedToShowSignInAlert) {
SignInAlert(
onDismissRequest = viewModel::onFastLogInAlertDismissed,
onConfirmClick = viewModel::onFastLogInAlertConfirmClicked,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LogoScreen(
isLoading: Boolean = false,
onLogoLongClicked: () -> Unit = {},
onGoNextButtonClicked: () -> Unit = {}
) {
val currentSize = LocalSizeConfig.current
Scaffold { padding ->
val topPadding by animateDpAsState(
targetValue = padding.calculateTopPadding(),
label = "topPaddingAnimation"
)
val bottomPadding by animateDpAsState(
targetValue = padding.calculateBottomPadding(),
label = "bottomPaddingAnimation"
)
val endPadding by animateDpAsState(
targetValue = padding.calculateEndPadding(LayoutDirection.Ltr),
label = "endPaddingAnimation"
)
val startPadding by animateDpAsState(
targetValue = padding.calculateStartPadding(LayoutDirection.Ltr),
label = "startPaddingAnimation"
)
val iconWidth = if (currentSize.isWidthSmall) {
110.dp
} else {
134.dp
}
val appNameTextStyle = if (currentSize.isWidthSmall) {
MaterialTheme.typography.displayMedium.copy(fontSize = 40.sp)
} else {
MaterialTheme.typography.displayMedium
}
val bottomAdditionalPadding = if (currentSize.isHeightSmall) {
10.dp
} else {
30.dp
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = startPadding,
top = topPadding,
end = endPadding,
bottom = bottomPadding
)
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomAdditionalPadding)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_logo_big),
contentDescription = "Application Logo",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.width(iconWidth)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onLongClick = onLogoLongClicked,
onClick = {}
)
)
Spacer(modifier = Modifier.height(46.dp))
Text(
text = stringResource(id = UiR.string.fast_messenger),
style = appNameTextStyle,
color = MaterialTheme.colorScheme.onBackground
)
}
AnimatedVisibility(
visible = !isLoading,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut()
) {
FloatingActionButton(
onClick = {
if (!isLoading) {
onGoNextButtonClicked()
}
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.testTag("go_next_fab")
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Go button",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
AnimatedVisibility(
visible = isLoading,
modifier = Modifier.align(Alignment.BottomCenter)
) {
CircularProgressIndicator()
}
}
}
}
@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),
@@ -4,17 +4,19 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.MaterialType
import dev.meloda.fast.chatmaterials.navigation.ChatMaterials
import dev.meloda.fast.chatmaterials.util.asPresentation
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError
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>
@@ -23,7 +25,7 @@ interface ChatMaterialsViewModel {
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onPaginationConditionsMet()
fun onRefresh()
@@ -33,6 +35,7 @@ interface ChatMaterialsViewModel {
}
class ChatMaterialsViewModelImpl(
private val materialType: MaterialType,
private val messagesUseCase: MessagesUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel(), ChatMaterialsViewModel {
@@ -50,15 +53,15 @@ class ChatMaterialsViewModelImpl(
screenState.setValue { old ->
old.copy(
peerId = arguments.peerId,
conversationMessageId = arguments.conversationMessageId
cmId = arguments.conversationMessageId
)
}
loadChatMaterials()
}
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.materials.size }
override fun onPaginationConditionsMet() {
currentOffset.setValue { old -> old + LOAD_COUNT }
loadChatMaterials()
}
@@ -75,31 +78,33 @@ class ChatMaterialsViewModelImpl(
loadChatMaterials(0)
}
private fun loadChatMaterials(
offset: Int = currentOffset.value
) {
private fun loadChatMaterials(offset: Int = currentOffset.value) {
messagesUseCase.getHistoryAttachments(
peerId = screenState.value.peerId,
count = LOAD_COUNT,
offset = offset,
attachmentTypes = listOf(screenState.value.attachmentType),
conversationMessageId = screenState.value.conversationMessageId
attachmentTypes = listOf(materialType.toString()),
cmId = screenState.value.cmId
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
},
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.materials.size >= LOAD_COUNT
val paginationExhausted = !itemsCountSufficient
&& screenState.value.materials.isNotEmpty()
val loadedMaterials = response.map(VkAttachmentHistoryMessage::asPresentation)
val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation)
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
isPaginationExhausted = paginationExhausted,
cmId = if (loadedMaterials.size + offset > 200) {
currentOffset.setValue { 0 }
loadedMaterials.lastOrNull()?.conversationMessageId ?: -1
} else {
screenState.value.cmId
}
)
if (offset == 0) {
@@ -125,6 +130,44 @@ class ChatMaterialsViewModelImpl(
}
}
private fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
companion object {
const val LOAD_COUNT = 100
}
@@ -1,9 +1,45 @@
package dev.meloda.fast.chatmaterials.di
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import org.koin.androidx.viewmodel.dsl.viewModelOf
import dev.meloda.fast.chatmaterials.model.MaterialType
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val chatMaterialsModule = module {
viewModelOf(::ChatMaterialsViewModelImpl)
viewModel(named(MaterialType.PHOTO)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.PHOTO,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.AUDIO)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.AUDIO,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.VIDEO)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.VIDEO,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.FILE)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.FILE,
messagesUseCase = get(),
savedStateHandle = get()
)
}
viewModel(named(MaterialType.LINK)) {
ChatMaterialsViewModelImpl(
materialType = MaterialType.LINK,
messagesUseCase = get(),
savedStateHandle = get()
)
}
}
@@ -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
)
}
}
@@ -0,0 +1,13 @@
package dev.meloda.fast.chatmaterials.model
enum class MaterialType {
PHOTO, VIDEO, AUDIO, FILE, LINK;
override fun toString(): String = when (this) {
PHOTO -> "photo"
VIDEO -> "video"
AUDIO -> "audio"
FILE -> "doc"
LINK -> "link"
}
}
@@ -1,28 +1,43 @@
package dev.meloda.fast.chatmaterials.model
sealed class UiChatMaterial {
sealed class UiChatMaterial(
open val conversationMessageId: Long
) {
data class Photo(
override val conversationMessageId: Long,
val previewUrl: String
) : UiChatMaterial()
) : UiChatMaterial(conversationMessageId)
data class Video(
val previewUrl: String
) : UiChatMaterial()
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String,
val views: Int,
val duration: String
) : UiChatMaterial(conversationMessageId)
data class Audio(
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String,
val artist: String,
val duration: String
) : UiChatMaterial()
) : UiChatMaterial(conversationMessageId)
data class File(
val title: String
) : UiChatMaterial()
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String,
val size: String,
val extension: String
) : UiChatMaterial(conversationMessageId)
data class Link(
val title: String,
val previewUrl: String?
) : UiChatMaterial()
override val conversationMessageId: Long,
val previewUrl: String?,
val title: String?,
val url: String,
val urlFirstChar: String
) : UiChatMaterial(conversationMessageId)
}
@@ -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,
@@ -1,93 +1,73 @@
package dev.meloda.fast.chatmaterials.presentation
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModel
import dev.meloda.fast.chatmaterials.ChatMaterialsViewModelImpl
import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.ui.R
import dev.meloda.fast.chatmaterials.model.MaterialType
import dev.meloda.fast.chatmaterials.presentation.materials.AudioMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.FileMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named
import dev.meloda.fast.ui.R as UiR
@Composable
fun ChatMaterialsRoute(
onBack: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ChatMaterialsViewModel = koinViewModel<ChatMaterialsViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
ChatMaterialsScreen(
screenState = screenState,
onBack = onBack,
onTypeChanged = viewModel::onTypeChanged,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked
)
}
@@ -99,55 +79,36 @@ fun ChatMaterialsRoute(
)
@Composable
fun ChatMaterialsScreen(
screenState: ChatMaterialsScreenState = ChatMaterialsScreenState.EMPTY,
onBack: () -> Unit = {},
onTypeChanged: (String) -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current
val attachments = screenState.materials
var moreClearBlur by rememberSaveable {
mutableStateOf(false)
}
val hazeState = remember { HazeState() }
val hazeStyle = if (moreClearBlur) HazeMaterials.ultraThin() else HazeMaterials.regular()
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
var checkedTypeIndex by rememberSaveable {
mutableIntStateOf(0)
}
LaunchedEffect(checkedTypeIndex) {
onTypeChanged(
when (checkedTypeIndex) {
0 -> "photo"
1 -> "video"
2 -> "audio"
3 -> "doc"
4 -> "link"
else -> ""
}
val titles = remember {
listOf(
UiR.string.chat_attachment_photos,
UiR.string.chat_attachment_videos,
UiR.string.chat_attachment_music,
UiR.string.chat_attachment_files,
UiR.string.chat_attachment_links,
)
}
val titles = listOf("Photos", "Videos", "Audios")//, "Files", "Links")
val listState = rememberLazyListState()
val gridState = rememberLazyGridState()
val canScrollBackward = when (checkedTypeIndex) {
in 0..1 -> gridState.canScrollBackward
else -> listState.canScrollBackward
val tabItems = remember {
titles.map { resId ->
TabItem(
titleResId = resId,
unselectedIconResId = null,
selectedIconResId = null
)
}
}
Log.d("ChatMaterialsScreen", "ChatMaterialsScreen: canScrollBackward: $canScrollBackward")
var canScrollBackward by remember {
mutableStateOf(false)
}
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f,
@@ -159,8 +120,7 @@ fun ChatMaterialsScreen(
)
val topBarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !canScrollBackward)
targetValue = if (currentTheme.enableBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
@@ -171,7 +131,13 @@ fun ChatMaterialsScreen(
)
)
val pullToRefreshState = rememberPullToRefreshState()
val pagerState = rememberPagerState(
pageCount = tabItems::size
)
val selectedTabIndex by remember {
derivedStateOf { pagerState.currentPage }
}
Scaffold(
topBar = {
@@ -181,11 +147,9 @@ fun ChatMaterialsScreen(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = hazeStyle
style = HazeMaterials.thick()
)
} else {
Modifier
}
} else Modifier
)
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.fillMaxWidth()
@@ -193,7 +157,7 @@ fun ChatMaterialsScreen(
TopAppBar(
title = {
Text(
text = "Chat Materials",
text = stringResource(UiR.string.chat_materials_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
@@ -210,162 +174,147 @@ fun ChatMaterialsScreen(
contentDescription = null
)
}
},
actions = {
IconButton(
onClick = {
dropDownMenuExpanded = true
}
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options button"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
if (currentTheme.enableBlur) {
DropdownMenuItem(
text = {
Text(text = if (moreClearBlur) "Default blur" else "Clearer blur")
},
onClick = {
moreClearBlur = !moreClearBlur
dropDownMenuExpanded = false
}
)
}
HorizontalDivider()
titles.forEachIndexed { index, title ->
DropdownMenuItem(
leadingIcon = {
RadioButton(
selected = checkedTypeIndex == index,
onClick = null
)
},
text = {
Text(text = title)
},
onClick = {
checkedTypeIndex = index
dropDownMenuExpanded = false
}
)
}
}
}
)
ScrollableTabRow(
modifier = Modifier.fillMaxWidth(),
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent,
edgePadding = 0.dp,
indicator = { tabPositions ->
TabRowDefaults.PrimaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = MaterialTheme.colorScheme.primary
)
}
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = index == selectedTabIndex,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
item.titleResId?.let { resId ->
Text(text = stringResource(id = resId))
}
}
)
}
}
}
}
) { padding ->
PullToRefreshBox(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
if (checkedTypeIndex in listOf(0, 1)) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
CompositionLocalProvider(LocalHazeState provides hazeState) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
when (index) {
0 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.PHOTO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
) {
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
onClick = {
if (item is UiChatMaterial.Photo) {
onPhotoClicked(item.previewUrl)
}
}
PhotoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onPhotoClicked = onPhotoClicked
)
}
repeat(3) {
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(attachments) { item ->
ChatMaterialItem(
item = item,
onClick = {}
1 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.VIDEO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
VideoMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
2 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.AUDIO))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
AudioMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
3 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.FILE))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
FileMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
4 -> {
val viewModel: ChatMaterialsViewModel =
koinViewModel<ChatMaterialsViewModelImpl>(named(MaterialType.LINK))
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LinkMaterialsScreen(
modifier = Modifier,
screenState = screenState,
baseError = baseError,
padding = padding,
onRefresh = viewModel::onRefresh,
onSessionExpiredLogOutButtonClicked = { },
setCanScrollBackward = { canScrollBackward = it },
canPaginate = canPaginate,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet
)
}
else -> Unit
}
}
}
@@ -0,0 +1,232 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeSource
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.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
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
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.Audio
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(42.dp)
.padding(4.dp),
painter = painterResource(UiR.drawable.round_play_arrow_24),
contentDescription = null,
tint = contentColorFor(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = item.artist,
style = MaterialTheme.typography.bodyMedium
)
}
}
Text(text = item.duration)
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,264 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.chrisbanes.haze.hazeSource
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.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
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
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FileMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.File
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
var errorLoading by remember {
mutableStateOf(false)
}
if (item.previewUrl != null && !errorLoading) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.size(width = 64.dp, height = 48.dp),
painter = rememberAsyncImagePainter(
model = item.previewUrl,
imageLoader = LocalContext.current.imageLoader,
onState = {
errorLoading = it is AsyncImagePainter.State.Error
}
),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
Text(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
)
.size(width = 64.dp, height = 48.dp)
.padding(4.dp),
text = item.extension.uppercase(),
textAlign = TextAlign.Center,
lineHeight = 40.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = item.size,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,283 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.chrisbanes.haze.hazeSource
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.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
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
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LinkMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.Link
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 72.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
var errorLoading by remember {
mutableStateOf(false)
}
if (item.previewUrl != null && !errorLoading) {
Image(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.size(
width = 86.dp,
height = 64.dp
),
painter = rememberAsyncImagePainter(
model = item.previewUrl,
imageLoader = LocalContext.current.imageLoader,
onState = {
errorLoading = it is AsyncImagePainter.State.Error
}
),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
Text(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
)
.size(
width = 86.dp,
height = 64.dp
)
.padding(4.dp),
text = item.urlFirstChar,
textAlign = TextAlign.Center,
lineHeight = 56.sp,
fontWeight = FontWeight.Medium,
style = MaterialTheme.typography.titleLarge
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
if (item.title != null) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
}
LocalContentAlpha(
alpha = if (item.title != null) ContentAlpha.medium
else ContentAlpha.high
) {
Text(
text = item.url,
style = if (item.title != null) {
MaterialTheme.typography.bodyMedium
} else {
MaterialTheme.typography.bodyLarge
},
maxLines = if (item.title != null) 1 else 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,207 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.chrisbanes.haze.hazeSource
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.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
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PhotoMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPhotoClicked: (String) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val gridState = rememberLazyGridState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(gridState) {
snapshotFlow { gridState.canScrollBackward }
.collect(setCanScrollBackward)
}
val paginationConditionMet by remember(canPaginate, gridState) {
derivedStateOf {
canPaginate &&
(gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (gridState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
state = gridState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item(span = { GridItemSpan(3) }) {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(items = screenState.materials) { item ->
item as UiChatMaterial.Photo
AsyncImage(
model = item.previewUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(
onClick = {
onPhotoClicked(item.previewUrl)
}
)
)
}
item(span = { GridItemSpan(3) }) {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
Spacer(modifier = Modifier.height(32.dp))
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
gridState.scrollToItem(14)
gridState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
item(span = { GridItemSpan(3) }) {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -0,0 +1,256 @@
package dev.meloda.fast.chatmaterials.presentation.materials
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.chrisbanes.haze.hazeSource
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.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
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
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VideoMaterialsScreen(
modifier: Modifier = Modifier,
canPaginate: Boolean,
screenState: ChatMaterialsScreenState,
baseError: BaseError?,
padding: PaddingValues,
onRefresh: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit,
setCanScrollBackward: (Boolean) -> Unit,
onPaginationConditionsMet: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val hazeState = LocalHazeState.current
val currentTheme = LocalThemeConfig.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(listState) {
snapshotFlow { listState.canScrollBackward }
.collect(setCanScrollBackward)
}
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
when {
baseError != null -> {
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr)),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
LazyColumn(
state = listState,
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}
)
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(screenState.materials) { item ->
item as UiChatMaterial.Video
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 72.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box {
Image(
modifier = Modifier
.fillMaxWidth(0.33f)
.height(64.dp)
.clip(RoundedCornerShape(4.dp)),
painter = rememberAsyncImagePainter(
model = item.previewUrl,
imageLoader = LocalContext.current.imageLoader
),
contentDescription = null,
contentScale = ContentScale.Crop
)
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(
end = 4.dp,
bottom = 4.dp
)
.clip(RoundedCornerShape(8.dp))
.background(
MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
)
.padding(
vertical = 1.dp,
horizontal = 4.dp
),
text = item.duration,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.background
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = "${item.views} views",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
listState.scrollToItem(14)
listState.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item {
Spacer(modifier = Modifier.height(padding.calculateBottomPadding()))
}
}
if (screenState.materials.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
@@ -1,6 +1,8 @@
package dev.meloda.fast.chatmaterials.util
import android.util.Log
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkAudioDomain
@@ -8,52 +10,133 @@ import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.model.api.domain.VkVideoDomain
import java.text.SimpleDateFormat
import java.util.Locale
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial =
fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
when (val type = this.attachment.type) {
AttachmentType.PHOTO -> {
val attachment = this.attachment as VkPhotoDomain
UiChatMaterial.Photo(
conversationMessageId = this.conversationMessageId,
previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty()
)
}
AttachmentType.VIDEO -> {
val attachment = this.attachment as VkVideoDomain
val duration = attachment.duration
val days = duration / (24 * 3600)
val hours = (duration % (24 * 3600)) / 3600
val minutes = (duration % 3600) / 60
val seconds = duration % 60
val args = mutableListOf<Int>()
if (days > 0) args.add(days)
if (hours > 0) args.add(hours)
args.add(minutes)
args.add(seconds)
val builder = StringBuilder()
if (days > 0) builder.append("%02d:")
if (hours > 0) builder.append("%02d:")
builder.append("%02d:%02d")
val formattedDuration =
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Video(
previewUrl = attachment.images.firstOrNull()?.url.orEmpty()
conversationMessageId = this.conversationMessageId,
previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(),
title = attachment.title,
views = attachment.views,
duration = formattedDuration
)
}
AttachmentType.AUDIO -> {
val attachment = this.attachment as VkAudioDomain
val duration = attachment.duration
val days = duration / (24 * 3600)
val hours = (duration % (24 * 3600)) / 3600
val minutes = (duration % 3600) / 60
val seconds = duration % 60
val args = mutableListOf<Int>()
if (days > 0) args.add(days)
if (hours > 0) args.add(hours)
args.add(minutes)
args.add(seconds)
val builder = StringBuilder()
if (days > 0) builder.append("%02d:")
if (hours > 0) builder.append("%02d:")
builder.append("%d:%02d")
val formattedDuration =
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Audio(
conversationMessageId = this.conversationMessageId,
previewUrl = null,
title = attachment.title,
artist = attachment.artist,
duration = SimpleDateFormat(
"mm:ss",
Locale.getDefault()
).format(attachment.duration)
duration = formattedDuration
)
}
AttachmentType.FILE -> {
val attachment = this.attachment as VkFileDomain
val previewUrl: String? = when (val preview = attachment.preview) {
null -> null
else -> {
when {
preview.photo != null -> {
val size = preview.photo?.sizes?.maxByOrNull { it.width }
size?.src
}
preview.video != null -> {
val size = preview.video?.src
size
}
else -> null
}
}
}
UiChatMaterial.File(
title = attachment.title
conversationMessageId = this.conversationMessageId,
title = attachment.title,
previewUrl = previewUrl,
size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()),
extension = attachment.ext.take(4)
)
}
AttachmentType.LINK -> {
val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link(
title = attachment.title ?: attachment.url,
previewUrl = attachment.photo?.getMaxSize()?.url
conversationMessageId = this.conversationMessageId,
title = attachment.title,
previewUrl = attachment.photo?.getMaxSize()?.url,
url = attachment.url,
urlFirstChar = attachment.url.replaceFirst("http://", "")
.replaceFirst("https://", "")
.take(1)
.uppercase()
)
}
else -> throw IllegalArgumentException("Unsupported type: $type")
else -> {
Log.w("ChatMaterialMapper", "Unsupported type: $type")
null
}
}
@@ -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 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,
onPhotoClicked: (url: String) -> Unit,
onCreateChatClicked: () -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
@Serializable
object Archive
ConversationsRoute(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
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)
)
}
}
}
@@ -6,10 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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
@@ -21,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
@@ -40,7 +38,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
@@ -63,16 +60,14 @@ 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,
isUserAccount: Boolean,
conversation: UiConversation,
modifier: Modifier = Modifier,
onPhotoClicked: (url: String) -> Unit
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
@@ -84,7 +79,7 @@ fun ConversationItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(conversation.id) },
onClick = { onItemClick(conversation) },
onLongClick = {
onItemLongClick(conversation)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -154,12 +149,7 @@ fun ConversationItem(
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.clickable {
if (avatarImage is String) {
onPhotoClicked(avatarImage)
}
},
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
)
}
@@ -250,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 {
@@ -338,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
@@ -361,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 = {
@@ -388,6 +383,7 @@ fun ConversationItem(
Text(text = option.title.getString().orEmpty())
}
)
Spacer(Modifier.width(8.dp))
}
}
}
@@ -402,5 +398,3 @@ fun ConversationItem(
}
}
}
@@ -27,25 +27,26 @@ import dev.meloda.fast.data.UserConfig
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(
onConversationsClick: (Int) -> Unit,
modifier: Modifier = Modifier,
conversations: ImmutableList<UiConversation>,
onConversationsClick: (UiConversation) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
val coroutineScope = rememberCoroutineScope()
val bottomPadding = LocalBottomPadding.current
LazyColumn(
modifier = modifier,
state = state
@@ -55,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) {
@@ -71,8 +72,12 @@ fun ConversationsList(
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
onPhotoClicked = onPhotoClicked
modifier =
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
)
Spacer(modifier = Modifier.height(8.dp))
@@ -82,7 +87,14 @@ fun ConversationsList(
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
.then(
if (theme.enableAnimations)
Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
@@ -107,6 +119,7 @@ fun ConversationsList(
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
@@ -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
)
}
@@ -3,11 +3,8 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
@@ -17,11 +14,13 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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
@@ -51,7 +50,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -59,66 +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.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,
onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> 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,
onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset
)
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
@@ -126,21 +88,23 @@ 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 = {},
onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
@@ -152,6 +116,21 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
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()
}
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L)
@@ -209,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
)
}
@@ -270,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())
@@ -281,46 +278,38 @@ fun ConversationsScreen(
}
},
floatingActionButton = {
Column {
AnimatedVisibility(
visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) {
FloatingActionButton(onClick = onCreateChatButtonClicked) {
if (!screenState.isArchive) {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column {
FloatingActionButton(
onClick = onCreateChatButtonClicked,
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
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()
@@ -345,6 +334,7 @@ fun ConversationsScreen(
}
) {
ConversationsList(
conversations = conversations,
onConversationsClick = onConversationItemClicked,
onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState,
@@ -356,11 +346,10 @@ fun ConversationsScreen(
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
padding = padding,
onPhotoClicked = onConversationPhotoClicked
padding = padding
)
if (screenState.conversations.isEmpty()) {
if (conversations.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
@@ -371,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()
@@ -1,6 +1,7 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -67,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
@@ -76,11 +78,9 @@ import dev.meloda.fast.ui.R as UiR
fun CreateChatRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatCreated: (Int) -> Unit,
onChatCreated: (Long) -> Unit,
viewModel: CreateChatViewModel
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
@@ -88,7 +88,7 @@ fun CreateChatRoute(
LaunchedEffect(isChatCreated) {
if (isChatCreated != null) {
onChatCreated(isChatCreated ?: -1)
onChatCreated(isChatCreated ?: -1L)
viewModel.onNavigatedBack()
}
}
@@ -121,7 +121,7 @@ fun CreateChatScreen(
onBack: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onItemClicked: (Int) -> Unit = {},
onItemClicked: (Long) -> Unit = {},
onTitleTextInputChanged: (String) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
@@ -148,20 +148,24 @@ fun CreateChatScreen(
val hazeState = LocalHazeState.current
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
val toolbarContainerColor by animateColorAsState(
val topBarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
if (currentTheme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
Scaffold(
@@ -171,11 +175,7 @@ fun CreateChatScreen(
Column(
modifier = Modifier
.fillMaxWidth()
.background(
toolbarContainerColor.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
)
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
@@ -205,11 +205,7 @@ fun CreateChatScreen(
style = MaterialTheme.typography.headlineSmall
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = 0f
)
),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth(),
)
@@ -272,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()
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
// TODO: 13/07/2024, Danil Nikolaev: separate two lists and their pagination
interface FriendsViewModel {
val screenState: StateFlow<FriendsScreenState>
@@ -33,19 +32,13 @@ interface FriendsViewModel {
fun onErrorConsumed()
fun onTabSelected(tabIndex: Int)
fun setScrollIndex(index: Int)
fun setScrollOffset(offset: Int)
fun setScrollIndexOnline(index: Int)
fun setScrollOffsetOnline(offset: Int)
fun onOrderTypeChanged(newOrderType: String)
}
class FriendsViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings,
private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase
) : ViewModel(), FriendsViewModel {
abstract class BaseFriendsViewModelImpl : ViewModel(), FriendsViewModel {
override val screenState = MutableStateFlow(FriendsScreenState.EMPTY)
@@ -54,13 +47,7 @@ class FriendsViewModelImpl(
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
private val friends = MutableStateFlow<List<VkUser>>(emptyList())
init {
userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames)
loadFriends()
}
protected val friends = MutableStateFlow<List<VkUser>>(emptyList())
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
@@ -76,10 +63,6 @@ class FriendsViewModelImpl(
baseError.setValue { null }
}
override fun onTabSelected(tabIndex: Int) {
screenState.setValue { old -> old.copy(selectedTabIndex = tabIndex) }
}
override fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) }
}
@@ -88,87 +71,15 @@ class FriendsViewModelImpl(
screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
override fun setScrollIndexOnline(index: Int) {
screenState.setValue { old -> old.copy(scrollIndexOnline = index) }
override fun onOrderTypeChanged(newOrderType: String) {
if (screenState.value.orderType == newOrderType) return
screenState.setValue { old -> old.copy(orderType = newOrderType) }
loadFriends(offset = 0)
}
override fun setScrollOffsetOnline(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffsetOnline = offset) }
}
abstract fun loadFriends(offset: Int = currentOffset.value)
private fun loadFriends(offset: Int = currentOffset.value) {
friendsUseCase.getOnlineFriends(null, null)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { userIds ->
loadUsersByIdsUseCase(userIds = userIds)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { onlineFriends ->
screenState.setValue { old ->
old.copy(
onlineFriends = onlineFriends.map {
it.asPresentation(userSettings.useContactNames.value)
}
)
}
}
)
}
}
)
}
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.size >= LOAD_COUNT
imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(userSettings.useContactNames.value)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(friends = newState.friends.plus(loadedFriends))
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun handleError(error: State.Error) {
protected fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
@@ -183,26 +94,30 @@ class FriendsViewModelImpl(
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
private fun updateFriendsNames(useContactNames: Boolean) {
protected fun updateFriendsNames(useContactNames: Boolean) {
val friends = friends.value
if (friends.isEmpty()) return
@@ -210,19 +125,119 @@ class FriendsViewModelImpl(
conversation.asPresentation(useContactNames)
}
val onlineUiFriends = screenState.value.onlineFriends.mapNotNull { friend ->
uiFriends.find { it.userId == friend.userId }
}
screenState.setValue { old ->
old.copy(
friends = uiFriends,
onlineFriends = onlineUiFriends
)
old.copy(friends = uiFriends)
}
}
companion object {
const val LOAD_COUNT = 15
const val LOAD_COUNT = 30
}
}
class FriendsViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings
) : BaseFriendsViewModelImpl() {
init {
userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames)
loadFriends()
}
override fun loadFriends(offset: Int) {
friendsUseCase.getFriends(
order = screenState.value.orderType,
count = LOAD_COUNT,
offset = offset
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient
&& screenState.value.friends.isNotEmpty()
imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(userSettings.useContactNames.value)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
friends.emit(response)
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(friends = newState.friends.plus(loadedFriends))
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
}
class OnlineFriendsViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val userSettings: UserSettings,
private val loadUsersByIdsUseCase: LoadUsersByIdsUseCase
) : BaseFriendsViewModelImpl() {
init {
userSettings.useContactNames.listenValue(viewModelScope, ::updateFriendsNames)
loadFriends()
}
override fun loadFriends(offset: Int) {
friendsUseCase.getOnlineFriends(null, null)
.listenValue(viewModelScope) { onlineState ->
onlineState.processState(
error = ::handleError,
success = { userIds ->
loadUsersByIdsUseCase(userIds = userIds).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { onlineFriends ->
screenState.setValue { old ->
old.copy(
friends = onlineFriends.map {
it.asPresentation(userSettings.useContactNames.value)
}
)
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && (onlineState.isLoading() || state.isLoading()),
isPaginating = offset > 0 && (onlineState.isLoading() || state.isLoading())
)
}
}
}
)
}
}
}
@@ -1,8 +1,9 @@
package dev.meloda.fast.friends.di
import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.domain.FriendsUseCaseImpl
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.friends.OnlineFriendsViewModelImpl
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
@@ -11,4 +12,5 @@ import org.koin.dsl.module
val friendsModule = module {
singleOf(::FriendsUseCaseImpl) bind FriendsUseCase::class
viewModelOf(::FriendsViewModelImpl)
viewModelOf(::OnlineFriendsViewModelImpl)
}
@@ -7,28 +7,22 @@ import dev.meloda.fast.ui.model.api.UiFriend
data class FriendsScreenState(
val isLoading: Boolean,
val friends: List<UiFriend>,
val onlineFriends: List<UiFriend>,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val selectedTabIndex: Int,
val scrollIndex: Int,
val scrollOffset: Int,
val scrollIndexOnline: Int,
val scrollOffsetOnline: Int
val orderType: String,
) {
companion object {
val EMPTY: FriendsScreenState = FriendsScreenState(
isLoading = true,
friends = emptyList(),
onlineFriends = emptyList(),
isPaginating = false,
isPaginationExhausted = false,
selectedTabIndex = 0,
scrollIndex = 0,
scrollOffset = 0,
scrollIndexOnline = 0,
scrollOffsetOnline = 0,
orderType = "hints"
)
}
}
@@ -1,13 +1,9 @@
package dev.meloda.fast.friends.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.friends.FriendsViewModel
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.friends.presentation.FriendsRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable
@Serializable
@@ -15,19 +11,16 @@ object Friends
fun NavGraphBuilder.friendsScreen(
onError: (BaseError) -> Unit,
navController: NavController,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit
onMessageClicked: (userid: Long) -> Unit,
onScrolledToTop: () -> Unit
) {
composable<Friends> {
val viewModel: FriendsViewModel =
it.sharedViewModel<FriendsViewModelImpl>(navController = navController)
FriendsRoute(
onError = onError,
viewModel = viewModel,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked
onMessageClicked = onMessageClicked,
onScrolledToTop = onScrolledToTop
)
}
}
@@ -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(),
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -36,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) {
@@ -46,8 +47,6 @@ fun FriendsList(
val coroutineScope = rememberCoroutineScope()
val friends = uiFriends.toList()
LazyColumn(
modifier = modifier,
state = listState
@@ -58,7 +57,7 @@ fun FriendsList(
}
items(
items = friends,
items = uiFriends.toList(),
key = UiFriend::userId,
) { friend ->
FriendItem(
@@ -100,6 +99,7 @@ fun FriendsList(
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
@@ -1,81 +1,72 @@
package dev.meloda.fast.friends.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
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.friends.FriendsViewModel
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.friends.OnlineFriendsViewModelImpl
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
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
import kotlinx.coroutines.flow.debounce
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FriendsRoute(
onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit,
viewModel: FriendsViewModel = koinViewModel<FriendsViewModelImpl>()
fun FriendsScreen(
modifier: Modifier = Modifier,
orderType: String,
padding: PaddingValues,
tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {},
onScrolledToTop: () -> Unit = {}
) {
val context = LocalContext.current
val context: Context = LocalContext.current
val viewModel: FriendsViewModel =
if (tabIndex == 0) {
koinViewModel<FriendsViewModelImpl>(viewModelStoreOwner = context as AppCompatActivity)
} else {
koinViewModel<OnlineFriendsViewModelImpl>(viewModelStoreOwner = context as AppCompatActivity)
}
LaunchedEffect(orderType) {
viewModel.onOrderTypeChanged(orderType)
}
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
@@ -92,43 +83,6 @@ fun FriendsRoute(
}
}
FriendsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setSelectedTabIndex = viewModel::onTabSelected,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
setScrollIndexOnline = viewModel::setScrollIndexOnline,
setScrollOffsetOnline = viewModel::setScrollOffsetOnline
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class
)
@Composable
fun FriendsScreen(
screenState: FriendsScreenState = FriendsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {},
setSelectedTabIndex: (Int) -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
setScrollIndexOnline: (Int) -> Unit = {},
setScrollOffsetOnline: (Int) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines by remember {
@@ -141,33 +95,28 @@ fun FriendsScreen(
initialFirstVisibleItemIndex = screenState.scrollIndex,
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
val listStateOnline = rememberLazyListState(
initialFirstVisibleItemIndex = screenState.scrollIndexOnline,
initialFirstVisibleItemScrollOffset = screenState.scrollOffsetOnline
)
val scrollToTop = LocalReselectedTab.current[Friends] ?: false
LaunchedEffect(scrollToTop) {
if (scrollToTop) {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
onScrolledToTop()
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L)
.collectLatest(setScrollIndex)
.debounce(250L)
.collectLatest(viewModel::setScrollIndex)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L)
.collectLatest(setScrollOffset)
}
LaunchedEffect(listStateOnline) {
snapshotFlow { listStateOnline.firstVisibleItemIndex }
.debounce(500L)
.collectLatest(setScrollIndexOnline)
}
LaunchedEffect(listStateOnline) {
snapshotFlow { listStateOnline.firstVisibleItemScrollOffset }
.debounce(500L)
.collectLatest(setScrollOffsetOnline)
.debounce(250L)
.collectLatest(viewModel::setScrollOffset)
}
val paginationConditionMet by remember(canPaginate, listState) {
@@ -180,209 +129,64 @@ fun FriendsScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
viewModel.onPaginationConditionsMet()
}
}
val hazeState = LocalHazeState.current
var canScrollBackward by remember {
mutableStateOf(false)
baseError?.let { error ->
VkErrorView(baseError = error)
return
}
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
when {
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
val topBarContainerColor by animateColorAsState(
targetValue = if (currentTheme.enableBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
else -> {
val pullToRefreshState = rememberPullToRefreshState()
val tabItems = remember {
listOf(
TabItem(
titleResId = UiR.string.title_friends_all,
unselectedIconResId = null,
selectedIconResId = null
),
TabItem(
titleResId = UiR.string.title_friends_online,
unselectedIconResId = null,
selectedIconResId = null
)
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}
PullToRefreshBox(
modifier = modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = viewModel::onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.fillMaxWidth()
}
) {
TopAppBar(
title = {
Text(
text = stringResource(id = UiR.string.title_friends),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
modifier = Modifier.fillMaxWidth()
FriendsList(
modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
screenState = screenState,
uiFriends = ImmutableList.copyOf(screenState.friends),
listState = listState,
maxLines = maxLines,
padding = padding,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = setCanScrollBackward
)
PrimaryTabRow(
selectedTabIndex = screenState.selectedTabIndex,
modifier = Modifier,
containerColor = Color.Transparent
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = index == screenState.selectedTabIndex,
onClick = {
if (screenState.selectedTabIndex != index) {
setSelectedTabIndex(index)
}
},
text = {
item.titleResId?.let { resId ->
Text(text = stringResource(id = resId))
}
}
)
}
}
}
}
) { 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
)
}
}
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
else -> {
val pagerState = rememberPagerState(
initialPage = screenState.selectedTabIndex
) {
tabItems.size
}
LaunchedEffect(screenState.selectedTabIndex) {
pagerState.animateScrollToPage(screenState.selectedTabIndex)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.collect(setSelectedTabIndex)
}
val pullToRefreshState = rememberPullToRefreshState()
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
PullToRefreshBox(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
val friendsToDisplay = remember(index) {
if (index == 0) {
screenState.friends
} else {
screenState.onlineFriends
}
}
FriendsList(
modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
screenState = screenState,
uiFriends = ImmutableList.copyOf(friendsToDisplay),
listState = if (index == 0) listState else listStateOnline,
maxLines = maxLines,
padding = padding,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { can ->
canScrollBackward = can
}
)
if (friendsToDisplay.isEmpty()) {
NoItemsView(
customText = if (index == 1) stringResource(UiR.string.no_online_friends) else null,
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
if (screenState.friends.isEmpty()) {
NoItemsView(
customText = if (tabIndex == 1) stringResource(R.string.no_online_friends) else null,
buttonText = stringResource(R.string.action_refresh),
onButtonClick = viewModel::onRefresh
)
}
}
}
@@ -0,0 +1,245 @@
package dev.meloda.fast.friends.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun FriendsRoute(
onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit,
onScrolledToTop: () -> Unit
) {
val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current
val hazeState = LocalHazeState.current
var canScrollBackward by remember {
mutableStateOf(false)
}
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!currentTheme.enableBlur || !canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
val topBarContainerColor by animateColorAsState(
targetValue = if (currentTheme.enableBlur || !canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
val tabItems = remember {
listOf(
TabItem(
titleResId = R.string.title_friends_all,
unselectedIconResId = null,
selectedIconResId = null
),
TabItem(
titleResId = R.string.title_friends_online,
unselectedIconResId = null,
selectedIconResId = null
)
)
}
val pagerState = rememberPagerState(pageCount = tabItems::size)
val selectedTabIndex by remember {
derivedStateOf { pagerState.currentPage }
}
var orderType: String by remember { mutableStateOf("hints") }
var showOrderDialog by remember { mutableStateOf(false) }
val orderPriority = stringResource(UiR.string.friends_order_priority)
val orderName = stringResource(UiR.string.friends_order_name)
val orderRandom = stringResource(UiR.string.friends_order_random)
val orderMobile = stringResource(UiR.string.friends_order_mobile)
val orderSmart = stringResource(UiR.string.friends_order_smart)
val orderTitleItems = remember {
ImmutableList.of(
orderPriority,
orderName,
orderRandom,
orderMobile,
orderSmart
)
}
val orderItems = remember {
listOf("hints", "name", "random", "mobile", "smart")
}
var selectedIndex by remember {
mutableIntStateOf(0)
}
if (showOrderDialog) {
MaterialDialog(
onDismissRequest = { showOrderDialog = false },
confirmText = stringResource(R.string.ok),
confirmAction = {
orderType = orderItems[selectedIndex]
},
cancelText = stringResource(R.string.cancel),
selectionType = SelectionType.Single,
items = orderTitleItems,
preSelectedItems = ImmutableList.of(selectedIndex),
onItemClick = {
selectedIndex = it
},
title = stringResource(UiR.string.friends_order_by_title),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}
)
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.fillMaxWidth()
) {
TopAppBar(
title = {
Text(
text = stringResource(id = R.string.title_friends),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
modifier = Modifier.fillMaxWidth(),
actions = {
IconButton(
onClick = {
showOrderDialog = true
}
) {
Icon(
painter = painterResource(UiR.drawable.round_filter_list_24),
contentDescription = null
)
}
}
)
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier,
containerColor = Color.Transparent
) {
tabItems.forEachIndexed { index, item ->
Tab(
selected = index == selectedTabIndex,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
item.titleResId?.let { resId ->
Text(text = stringResource(id = resId))
}
}
)
}
}
}
}
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { index ->
FriendsScreen(
orderType = orderType,
padding = padding,
tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it },
onScrolledToTop = onScrolledToTop
)
}
}
}
@@ -0,0 +1,23 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.domain.VkMessage
@Immutable
sealed class MessageDialog {
data class MessageOptions(val message: VkMessage) : 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()
data class MessageSpam(
val message: VkMessage,
val isSpam: Boolean
) : MessageDialog()
data class MessageMarkImportance(
val message: VkMessage,
val isImportant: Boolean
) : 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()
}
@@ -0,0 +1,84 @@
package dev.meloda.fast.messageshistory.model
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import dev.meloda.fast.ui.R
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class MessageOption(
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int
) : Parcelable {
data object Retry : MessageOption(
titleResId = R.string.message_context_action_retry,
iconResId = R.drawable.round_restart_alt_24
)
data object Reply : MessageOption(
titleResId = R.string.message_context_action_reply,
iconResId = R.drawable.round_reply_24
)
data object ForwardHere : MessageOption(
titleResId = R.string.message_context_action_forward_here,
iconResId = R.drawable.round_reply_all_24
)
data object Forward : MessageOption(
titleResId = R.string.message_context_action_forward,
iconResId = R.drawable.round_forward_24
)
data object Pin : MessageOption(
titleResId = R.string.message_context_action_pin,
iconResId = R.drawable.pin_outline_24
)
data object Unpin : MessageOption(
titleResId = R.string.message_context_action_unpin,
iconResId = R.drawable.pin_off_outline_24
)
data object Read : MessageOption(
titleResId = R.string.message_context_action_read,
iconResId = R.drawable.round_mark_email_read_24
)
data object Copy : MessageOption(
titleResId = R.string.message_context_action_copy,
iconResId = R.drawable.round_content_copy_24
)
data object MarkAsImportant : MessageOption(
titleResId = R.string.message_context_action_mark_as_important,
iconResId = R.drawable.round_star_24
)
data object UnmarkAsImportant : MessageOption(
titleResId = R.string.message_context_action_unmark_as_important,
iconResId = R.drawable.round_star_outline_24
)
data object MarkAsSpam : MessageOption(
titleResId = R.string.message_context_action_mark_as_spam,
iconResId = R.drawable.round_report_gmailerrorred_24
)
data object UnmarkAsSpam : MessageOption(
titleResId = R.string.message_context_action_unmark_as_spam,
iconResId = R.drawable.round_report_off_24
)
data object Edit : MessageOption(
titleResId = R.string.message_context_action_edit,
iconResId = R.drawable.round_create_24
)
data object Delete : MessageOption(
titleResId = R.string.message_context_action_delete,
iconResId = R.drawable.round_delete_outline_24
)
}
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
@@ -1,18 +1,19 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
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,
val messages: List<UiItem>,
val message: TextFieldValue,
val attachments: List<VkAttachment>,
val isLoading: Boolean,
@@ -20,7 +21,10 @@ data class MessagesHistoryScreenState(
val isPaginationExhausted: Boolean,
val actionMode: ActionMode,
val chatImageUrl: String?,
val conversation: VkConversation
val conversation: VkConversation,
val pinnedMessage: VkMessage?,
val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?
) {
companion object {
@@ -29,7 +33,6 @@ data class MessagesHistoryScreenState(
title = "",
status = null,
avatar = UiImage.Color(0),
messages = emptyList(),
message = TextFieldValue(),
attachments = emptyList(),
isLoading = true,
@@ -37,7 +40,10 @@ data class MessagesHistoryScreenState(
isPaginationExhausted = false,
actionMode = ActionMode.Record,
chatImageUrl = null,
conversation = VkConversation.EMPTY
conversation = VkConversation.EMPTY,
pinnedMessage = null,
pinnedTitle = null,
pinnedSummary = null
)
}
}
@@ -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,
@@ -24,13 +24,16 @@ sealed class UiItem(
val avatar: UiImage,
val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
val sendingStatus: SendingStatus,
val isSelected: Boolean,
val isPinned: Boolean,
val isImportant: Boolean
) : 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)))
}
@@ -27,13 +27,15 @@ fun ActionMessageItem(
Text(
text = item.text,
modifier = modifier
.padding(horizontal = 32.dp)
.padding(
horizontal = 32.dp,
vertical = 4.dp
)
.clip(RoundedCornerShape(12.dp))
.then(
if (item.actionCmId != null) {
Modifier.clickable(onClick = onClick)
}
else Modifier
} else Modifier
)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
.fillMaxWidth()
@@ -1,6 +1,7 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -26,65 +27,75 @@ import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
@Composable
fun IncomingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
val context = LocalContext.current
Row(
modifier = modifier
.fillMaxWidth(0.75f)
.padding(start = 16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Start
.fillMaxWidth()
.then(
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
else Modifier
),
) {
if (message.isInChat) {
Image(
painter =
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
modifier = Modifier
.padding(bottom = 6.dp)
.size(28.dp)
.alpha(if (message.showAvatar) 1f else 0f)
.clip(CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
Column {
AnimatedVisibility(visible = message.showName) {
Text(
Row(
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(start = 16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Start
) {
if (message.isInChat) {
Image(
painter =
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = LocalContext.current.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
modifier = Modifier
.padding(start = 12.dp)
.widthIn(max = 140.dp),
text = message.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
.padding(bottom = 6.dp)
.size(28.dp)
.alpha(if (message.showAvatar) 1f else 0f)
.clip(CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
MessageBubble(
modifier = Modifier,
text = message.text,
isOut = false,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
Column {
AnimatedVisibility(visible = message.showName) {
Text(
modifier = Modifier
.padding(start = 12.dp)
.widthIn(max = 140.dp),
text = message.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
MessageBubble(
modifier = Modifier,
text = message.text,
isOut = false,
date = message.date,
edited = message.isEdited,
isRead = message.isRead,
sendingStatus = message.sendingStatus,
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
)
}
}
Spacer(modifier = Modifier.fillMaxWidth(0.25f))
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
@@ -20,113 +21,192 @@ 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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
import dev.meloda.fast.ui.R as UiR
@Composable
fun MessageBubble(
modifier: Modifier = Modifier,
text: String?,
text: AnnotatedString?,
isOut: Boolean,
date: String?,
edited: Boolean,
animate: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus
sendingStatus: SendingStatus,
pinned: Boolean,
important: Boolean,
isSelected: Boolean
) {
val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
} else {
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
)
) {
val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
mainPart + readIndicatorPart
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) {
Text(
text = text,
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (animate) Modifier.animateContentSize() else Modifier),
color = textColor
)
}
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 (edited) {
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.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
}
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) {
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
{
Text(
text = kotlin.run {
val builder = AnnotatedString.Builder(text)
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))
}
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
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
}
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
else LocalContentColor.current,
contentDescription = null
)
),
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,13 +1,17 @@
package dev.meloda.fast.messageshistory.presentation
import android.content.SharedPreferences
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,6 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@@ -75,60 +80,29 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
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.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.util.firstMessage
import dev.meloda.fast.messageshistory.model.UiItem
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.components.IconButton
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.emptyImmutableList
import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
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 baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
MessagesHistoryScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
showEmojiButton = showEmojiButton,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked,
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
@@ -137,27 +111,51 @@ fun MessagesHistoryRoute(
@Composable
fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
messages: ImmutableList<VkMessage> = emptyImmutableList(),
uiMessages: ImmutableList<UiItem> = emptyImmutableList(),
scrollIndex: Int? = null,
selectedMessages: ImmutableList<VkMessage> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
onBack: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefreshDropdownItemClicked: () -> Unit = {},
onToggleAnimationsDropdownItemClicked: (Boolean) -> Unit = {},
onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onTopBarClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {}
onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val coroutineScope = rememberCoroutineScope()
val preferences: SharedPreferences = koinInject()
val currentTheme = LocalThemeConfig.current
val theme = LocalThemeConfig.current
val listState = rememberLazyListState()
val hazeState = remember { HazeState() }
LaunchedEffect(scrollIndex) {
if (scrollIndex != null) {
coroutineScope.launch {
listState.animateScrollToItem(scrollIndex)
onScrolledToIndex()
}
}
}
BackHandler(
enabled = selectedMessages.isNotEmpty(),
onBack = onClose
)
val pinnedMessage = screenState.pinnedMessage
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
@@ -177,12 +175,24 @@ fun MessagesHistoryScreen(
mutableStateOf(false)
}
val hazeState = remember { HazeState() }
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollForward) 1f else 0f,
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
val topBarContainerColor by animateColorAsState(
targetValue =
if (theme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
var messageBarHeight by remember {
@@ -191,54 +201,97 @@ fun MessagesHistoryScreen(
val density = LocalDensity.current
val showReplyAction by remember(selectedMessages) {
derivedStateOf { selectedMessages.size == 1 }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.then(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
) {
TopAppBar(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
.fillMaxWidth()
.then(
if (screenState.isLoading && messages.isEmpty()) Modifier
else Modifier.clickable {
onTopBarClicked()
}
),
title = {
Row(
modifier = Modifier
.weight(1f),
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
val avatar = screenState.avatar.getImage()
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),
)
if (selectedMessages.isEmpty()) {
val avatar = screenState.avatar.getImage()
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 {
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))
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text =
if (screenState.isLoading) stringResource(id = UiR.string.title_loading)
else screenState.title,
text = when {
screenState.isLoading -> stringResource(id = UiR.string.title_loading)
selectedMessages.size > 0 -> "(${selectedMessages.size})"
else -> screenState.title
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
@@ -246,73 +299,109 @@ fun MessagesHistoryScreen(
}
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(
onClick = {
if (selectedMessages.isEmpty()) onBack()
else onClose()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
imageVector = if (selectedMessages.isEmpty()) {
Icons.AutoMirrored.Rounded.ArrowBack
} else {
Icons.Rounded.Close
},
contentDescription = "Back button"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = {
IconButton(
onClick = { dropDownMenuExpanded = true }
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
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 not messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId
)
},
text = {
Text(text = "Materials")
}
)
DropdownMenuItem(
onClick = {
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
Text(text = "Refresh")
},
leadingIcon = {
if (selectedMessages.isNotEmpty()) {
AnimatedVisibility(showReplyAction) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
imageVector = Icons.Rounded.Refresh,
painter = painterResource(UiR.drawable.round_reply_24),
contentDescription = null
)
}
)
}
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_reply_all_24),
contentDescription = null
)
}
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_forward_24),
contentDescription = null
)
}
IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon(
painter = painterResource(UiR.drawable.round_delete_outline_24),
contentDescription = null
)
}
} else {
IconButton(
onClick = { dropDownMenuExpanded = true }
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(UiR.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
}
}
}
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() }
derivedStateOf { screenState.isLoading && messages.isNotEmpty() }
}
if (showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -320,6 +409,19 @@ fun MessagesHistoryScreen(
AnimatedVisibility(!showHorizontalProgressBar) {
HorizontalDivider()
}
if (!screenState.isLoading && pinnedMessage != null) {
PinnedMessageContainer(
modifier = Modifier,
pinnedMessage = requireNotNull(pinnedMessage),
title = screenState.pinnedTitle.orDots(),
summary = screenState.pinnedSummary,
canChangePin = screenState.conversation.canChangePin,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
)
HorizontalDivider()
}
}
}
) { padding ->
@@ -331,18 +433,32 @@ fun MessagesHistoryScreen(
.padding(bottom = padding.calculateBottomPadding()),
) {
MessagesList(
modifier = Modifier.align(Alignment.BottomStart),
hazeState = hazeState,
listState = listState,
immutableMessages = ImmutableList.copyOf(screenState.messages),
hasPinnedMessage = pinnedMessage != null,
uiMessages = uiMessages,
isPaginating = screenState.isPaginating,
messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId ->
coroutineScope.launch {
listState.animateScrollToItem(
index = screenState.messages.indexOfMessageByCmId(cmId)
)
val index = uiMessages.values.indexOfMessageByCmId(cmId)
if (index == null) { // сообщения нет в списке
// pizdets
} else {
coroutineScope.launch {
listState.animateScrollToItem(index = index)
}
}
}
},
onMessageClicked = { id ->
if (selectedMessages.isNotEmpty()) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
}
}
onMessageClicked(id)
},
onMessageLongClicked = onMessageLongClicked
)
Column(
@@ -362,13 +478,28 @@ fun MessagesHistoryScreen(
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(10.dp))
Row(
modifier = Modifier
.clip(RoundedCornerShape(36.dp))
.then(
if (theme.enableBlur) {
Modifier
.hazeEffect(
state = hazeState,
style = HazeMaterials.ultraThin()
)
.border(
1.dp, MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(36.dp)
)
} else Modifier
)
.animateContentSize()
.weight(1f)
.clip(RoundedCornerShape(36.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
.background(
if (theme.enableBlur) Color.Transparent
else MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
)
.onGloballyPositioned {
messageBarHeight = with(density) {
it.size.height.toDp()
@@ -386,7 +517,9 @@ fun MessagesHistoryScreen(
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
@@ -405,7 +538,9 @@ fun MessagesHistoryScreen(
},
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.LONG_PRESS)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.LONG_PRESS
)
}
onEmojiButtonLongClicked()
},
@@ -447,8 +582,11 @@ fun MessagesHistoryScreen(
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
onAttachmentButtonClicked()
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
@@ -484,7 +622,9 @@ fun MessagesHistoryScreen(
onClick = {
if (screenState.actionMode == ActionMode.Record) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
@@ -535,8 +675,14 @@ fun MessagesHistoryScreen(
}
}
if (screenState.isLoading && screenState.messages.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
when {
screenState.isLoading && messages.values.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
baseError != null -> {
VkErrorView(baseError = baseError)
}
}
}
}
@@ -1,54 +1,63 @@
package dev.meloda.fast.messageshistory.presentation
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessagesList(
modifier: Modifier = Modifier,
hasPinnedMessage: Boolean,
hazeState: HazeState,
listState: LazyListState,
immutableMessages: ImmutableList<UiItem>,
uiMessages: ImmutableList<UiItem>,
isPaginating: Boolean,
messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {}
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {}
) {
val enableAnimations = remember {
AppSettings.Experimental.moreAnimations
}
val messages = remember(immutableMessages) {
immutableMessages.toList()
}
val currentTheme = LocalThemeConfig.current
val theme = LocalThemeConfig.current
val view = LocalView.current
LazyColumn(
modifier = modifier
.fillMaxWidth()
.then(
if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState)
if (theme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else Modifier
),
state = listState,
@@ -65,7 +74,7 @@ fun MessagesList(
}
items(
items = messages,
items = uiMessages.values,
key = UiItem::id,
contentType = { item ->
when (item) {
@@ -77,6 +86,12 @@ fun MessagesList(
when (item) {
is UiItem.ActionMessage -> {
ActionMessageItem(
modifier = Modifier.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
) else Modifier
),
item = item,
onClick = {
if (item.actionCmId != null) {
@@ -87,37 +102,65 @@ fun MessagesList(
}
is UiItem.Message -> {
if (item.isOut) {
OutgoingMessageBubble(
modifier =
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
} else {
IncomingMessageBubble(
modifier =
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
val backgroundColor by animateColorAsState(
targetValue = if (item.isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
} else {
Color.Transparent
}
)
Surface(
modifier = Modifier
.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
) else Modifier
)
.combinedClickable(
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
onMessageLongClicked(item.id)
},
onClick = { onMessageClicked(item.id) }
),
color = backgroundColor
) {
if (item.isOut) {
OutgoingMessageBubble(
modifier =
Modifier
.padding(vertical = 4.dp)
.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item
)
} else {
IncomingMessageBubble(
modifier =
Modifier
.padding(vertical = 4.dp)
.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
item {
@@ -130,6 +173,10 @@ fun MessagesList(
}
}
if (hasPinnedMessage) {
Spacer(modifier = Modifier.height(56.dp))
}
Spacer(Modifier.height(8.dp))
Spacer(
modifier = Modifier
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -11,34 +12,41 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
@Composable
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
Row(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxWidth()
.then(
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
else Modifier
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Column(
modifier = Modifier
.padding(end = 16.dp)
.fillMaxWidth(0.75f),
.fillMaxWidth(0.85f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End,
) {
MessageBubble(
modifier = Modifier,
text = message.text.orDots(),
text = message.text,
isOut = true,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
sendingStatus = message.sendingStatus,
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
)
}
}
@@ -0,0 +1,88 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.IconButton
@Composable
fun PinnedMessageContainer(
modifier: Modifier = Modifier,
pinnedMessage: VkMessage,
title: String,
summary: AnnotatedString?,
canChangePin: Boolean,
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onPinnedMessageClicked(pinnedMessage.id) }
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.rotate(45f)
.alpha(0.5f),
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
summary?.let { summary ->
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(text = summary)
}
}
}
if (canChangePin) {
Spacer(modifier = Modifier.width(16.dp))
IconButton(onClick = onUnpinMessageButtonClicked) {
Icon(
modifier = Modifier.alpha(0.5f),
imageVector = Icons.Rounded.Close,
contentDescription = null
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
@@ -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() -> {
@@ -96,11 +102,12 @@ fun VkMessage.asPresentation(
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean
showTimeInActionMessages: Boolean,
isSelected: Boolean
): 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),
@@ -111,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(),
@@ -126,9 +137,13 @@ fun VkMessage.asPresentation(
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
}
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant
)
}
@@ -537,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
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -139,8 +140,11 @@ fun EditTextAlert(
cancelText = stringResource(id = R.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
) {
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(20.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
TextField(
modifier = Modifier
.fillMaxWidth()
@@ -155,8 +159,8 @@ fun EditTextAlert(
placeholder = { Text(text = "Value") },
shape = RoundedCornerShape(10.dp),
)
Spacer(modifier = Modifier.width(20.dp))
}
Spacer(modifier = Modifier.height(8.dp))
}
LaunchedEffect(Unit) {