forked from melod1n/fast-messenger
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:
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ fun NavController.navigateToCaptcha(captchaImageUrl: String) {
|
||||
}
|
||||
|
||||
fun NavController.setCaptchaResult(code: String?) {
|
||||
this.currentBackStackEntry
|
||||
this.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("captcha_code", code)
|
||||
}
|
||||
|
||||
+7
-1
@@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -24,6 +27,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -123,7 +127,9 @@ fun CaptchaScreen(
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Scaffold { padding ->
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-16
@@ -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? {
|
||||
|
||||
+193
-273
@@ -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") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ fun NavController.navigateToValidation(arguments: ValidationArguments) {
|
||||
}
|
||||
|
||||
fun NavController.setValidationResult(code: String?) {
|
||||
this.currentBackStackEntry
|
||||
this.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("validation_code", code)
|
||||
}
|
||||
|
||||
+12
-2
@@ -7,10 +7,13 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -23,6 +26,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -35,10 +39,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.ContentType
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentType
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -146,7 +153,9 @@ fun ValidationScreen(
|
||||
|
||||
var code by remember { mutableStateOf(TextFieldValue(screenState.code.orEmpty())) }
|
||||
|
||||
Scaffold { padding ->
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -210,7 +219,8 @@ fun ValidationScreen(
|
||||
placeholder = { Text(text = "Code") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.semantics { contentType = ContentType.SmsOtpCode },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_qr_code_24),
|
||||
|
||||
+60
-17
@@ -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
|
||||
}
|
||||
|
||||
+38
-2
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -9,8 +9,8 @@ data class ChatMaterialsScreenState(
|
||||
val attachmentType: String,
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val peerId: Int,
|
||||
val conversationMessageId: Int
|
||||
val peerId: Long,
|
||||
val cmId: Long
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -21,7 +21,7 @@ data class ChatMaterialsScreenState(
|
||||
isPaginating = false,
|
||||
isPaginationExhausted = false,
|
||||
peerId = -1,
|
||||
conversationMessageId = -1
|
||||
cmId = -1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
+25
-10
@@ -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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ChatMaterials(
|
||||
val peerId: Int,
|
||||
val conversationMessageId: Int
|
||||
val peerId: Long,
|
||||
val conversationMessageId: Long
|
||||
) {
|
||||
companion object {
|
||||
fun from(savedStateHandle: SavedStateHandle) =
|
||||
@@ -31,7 +31,7 @@ fun NavGraphBuilder.chatMaterialsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToChatMaterials(peerId: Int, conversationMessageId: Int) {
|
||||
fun NavController.navigateToChatMaterials(peerId: Long, conversationMessageId: Long) {
|
||||
this.navigate(
|
||||
ChatMaterials(
|
||||
peerId = peerId,
|
||||
|
||||
+179
-230
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+232
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+264
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+256
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-11
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+393
-425
File diff suppressed because it is too large
Load Diff
+25
-2
@@ -3,12 +3,35 @@ package dev.meloda.fast.conversations.di
|
||||
import dev.meloda.fast.conversations.ConversationsViewModelImpl
|
||||
import dev.meloda.fast.domain.ConversationsUseCase
|
||||
import dev.meloda.fast.domain.ConversationsUseCaseImpl
|
||||
import dev.meloda.fast.model.ConversationsFilter
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.core.scope.Scope
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val conversationsModule = module {
|
||||
viewModel(named(ConversationsFilter.ALL)) {
|
||||
createConversationsViewModel(ConversationsFilter.ALL)
|
||||
}
|
||||
viewModel(named(ConversationsFilter.ARCHIVE)) {
|
||||
createConversationsViewModel(ConversationsFilter.ARCHIVE)
|
||||
}
|
||||
|
||||
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
|
||||
viewModelOf(::ConversationsViewModelImpl)
|
||||
}
|
||||
|
||||
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModelImpl {
|
||||
return ConversationsViewModelImpl(
|
||||
filter = filter,
|
||||
updatesParser = get(),
|
||||
conversationsUseCase = get(),
|
||||
messagesUseCase = get(),
|
||||
resources = get(),
|
||||
userSettings = get(),
|
||||
imageLoader = get(),
|
||||
applicationContext = get(),
|
||||
loadConversationsByIdUseCase = get()
|
||||
)
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class ConversationDialog {
|
||||
data class ConversationPin(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationUnpin(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationDelete(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationArchive(val conversationId: Long) : ConversationDialog()
|
||||
data class ConversationUnarchive(val conversationId: Long) : ConversationDialog()
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class ConversationNavigation {
|
||||
|
||||
data class MessagesHistory(val peerId: Long) : ConversationNavigation()
|
||||
|
||||
data object CreateChat : ConversationNavigation()
|
||||
}
|
||||
+3
-6
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import dev.meloda.fast.model.InteractionType
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
data class InteractionJob(
|
||||
val interactionType: InteractionType,
|
||||
val timerJob: Job
|
||||
)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package dev.meloda.fast.conversations.model
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class NewInteractionException : CancellationException()
|
||||
+58
-20
@@ -1,35 +1,73 @@
|
||||
package dev.meloda.fast.conversations.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.conversations.ConversationsViewModel
|
||||
import androidx.navigation.navigation
|
||||
import dev.meloda.fast.conversations.ConversationsViewModelImpl
|
||||
import dev.meloda.fast.conversations.presentation.ConversationsRoute
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.extensions.sharedViewModel
|
||||
import dev.meloda.fast.model.ConversationsFilter
|
||||
import dev.meloda.fast.ui.theme.LocalNavController
|
||||
import dev.meloda.fast.ui.theme.getOrThrow
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
@Serializable
|
||||
object ConversationsGraph
|
||||
|
||||
@Serializable
|
||||
object Conversations
|
||||
|
||||
fun NavGraphBuilder.conversationsScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onConversationItemClicked: (id: Int) -> Unit,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.meloda.fast.conversations.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.os.bundleOf
|
||||
import dev.meloda.fast.conversations.model.ConversationDialog
|
||||
import dev.meloda.fast.conversations.model.ConversationsScreenState
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: ConversationsScreenState,
|
||||
dialog: ConversationDialog?,
|
||||
onConfirmed: (ConversationDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (ConversationDialog) -> Unit = {},
|
||||
onItemPicked: (ConversationDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (dialog) {
|
||||
null -> Unit
|
||||
|
||||
is ConversationDialog.ConversationArchive -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_archive_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_archive),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationUnarchive -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_unarchive_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_unarchive),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationDelete -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_delete_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_delete),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationPin -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_pin_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_pin),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationDialog.ConversationUnpin -> {
|
||||
MaterialDialog(
|
||||
onDismissRequest = { onDismissed(dialog) },
|
||||
title = stringResource(id = UiR.string.confirm_unpin_conversation),
|
||||
confirmAction = { onConfirmed(dialog, bundleOf()) },
|
||||
confirmText = stringResource(id = UiR.string.action_unpin),
|
||||
cancelText = stringResource(id = UiR.string.cancel)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-25
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+23
-10
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.meloda.fast.conversations.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.conversations.ConversationsViewModel
|
||||
import dev.meloda.fast.conversations.model.ConversationNavigation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun ConversationsRoute(
|
||||
viewModel: ConversationsViewModel,
|
||||
onBack: (() -> Unit)? = null,
|
||||
onError: (BaseError) -> Unit,
|
||||
onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
|
||||
onNavigateToCreateChat: (() -> Unit)? = null,
|
||||
onNavigateToArchive: (() -> Unit)? = null,
|
||||
onScrolledToTop: () -> Unit,
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
|
||||
val conversations by viewModel.uiConversations.collectAsStateWithLifecycle()
|
||||
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(navigationEvent) {
|
||||
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
|
||||
null -> false
|
||||
|
||||
is ConversationNavigation.CreateChat -> {
|
||||
onNavigateToCreateChat?.invoke()
|
||||
true
|
||||
}
|
||||
|
||||
is ConversationNavigation.MessagesHistory -> {
|
||||
onNavigateToMessagesHistory(navigation.peerId)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBeConsumed) viewModel.onNavigationConsumed()
|
||||
}
|
||||
|
||||
ConversationsScreen(
|
||||
onBack = { onBack?.invoke() },
|
||||
screenState = screenState,
|
||||
conversations = conversations.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
onConversationItemClicked = viewModel::onConversationItemClick,
|
||||
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
|
||||
onOptionClicked = viewModel::onOptionClicked,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onRefreshDropdownItemClicked = viewModel::onRefresh,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
|
||||
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
|
||||
setScrollIndex = viewModel::setScrollIndex,
|
||||
setScrollOffset = viewModel::setScrollOffset,
|
||||
onConsumeReselection = onScrolledToTop,
|
||||
onErrorViewButtonClicked = {
|
||||
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
|
||||
onError(requireNotNull(baseError))
|
||||
} else {
|
||||
viewModel.onErrorButtonClicked()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
+82
-128
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-17
@@ -23,8 +23,10 @@ import dev.meloda.fast.model.api.domain.VkAttachment
|
||||
import dev.meloda.fast.model.api.domain.VkConversation
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.model.api.ActionState
|
||||
import dev.meloda.fast.ui.model.api.ConversationOption
|
||||
import dev.meloda.fast.ui.model.api.UiConversation
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import dev.meloda.fast.ui.util.emptyImmutableList
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.math.ln
|
||||
@@ -33,7 +35,9 @@ import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
fun VkConversation.asPresentation(
|
||||
resources: Resources,
|
||||
useContactName: Boolean
|
||||
useContactName: Boolean,
|
||||
isExpanded: Boolean = false,
|
||||
options: ImmutableList<ConversationOption> = emptyImmutableList()
|
||||
): UiConversation = UiConversation(
|
||||
id = id,
|
||||
lastMessageId = lastMessageId,
|
||||
@@ -47,14 +51,15 @@ fun VkConversation.asPresentation(
|
||||
isPinned = majorId > 0,
|
||||
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
|
||||
isBirthday = extractBirthday(this),
|
||||
isUnread = extractReadCondition(this, lastMessage),
|
||||
isUnread = !isRead(),
|
||||
isAccount = isAccount(id),
|
||||
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
|
||||
lastMessage = lastMessage,
|
||||
peerType = peerType,
|
||||
interactionText = extractInteractionText(resources, this),
|
||||
isExpanded = false,
|
||||
options = ImmutableList.empty()
|
||||
isExpanded = isExpanded,
|
||||
isArchived = isArchived,
|
||||
options = options
|
||||
)
|
||||
|
||||
fun VkConversation.extractAvatar() = when (peerType) {
|
||||
@@ -101,7 +106,7 @@ private fun extractUnreadCount(
|
||||
lastMessage: VkMessage?,
|
||||
conversation: VkConversation
|
||||
): String? = when {
|
||||
lastMessage?.isOut == false && !conversation.isInUnread() -> null
|
||||
lastMessage?.isOut == false && conversation.isInRead() -> null
|
||||
conversation.unreadCount == 0 -> null
|
||||
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
|
||||
else -> {
|
||||
@@ -121,7 +126,7 @@ private fun extractUnreadCount(
|
||||
private fun extractMessage(
|
||||
resources: Resources,
|
||||
lastMessage: VkMessage?,
|
||||
peerId: Int,
|
||||
peerId: Long,
|
||||
peerType: PeerType
|
||||
): AnnotatedString {
|
||||
val youPrefix = UiText.Resource(UiR.string.you_message_prefix)
|
||||
@@ -210,7 +215,12 @@ private fun extractMessage(
|
||||
.replace("<br/>", " ")
|
||||
.replace("–", "-")
|
||||
.trim()
|
||||
.let { text -> getTextWithVisualizedMentions(text, Color.Red) }
|
||||
.let { text ->
|
||||
extractTextWithVisualizedMentions(
|
||||
isOut = lastMessage?.isOut == true,
|
||||
originalText = text
|
||||
)
|
||||
}
|
||||
.let { text -> prefix + text }
|
||||
}
|
||||
|
||||
@@ -612,6 +622,9 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
|
||||
AttachmentType.PODCAST -> null
|
||||
AttachmentType.NARRATIVE -> null
|
||||
AttachmentType.ARTICLE -> null
|
||||
AttachmentType.VIDEO_MESSAGE -> null
|
||||
AttachmentType.GROUP_CHAT_STICKER -> UiR.drawable.ic_attachment_sticker
|
||||
AttachmentType.STICKER_PACK_PREVIEW -> null
|
||||
}?.let(UiImage::Resource)
|
||||
}
|
||||
|
||||
@@ -649,10 +662,9 @@ private fun extractForwardsText(
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
private fun getTextWithVisualizedMentions(
|
||||
originalText: String,
|
||||
mentionColor: Color,
|
||||
fun extractTextWithVisualizedMentions(
|
||||
isOut: Boolean,
|
||||
originalText: String
|
||||
): AnnotatedString = buildAnnotatedString {
|
||||
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
|
||||
|
||||
@@ -676,7 +688,7 @@ private fun getTextWithVisualizedMentions(
|
||||
replacements.add(indexRange to replaced)
|
||||
|
||||
mentions += MentionIndex(
|
||||
id = id.toIntOrNull() ?: -1,
|
||||
id = id.toLongOrNull() ?: -1,
|
||||
idPrefix = idPrefix,
|
||||
indexRange = indexRange
|
||||
)
|
||||
@@ -693,7 +705,7 @@ private fun getTextWithVisualizedMentions(
|
||||
val endIndex = mention.indexRange.last
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(color = mentionColor),
|
||||
style = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
@@ -707,7 +719,7 @@ private fun getTextWithVisualizedMentions(
|
||||
}
|
||||
|
||||
data class MentionIndex(
|
||||
val id: Int,
|
||||
val id: Long,
|
||||
val idPrefix: String,
|
||||
val indexRange: IntRange
|
||||
)
|
||||
@@ -755,6 +767,9 @@ private fun getAttachmentUiText(
|
||||
AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
|
||||
AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative
|
||||
AttachmentType.ARTICLE -> UiR.string.message_attachments_article
|
||||
AttachmentType.VIDEO_MESSAGE -> UiR.string.message_attachments_video_message
|
||||
AttachmentType.GROUP_CHAT_STICKER -> UiR.string.message_attachments_group_sticker
|
||||
AttachmentType.STICKER_PACK_PREVIEW -> UiR.string.message_attachments_sticker_pack_preview
|
||||
}.let(UiText::Resource)
|
||||
}
|
||||
|
||||
@@ -796,10 +811,9 @@ private fun extractBirthday(conversation: VkConversation): Boolean {
|
||||
private fun extractReadCondition(
|
||||
conversation: VkConversation,
|
||||
lastMessage: VkMessage?
|
||||
): Boolean = (lastMessage?.isOut == true && conversation.isOutUnread()) ||
|
||||
(lastMessage?.isOut == false && conversation.isInUnread())
|
||||
): Boolean = !conversation.isRead(lastMessage)
|
||||
|
||||
private fun isAccount(peerId: Int) = peerId == UserConfig.userId
|
||||
private fun isAccount(peerId: Long) = peerId == UserConfig.userId
|
||||
|
||||
private fun extractInteractionText(
|
||||
resources: Resources,
|
||||
|
||||
+4
-4
@@ -33,13 +33,13 @@ interface CreateChatViewModel {
|
||||
val currentOffset: StateFlow<Int>
|
||||
val canPaginate: StateFlow<Boolean>
|
||||
|
||||
val isChatCreated: StateFlow<Int?>
|
||||
val isChatCreated: StateFlow<Long?>
|
||||
|
||||
fun onPaginationConditionsMet()
|
||||
fun onRefresh()
|
||||
fun onErrorConsumed()
|
||||
|
||||
fun toggleFriendSelection(userId: Int)
|
||||
fun toggleFriendSelection(userId: Long)
|
||||
|
||||
fun onTitleTextInputChanged(newTitle: String)
|
||||
|
||||
@@ -62,7 +62,7 @@ class CreateChatViewModelImpl(
|
||||
override val currentOffset = MutableStateFlow(0)
|
||||
override val canPaginate = MutableStateFlow(false)
|
||||
|
||||
override val isChatCreated = MutableStateFlow<Int?>(null)
|
||||
override val isChatCreated = MutableStateFlow<Long?>(null)
|
||||
|
||||
private val useContactNames: Boolean = userSettings.useContactNames.value
|
||||
|
||||
@@ -84,7 +84,7 @@ class CreateChatViewModelImpl(
|
||||
baseError.setValue { null }
|
||||
}
|
||||
|
||||
override fun toggleFriendSelection(userId: Int) {
|
||||
override fun toggleFriendSelection(userId: Long) {
|
||||
val newSelectionList = screenState.value.selectedFriendsIds.toMutableList()
|
||||
|
||||
if (newSelectionList.contains(userId)) {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ data class CreateChatScreenState(
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val friends: List<UiFriend>,
|
||||
val selectedFriendsIds: List<Int>,
|
||||
val selectedFriendsIds: List<Long>,
|
||||
val chatTitle: String
|
||||
) {
|
||||
companion object {
|
||||
|
||||
+8
-4
@@ -1,24 +1,28 @@
|
||||
package dev.meloda.fast.conversations.navigation
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.conversations.CreateChatViewModel
|
||||
import dev.meloda.fast.conversations.CreateChatViewModelImpl
|
||||
import dev.meloda.fast.conversations.presentation.CreateChatRoute
|
||||
import dev.meloda.fast.ui.extensions.sharedViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Serializable
|
||||
object CreateChat
|
||||
|
||||
fun NavGraphBuilder.createChatScreen(
|
||||
onChatCreated: (Int) -> Unit,
|
||||
onChatCreated: (Long) -> Unit,
|
||||
navController: NavController,
|
||||
) {
|
||||
composable<CreateChat> {
|
||||
val viewModel: CreateChatViewModel =
|
||||
it.sharedViewModel<CreateChatViewModelImpl>(navController = navController)
|
||||
val context = LocalContext.current
|
||||
val viewModel: CreateChatViewModel = koinViewModel<CreateChatViewModelImpl>(
|
||||
viewModelStoreOwner = context as AppCompatActivity
|
||||
)
|
||||
|
||||
CreateChatRoute(
|
||||
onError = {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ fun CreateChatItem(
|
||||
friend: UiFriend,
|
||||
maxLines: Int,
|
||||
isSelected: Boolean,
|
||||
onItemClicked: (Int) -> Unit
|
||||
onItemClicked: (Long) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ fun CreateChatList(
|
||||
maxLines: Int,
|
||||
modifier: Modifier,
|
||||
padding: PaddingValues,
|
||||
onItemClicked: (Int) -> Unit,
|
||||
onItemClicked: (Long) -> Unit,
|
||||
onTitleTextInputChanged: (String) -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
+21
-41
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-11
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+90
-286
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+245
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+690
-255
File diff suppressed because it is too large
Load Diff
+23
@@ -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()
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class MessageNavigation {
|
||||
|
||||
data class ChatMaterials(
|
||||
val peerId: Long,
|
||||
val cmId: Long
|
||||
) : MessageNavigation()
|
||||
}
|
||||
+84
@@ -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
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
|
||||
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
|
||||
|
||||
+11
-5
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,17 +27,17 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
|
||||
fun NavGraphBuilder.messagesHistoryScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
|
||||
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit
|
||||
) {
|
||||
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
|
||||
MessagesHistoryRoute(
|
||||
onError = onError,
|
||||
onBack = onBack,
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onNavigateToChatMaterials = onNavigateToChatMaterials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Int) {
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Long) {
|
||||
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
|
||||
}
|
||||
|
||||
+5
-3
@@ -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()
|
||||
|
||||
+57
-46
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+152
-72
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.messageshistory.model.MessageDialog
|
||||
import dev.meloda.fast.messageshistory.model.MessageOption
|
||||
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.basic.ContentAlpha
|
||||
import dev.meloda.fast.ui.basic.LocalContentAlpha
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
dialog: MessageDialog?,
|
||||
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (MessageDialog) -> Unit = {},
|
||||
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (dialog) {
|
||||
null -> Unit
|
||||
|
||||
is MessageDialog.MessageOptions -> {
|
||||
MessageOptionsDialog(
|
||||
screenState = screenState,
|
||||
message = dialog.message,
|
||||
onDismissed = { onDismissed(dialog) },
|
||||
onItemPicked = { bundle -> onItemPicked(dialog, bundle) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = listOf(dialog.message),
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagesDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = dialog.messages,
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagePin,
|
||||
is MessageDialog.MessageUnpin -> {
|
||||
MessagePinStateDialog(
|
||||
pin = dialog is MessageDialog.MessagePin,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageMarkImportance -> {
|
||||
MessageImportanceDialog(
|
||||
important = dialog.isImportant,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageSpam -> {
|
||||
MessageSpamDialog(
|
||||
spam = dialog.isSpam,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MessageOptionsDialog(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
message: VkMessage,
|
||||
onDismissed: () -> Unit = {},
|
||||
onItemPicked: (Bundle) -> Unit
|
||||
) {
|
||||
val options = mutableListOf<MessageOption>()
|
||||
if (message.isFailed()) {
|
||||
options += MessageOption.Retry
|
||||
} else {
|
||||
options += MessageOption.Reply
|
||||
options += MessageOption.ForwardHere
|
||||
options += MessageOption.Forward
|
||||
|
||||
if (message.isPeerChat() && screenState.conversation.canChangePin) {
|
||||
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
|
||||
}
|
||||
|
||||
if (!message.isRead(screenState.conversation)) {
|
||||
options += MessageOption.Read
|
||||
}
|
||||
|
||||
options += MessageOption.Copy
|
||||
|
||||
if (message.isOut) {
|
||||
val diff = System.currentTimeMillis() - message.date * 1000L
|
||||
if (diff - TimeUnit.DAYS.toMillis(1) <= 0) {
|
||||
options += MessageOption.Edit
|
||||
}
|
||||
}
|
||||
|
||||
options += if (message.isImportant) MessageOption.UnmarkAsImportant
|
||||
else MessageOption.MarkAsImportant
|
||||
|
||||
|
||||
if (!message.isOut) {
|
||||
options += if (message.isSpam) MessageOption.UnmarkAsSpam
|
||||
else MessageOption.MarkAsSpam
|
||||
}
|
||||
}
|
||||
|
||||
options += MessageOption.Delete
|
||||
|
||||
val messageOptions = options.map { option ->
|
||||
Triple(
|
||||
stringResource(option.titleResId),
|
||||
painterResource(option.iconResId),
|
||||
when {
|
||||
option in listOf(
|
||||
MessageOption.Delete,
|
||||
MessageOption.MarkAsSpam
|
||||
) -> MaterialTheme.colorScheme.error
|
||||
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(onDismissRequest = onDismissed) {
|
||||
messageOptions
|
||||
.forEachIndexed { index, (title, painter, tintColor) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row {
|
||||
Text(text = title)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
tint = tintColor
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onDismissed()
|
||||
val pickedOption = options[index]
|
||||
onItemPicked(bundleOf("option" to pickedOption))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageDeleteDialog(
|
||||
messages: List<VkMessage>,
|
||||
onConfirmed: (Bundle) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
var forEveryone by remember {
|
||||
mutableStateOf(
|
||||
!messages.any { it.peerId == UserConfig.userId }
|
||||
&& messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldBeDisabled by remember(messages) {
|
||||
mutableStateOf(
|
||||
messages.any { it.peerId == UserConfig.userId }
|
||||
|| messages.all(VkMessage::isFailed)
|
||||
|| !messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(R.string.delete_message_title),
|
||||
confirmText = stringResource(R.string.action_delete),
|
||||
confirmAction = {
|
||||
onConfirmed(
|
||||
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
|
||||
)
|
||||
},
|
||||
cancelText = stringResource(R.string.cancel),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!shouldBeDisabled) {
|
||||
Modifier.clickable { forEveryone = !forEveryone }
|
||||
} else Modifier)
|
||||
.fillMaxWidth()
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = forEveryone,
|
||||
onCheckedChange = null,
|
||||
enabled = !shouldBeDisabled
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
LocalContentAlpha(
|
||||
alpha = if (shouldBeDisabled) ContentAlpha.disabled
|
||||
else ContentAlpha.high
|
||||
) {
|
||||
Text(text = stringResource(R.string.delete_message_for_everyone))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagePinStateDialog(
|
||||
pin: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (pin) R.string.pin_message_title
|
||||
else R.string.unpin_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (pin) R.string.pin_message_text
|
||||
else R.string.unpin_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (pin) R.string.action_pin
|
||||
else R.string.action_unpin
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageImportanceDialog(
|
||||
important: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (important) R.string.important_message_title
|
||||
else R.string.unimportant_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (important) R.string.important_message_text
|
||||
else R.string.unimportant_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (important) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageSpamDialog(
|
||||
spam: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (spam) R.string.spam_message_title
|
||||
else R.string.unspam_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (spam) R.string.spam_message_text
|
||||
else R.string.unspam_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (spam) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import dev.meloda.fast.messageshistory.model.MessageNavigation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
|
||||
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
|
||||
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(navigationEvent) {
|
||||
val needToConsume = when (val navigation = navigationEvent) {
|
||||
null -> false
|
||||
|
||||
is MessageNavigation.ChatMaterials -> {
|
||||
val (peerId, cmId) = navigation
|
||||
onNavigateToChatMaterials(peerId, cmId)
|
||||
true
|
||||
}
|
||||
}
|
||||
if (needToConsume) viewModel.onNavigationConsumed()
|
||||
}
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
messages = messages.toImmutableList(),
|
||||
uiMessages = uiMessages.toImmutableList(),
|
||||
scrollIndex = scrollIndex,
|
||||
selectedMessages = selectedMessages.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onClose = viewModel::onCloseButtonClicked,
|
||||
onScrolledToIndex = viewModel::onScrolledToIndex,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onTopBarClicked = viewModel::onTopBarClicked,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
|
||||
onMessageClicked = viewModel::onMessageClicked,
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
+295
-149
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-41
@@ -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
|
||||
|
||||
+14
-6
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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
|
||||
|
||||
+162
-6
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.domain)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.view.HapticFeedbackConstantsCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.extensions.findWithIndex
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
@@ -19,16 +18,19 @@ import dev.meloda.fast.data.db.AccountsRepository
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.datastore.SettingsKeys
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.domain.AuthUseCase
|
||||
import dev.meloda.fast.model.database.AccountEntity
|
||||
import dev.meloda.fast.settings.model.SettingsItem
|
||||
import dev.meloda.fast.settings.model.SettingsScreenState
|
||||
import dev.meloda.fast.settings.model.SettingsShowOptions
|
||||
import dev.meloda.fast.settings.model.TextProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
interface SettingsViewModel {
|
||||
@@ -37,7 +39,7 @@ interface SettingsViewModel {
|
||||
val hapticType: StateFlow<HapticType?>
|
||||
|
||||
fun onLogOutAlertDismissed()
|
||||
fun onLogOutAlertPositiveClick()
|
||||
suspend fun onLogOutAlertPositiveClick()
|
||||
|
||||
fun onPerformCrashAlertDismissed()
|
||||
fun onPerformCrashPositiveButtonClicked()
|
||||
@@ -50,6 +52,7 @@ interface SettingsViewModel {
|
||||
}
|
||||
|
||||
class SettingsViewModelImpl(
|
||||
private val authUseCase: AuthUseCase,
|
||||
private val accountsRepository: AccountsRepository,
|
||||
private val userSettings: UserSettings,
|
||||
private val resources: Resources,
|
||||
@@ -69,20 +72,37 @@ class SettingsViewModelImpl(
|
||||
emitShowOptions { old -> old.copy(showLogOut = false) }
|
||||
}
|
||||
|
||||
override fun onLogOutAlertPositiveClick() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountsRepository.storeAccounts(
|
||||
listOf(
|
||||
AccountEntity(
|
||||
userId = UserConfig.userId,
|
||||
accessToken = "",
|
||||
fastToken = UserConfig.fastToken,
|
||||
trustedHash = UserConfig.trustedHash
|
||||
override suspend fun onLogOutAlertPositiveClick() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val tasks = listOf(
|
||||
// async {
|
||||
// suspendCoroutine { continuation ->
|
||||
// authUseCase.logout().listenValue(viewModelScope) { state ->
|
||||
// state.processState(
|
||||
// any = { continuation.resume(Unit) },
|
||||
// success = {},
|
||||
// error = {}
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
async {
|
||||
accountsRepository.storeAccounts(
|
||||
listOf(
|
||||
AccountEntity(
|
||||
userId = UserConfig.userId,
|
||||
accessToken = "",
|
||||
fastToken = UserConfig.fastToken,
|
||||
trustedHash = UserConfig.trustedHash,
|
||||
exchangeToken = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
async { UserConfig.clear() }
|
||||
)
|
||||
|
||||
UserConfig.clear()
|
||||
tasks.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -52,6 +53,7 @@ import dev.meloda.fast.settings.presentation.item.TitleTextItem
|
||||
import dev.meloda.fast.ui.components.ActionInvokeDismiss
|
||||
import dev.meloda.fast.ui.components.MaterialDialog
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@@ -83,12 +85,16 @@ fun SettingsRoute(
|
||||
onSettingsItemValueChanged = viewModel::onSettingsItemChanged
|
||||
)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
HandlePopups(
|
||||
performCrashPositiveClick = viewModel::onPerformCrashPositiveButtonClicked,
|
||||
performCrashDismissed = viewModel::onPerformCrashAlertDismissed,
|
||||
logoutPositiveClick = {
|
||||
viewModel.onLogOutAlertPositiveClick()
|
||||
onLogOutButtonClicked()
|
||||
scope.launch {
|
||||
viewModel.onLogOutAlertPositiveClick()
|
||||
onLogOutButtonClicked()
|
||||
}
|
||||
},
|
||||
logoutDismissed = viewModel::onLogOutAlertDismissed,
|
||||
screenState = screenState
|
||||
|
||||
+7
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user