Upstream changes (#23)

This commit is contained in:
2024-07-11 02:12:32 +03:00
committed by GitHub
parent 8a6378f509
commit 3503ecffab
906 changed files with 23577 additions and 24115 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,625 @@
package com.meloda.app.fast.conversations
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.conena.nanokt.collections.indexOfFirstOrNull
import com.meloda.app.fast.common.extensions.createTimerFlow
import com.meloda.app.fast.common.extensions.findWithIndex
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.ConversationsShowOptions
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.conversations.util.asPresentation
import com.meloda.app.fast.conversations.util.extractAvatar
import com.meloda.app.fast.data.LongPollUpdatesParser
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.conversations.ConversationsUseCase
import com.meloda.app.fast.data.api.messages.MessagesUseCase
import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.InteractionType
import com.meloda.app.fast.model.LongPollEvent
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.network.VkErrorCodes
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlin.coroutines.cancellation.CancellationException
interface ConversationsViewModel {
val screenState: StateFlow<ConversationsScreenState>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onMetPaginationCondition()
fun onDeleteDialogDismissed()
fun onDeleteDialogPositiveClick(conversationId: Int)
fun onRefresh()
fun onConversationItemClick(conversationId: Int)
fun onConversationItemLongClick(conversation: UiConversation)
fun onPinDialogDismissed()
fun onPinDialogPositiveClick(conversation: UiConversation)
fun onOptionClicked(conversation: UiConversation, option: ConversationOption)
fun onErrorConsumed()
}
class ConversationsViewModelImpl(
updatesParser: LongPollUpdatesParser,
private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
private val userSettings: UserSettings
) : ConversationsViewModel, ViewModel() {
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override fun onMetPaginationCondition() {
currentOffset.update { screenState.value.conversations.size }
loadConversations()
}
private val conversations = MutableStateFlow<List<VkConversation>>(emptyList())
private val pinnedConversationsCount = conversations.map { conversations ->
conversations.count(VkConversation::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
init {
userSettings.useContactNames.listenValue(::updateConversationsNames)
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onConversationPinStateChanged(::handlePinStateChanged)
updatesParser.onInteractions(::handleInteraction)
loadConversations()
}
override fun onDeleteDialogDismissed() {
emitShowOptions { old -> old.copy(showDeleteDialog = null) }
}
override fun onDeleteDialogPositiveClick(conversationId: Int) {
deleteConversation(conversationId)
hideOptions(conversationId)
}
override fun onRefresh() {
loadConversations(offset = 0)
}
override fun onConversationItemClick(conversationId: Int) {
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
item.copy(isExpanded = false)
}
)
}
}
override fun onConversationItemLongClick(conversation: UiConversation) {
val options = mutableListOf<ConversationOption>()
if (!conversation.isExpanded) {
conversation.lastMessage?.run {
if (conversation.isUnread && !this.isOut) {
options += ConversationOption.MarkAsRead
}
}
val conversationsSize = screenState.value.conversations.size
val pinnedCount = pinnedConversationsCount.value
val canPinOneMoreDialog =
conversationsSize > 4 && pinnedCount < 5 && !conversation.isPinned
if (conversation.isPinned) {
options += ConversationOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConversationOption.Pin
}
options += ConversationOption.Delete
}
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
item.copy(
isExpanded =
if (item.id == conversation.id) {
!item.isExpanded
} else {
false
},
options = ImmutableList.copyOf(options)
)
}
)
}
}
override fun onPinDialogDismissed() {
emitShowOptions { old -> old.copy(showPinDialog = null) }
}
override fun onPinDialogPositiveClick(conversation: UiConversation) {
pinConversation(conversation.id, !conversation.isPinned)
hideOptions(conversation.id)
}
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) {
when (option) {
ConversationOption.Delete -> {
emitShowOptions { old ->
old.copy(showDeleteDialog = conversation.id)
}
}
ConversationOption.MarkAsRead -> {
conversation.lastMessageId?.let { lastMessageId ->
readConversation(
peerId = conversation.id,
startMessageId = lastMessageId
)
hideOptions(conversation.id)
}
}
ConversationOption.Pin,
ConversationOption.Unpin -> {
emitShowOptions { old -> old.copy(showPinDialog = conversation) }
}
}
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
private fun hideOptions(conversationId: Int) {
screenState.setValue { old ->
old.copy(
conversations = old.conversations.map { item ->
if (item.id == conversationId) {
item.copy(isExpanded = false)
} else item
}
)
}
}
private fun emitShowOptions(function: (ConversationsShowOptions) -> ConversationsShowOptions) {
val newShowOptions = function.invoke(screenState.value.showOptions)
screenState.setValue { old -> old.copy(showOptions = newShowOptions) }
}
private fun loadConversations(
offset: Int = currentOffset.value
) {
conversationsUseCase.getConversations(count = 30, offset = offset).listenValue { state ->
state.processState(
error = { error ->
when (error) {
is State.Error.ApiError -> {
val (code, message) = error
when (code) {
VkErrorCodes.UserAuthorizationFailed -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
Unit
}
}
}
State.Error.ConnectionError -> TODO()
State.Error.InternalError -> TODO()
is State.Error.OAuthError -> TODO()
State.Error.Unknown -> TODO()
}
},
success = { response ->
val itemsCountSufficient = response.size == 30
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.conversations.isNotEmpty()
imagesToPreload.setValue {
response.mapNotNull { it.extractAvatar().extractUrl() }
}
conversationsUseCase.storeConversations(response)
val loadedConversations = response.map {
it.asPresentation(
resources,
userSettings.useContactNames.value
)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
conversations.emit(response)
screenState.setValue {
newState.copy(conversations = loadedConversations)
}
} else {
conversations.emit(conversations.value.plus(response))
screenState.setValue {
newState.copy(
conversations = newState.conversations.plus(loadedConversations)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun deleteConversation(peerId: Int) {
conversationsUseCase.delete(peerId).listenValue { state ->
state.processState(
error = { error ->
},
success = {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConversations.removeAt(conversationIndex)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
)
screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
private fun pinConversation(peerId: Int, pin: Boolean) {
conversationsUseCase.changePinState(peerId, pin)
.listenValue { state ->
state.processState(
error = { error ->
},
success = {
handlePinStateChanged(
LongPollEvent.VkConversationPinStateChangedEvent(
peerId = peerId,
majorId = if (pin) {
(pinnedConversationsCount.value + 1) * 16
} else {
0
}
)
)
}
)
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
// TODO: 04/07/2024, Danil Nikolaev: load conversation and store info
} else {
val conversation = newConversations[conversationIndex]
var newConversation = conversation.copy(
lastMessage = message,
lastMessageId = message.id,
lastConversationMessageId = -1,
unreadCount = if (message.isOut) conversation.unreadCount
else conversation.unreadCount + 1
)
interactionsTimers[conversation.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in conversation.interactionIds
) {
val newInteractionIds = newConversation.interactionIds.filter { id ->
id != message.fromId
}
newConversation = newConversation.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConversation.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (conversation.isPinned()) {
newConversations[conversationIndex] = newConversation
} else {
newConversations.removeAt(conversationIndex)
val toPosition = pinnedConversationsCount.value
newConversations.add(toPosition, newConversation)
}
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
}
private fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex = newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
val conversation = newConversations[conversationIndex]
newConversations[conversationIndex] = conversation.copy(
lastMessage = message,
lastMessageId = message.id,
lastConversationMessageId = -1
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
}
private fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
inRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
private fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
private fun handlePinStateChanged(event: LongPollEvent.VkConversationPinStateChangedEvent) {
var pinnedCount = pinnedConversationsCount.value
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
val pin = event.majorId > 0
val conversation = newConversations[conversationIndex].copy(majorId = event.majorId)
newConversations.removeAt(conversationIndex)
if (pin) {
newConversations.add(0, conversation)
} else {
pinnedCount -= 1
newConversations.add(conversation)
val pinnedSubList = newConversations.filter(VkConversation::isPinned)
val unpinnedSubList = newConversations
.filterNot(VkConversation::isPinned)
.sortedByDescending { it.lastMessage?.date }
newConversations.clear()
newConversations += pinnedSubList + unpinnedSubList
}
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(conversations = newConversations.map { it.asPresentation(resources) })
}
}
private val interactionsTimers = hashMapOf<Int, InteractionJob?>()
private data class InteractionJob(
val interactionType: InteractionType,
val timerJob: Job
)
private object NewInteractionException : CancellationException()
private fun handleInteraction(event: LongPollEvent.Interaction) {
val interactionType = event.interactionType
val peerId = event.peerId
val userIds = event.userIds
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException)
}
}
var timeoutAction: (() -> Unit)? = null
val timerJob = createTimerFlow(
time = 5,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
interactionsTimers[peerId] = newInteractionJob
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
}
private fun stopInteraction(peerId: Int, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = -1,
interactionIds = emptyList()
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
interactionJob.timerJob.cancel()
interactionsTimers[peerId] = null
}
private fun readConversation(peerId: Int, startMessageId: Int) {
messagesUseCase.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).listenValue { state ->
state.processState(
error = { error ->
},
success = {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(inRead = startMessageId)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
}
)
}
}
private fun updateConversationsNames(useContactNames: Boolean) {
val conversations = conversations.value
if (conversations.isEmpty()) return
val uiConversations = conversations.map { conversation ->
conversation.asPresentation(resources, useContactNames)
}
screenState.setValue { old ->
old.copy(conversations = uiConversations)
}
}
}
@@ -0,0 +1,342 @@
package com.meloda.app.fast.conversations
// TODO: 26.08.2023, Danil Nikolaev: rewrite
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.designsystem.AppTheme
const val numberOfDots = 3
val dotSize = 6.dp
val dotColor: Color = Color.Blue
const val delayUnit = 300
const val duration = numberOfDots * delayUnit
val spaceBetween = 2.dp
@Composable
fun DotsPulsing() {
@Composable
fun Dot(scale: Float) {
Spacer(
Modifier
.size(dotSize)
.scale(scale)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = delayUnit * numberOfDots
0f at delay using LinearEasing
1f at delay + delayUnit using LinearEasing
0f at delay + duration
}), label = ""
)
val scales = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
scales.add(animateScaleWithDelay(delay = i * delayUnit))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
scales.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsElastic() {
val minScale = 0.6f
@Composable
fun Dot(scale: Float) {
Spacer(
Modifier
.size(dotSize)
.scale(scaleX = minScale, scaleY = scale)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = minScale,
targetValue = minScale,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = duration
minScale at delay using LinearEasing
1f at delay + delayUnit using LinearEasing
minScale at delay + duration
}), label = ""
)
val scales = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
scales.add(animateScaleWithDelay(delay = i * delayUnit))
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
scales.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsFlashing(
modifier: Modifier = Modifier,
dotSize: Dp = 6.dp,
dotColor: Color = Color.Blue,
spaceBetween: Dp = 2.dp,
numberOfDots: Int = 3,
) {
val minAlpha = 0.1f
@Composable
fun Dot(alpha: Float) = Spacer(
Modifier
.size(dotSize)
.alpha(alpha)
.background(
color = dotColor, shape = CircleShape
)
)
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateAlphaWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = minAlpha,
targetValue = minAlpha,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = duration
minAlpha at delay using LinearEasing
1f at delay + delayUnit using LinearEasing
minAlpha at delay + duration
}), label = ""
)
val alphas = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
alphas.add(animateAlphaWithDelay(delay = i * delayUnit))
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
alphas.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsTyping(
modifier: Modifier = Modifier,
dotSize: Dp = 6.dp,
dotColor: Color = Color.Blue,
spaceBetween: Dp = 2.dp,
numberOfDots: Int = 3,
) {
val maxOffset = (numberOfDots * 2).toFloat()
@Composable
fun Dot(offset: Float) {
Spacer(
Modifier
.size(dotSize)
.offset(y = -offset.dp)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
@Composable
fun animateOffsetWithDelay(delay: Int) = infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = duration
0f at delay using LinearEasing
maxOffset at delay + delayUnit using LinearEasing
0f at delay + (duration / 2)
}), label = ""
)
val offsets = arrayListOf<State<Float>>()
for (i in 0 until numberOfDots) {
offsets.add(animateOffsetWithDelay(delay = i * delayUnit))
}
Row(
modifier = modifier.padding(top = maxOffset.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
offsets.forEach {
Dot(it.value)
Spacer(Modifier.width(spaceBetween))
}
}
}
@Composable
fun DotsCollision() {
val maxOffset = 30f
val delayUnit = 500
@Composable
fun Dot(offset: Float) {
Spacer(
Modifier
.size(dotSize)
.offset(x = offset.dp)
.background(
color = dotColor,
shape = CircleShape
)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "")
val offsetLeft by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = delayUnit * 3
0f at 0 using LinearEasing
-maxOffset at delayUnit / 2 using LinearEasing
0f at delayUnit
}), label = ""
)
val offsetRight by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(animation = keyframes {
durationMillis = delayUnit * 3
0f at delayUnit using LinearEasing
maxOffset at delayUnit + delayUnit / 2 using LinearEasing
0f at delayUnit * 2
}), label = ""
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = maxOffset.dp)
) {
Dot(offsetLeft)
Spacer(Modifier.width(spaceBetween))
for (i in 0 until numberOfDots - 2) {
Dot(0f)
Spacer(Modifier.width(spaceBetween))
}
Dot(offsetRight)
}
}
@Preview(showBackground = true)
@Composable
fun DotsPreview() = AppTheme {
Column(
modifier = Modifier
.padding(4.dp)
.fillMaxSize()
) {
val spaceSize = 16.dp
Text(
text = "Dots pulsing", //style = MaterialTheme.typography.h5
)
DotsPulsing()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots elastic", //style = MaterialTheme.typography.h5
)
DotsElastic()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots flashing", //style = MaterialTheme.typography.h5
)
DotsFlashing()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots typing", //style = MaterialTheme.typography.h5
)
DotsTyping()
Spacer(Modifier.height(spaceSize))
Text(
text = "Dots collision", //style = MaterialTheme.typography.h5
)
DotsCollision()
}
}
@@ -0,0 +1,119 @@
package com.meloda.app.fast.conversations.data
import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.conversations.ConversationsRepository
import com.meloda.app.fast.data.api.conversations.ConversationsUseCase
import com.meloda.app.fast.data.mapToState
import com.meloda.app.fast.model.api.domain.VkConversation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
class ConversationsUseCaseImpl(
private val repository: ConversationsRepository,
) : ConversationsUseCase {
// override fun getConversations(
// count: Int?,
// offset: Int?,
// fields: String,
// filter: String,
// extended: Boolean?,
// startMessageId: Int?
// ): Flow<com.meloda.app.fast.network.State<ConversationsResponseDomain>> = flow {
// emit(com.meloda.app.fast.network.State.Loading)
//
// val newState = conversationsRepository.getConversations(
// params = ConversationsGetRequest(
// count = count,
// offset = offset,
// fields = fields,
// filter = filter,
// extended = extended,
// startMessageId = startMessageId
// )
// ).fold(
// onSuccess = { response -> com.meloda.app.fast.network.State.Success(response.toDomain()) },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
//
// override fun pin(peerId: Int): Flow<com.meloda.app.fast.network.State<Unit>> = flow {
// emit(com.meloda.app.fast.network.State.Loading)
//
// val newState = conversationsRepository.pin(
// ConversationsPinRequest(peerId = peerId)
// ).fold(
// onSuccess = { com.meloda.app.fast.network.State.Success(Unit) },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override fun unpin(peerId: Int): Flow<com.meloda.app.fast.network.State<Unit>> = flow {
// emit(com.meloda.app.fast.network.State.Loading)
//
// val newState = conversationsRepository.unpin(
// ConversationsUnpinRequest(peerId = peerId)
// ).fold(
// onSuccess = { com.meloda.app.fast.network.State.Success(Unit) },
// onNetworkFailure = { com.meloda.app.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { com.meloda.app.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override suspend fun storeConversations(conversations: List<VkConversationDomain>) {
// conversationsDao.insertAll(conversations.map(VkConversationDomain::mapToDb))
// }
//
// override suspend fun storeGroups(groups: List<VkGroupDomain>) {
// groupsDao.insertAll(groups.map(VkGroupDomain::mapToDB))
// }
override fun getConversations(
count: Int?,
offset: Int?
): Flow<State<List<VkConversation>>> = flow {
emit(State.Loading)
val newState = repository.getConversations(count, offset).mapToState()
emit(newState)
}
override suspend fun storeConversations(
conversations: List<VkConversation>
) = withContext(Dispatchers.IO) {
repository.storeConversations(conversations)
}
override fun delete(peerId: Int): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.delete(peerId = peerId).mapToState()
emit(newState)
}
override fun changePinState(peerId: Int, pin: Boolean): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = if (pin) {
repository.pin(peerId)
} else {
repository.unpin(peerId)
}.mapToState()
emit(newState)
}
}
@@ -0,0 +1,15 @@
package com.meloda.app.fast.conversations.di
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.data.ConversationsUseCaseImpl
import com.meloda.app.fast.data.api.conversations.ConversationsUseCase
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
val conversationsModule = module {
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
viewModelOf(::ConversationsViewModelImpl)
}
@@ -0,0 +1,20 @@
package com.meloda.app.fast.conversations.model
enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE;
// TODO: 11/04/2024, Danil Nikolaev: implement
fun getResourceId(): Int {
return -1
}
companion object {
fun parse(isPhantom: Boolean, isCallInProgress: Boolean): ActionState {
return when {
isPhantom -> PHANTOM
isCallInProgress -> CALL_IN_PROGRESS
else -> NONE
}
}
}
}
@@ -0,0 +1,31 @@
package com.meloda.app.fast.conversations.model
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.designsystem.R as UiR
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(UiR.string.action_mark_as_read),
icon = UiImage.Resource(UiR.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(UiR.string.action_pin),
icon = UiImage.Resource(UiR.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(UiR.string.action_unpin),
icon = UiImage.Resource(UiR.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(UiR.string.action_delete),
icon = UiImage.Resource(UiR.drawable.round_delete_outline_24)
)
}
@@ -0,0 +1,23 @@
package com.meloda.app.fast.conversations.model
import androidx.compose.runtime.Immutable
@Immutable
data class ConversationsScreenState(
val showOptions: ConversationsShowOptions,
val conversations: List<UiConversation>,
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean
) {
companion object {
val EMPTY: ConversationsScreenState = ConversationsScreenState(
showOptions = ConversationsShowOptions.EMPTY,
conversations = emptyList(),
isLoading = true,
isPaginating = false,
isPaginationExhausted = false
)
}
}
@@ -0,0 +1,14 @@
package com.meloda.app.fast.conversations.model
data class ConversationsShowOptions(
val showDeleteDialog: Int?,
val showPinDialog: UiConversation?
) {
companion object {
val EMPTY: ConversationsShowOptions = ConversationsShowOptions(
showDeleteDialog = null,
showPinDialog = null
)
}
}
@@ -0,0 +1,31 @@
package com.meloda.app.fast.conversations.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.model.api.PeerType
import com.meloda.app.fast.model.api.domain.VkMessage
@Immutable
data class UiConversation(
val id: Int,
val lastMessageId: Int?,
val avatar: UiImage?,
val title: String,
val unreadCount: String?,
val date: String,
val message: AnnotatedString,
val attachmentImage: UiImage?,
val isPinned: Boolean,
val actionImageId: Int,
val isBirthday: Boolean,
val isUnread: Boolean,
val isAccount: Boolean,
val isOnline: Boolean,
val lastMessage: VkMessage?,
val peerType: PeerType,
val interactionText: String?,
val isExpanded: Boolean,
val options: ImmutableList<ConversationOption>,
)
@@ -0,0 +1,33 @@
package com.meloda.app.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.meloda.app.fast.common.extensions.navigation.sharedViewModel
import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.presentation.ConversationsScreen
import com.meloda.app.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Conversations
fun NavGraphBuilder.conversationsRoute(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
ConversationsScreen(
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onListScrollingUp = onListScrollingUp,
viewModel = viewModel
)
}
}
@@ -0,0 +1,392 @@
package com.meloda.app.fast.conversations.presentation
import android.graphics.drawable.ColorDrawable
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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.conversations.DotsFlashing
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.ContentAlpha
import com.meloda.app.fast.designsystem.LocalContentAlpha
import com.meloda.app.fast.designsystem.getString
import com.meloda.app.fast.designsystem.R as UiR
val BirthdayColor = Color(0xffb00b69)
@Composable
fun UiImage.getResourcePainter(): Painter? {
return when (this) {
is UiImage.Resource -> painterResource(id = resId)
else -> null
}
}
@Composable
fun UiImage.getImage(): Any {
return when (this) {
is UiImage.Color -> ColorDrawable(color)
is UiImage.ColorResource -> ColorDrawable(colorResource(id = resId).toArgb())
is UiImage.Resource -> painterResource(id = resId)
is UiImage.Simple -> drawable
is UiImage.Url -> url
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConversationItem(
onItemClick: (Int) -> Unit,
onItemLongClick: (conversation: UiConversation) -> Unit,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
conversation: UiConversation,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
targetValue = if (conversation.isExpanded) 10.dp else 34.dp,
label = "bottomStartCornerRadius"
)
Box(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(conversation.id) },
onLongClick = {
onItemLongClick(conversation)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
) {
val showBackground by remember(conversation) {
derivedStateOf { conversation.isUnread || conversation.isExpanded }
}
AnimatedVisibility(
visible = showBackground,
modifier = Modifier
.matchParentSize()
.padding(start = 8.dp),
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.matchParentSize()
.clip(
RoundedCornerShape(
topStart = 34.dp,
bottomStart = bottomStartCornerRadius
)
)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
)
}
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(16.dp))
Box(modifier = Modifier.size(56.dp)) {
if (isUserAccount) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Image(
modifier = Modifier
.align(Alignment.Center)
.size(32.dp),
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24),
contentDescription = "Favorites icon"
)
}
} else {
val avatarImage = conversation.avatar?.getImage()
if (avatarImage is Painter) {
Image(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
painter = avatarImage,
contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
} else {
AsyncImage(
model = ImageRequest.Builder(context).data(avatarImage)
.crossfade(true).build(),
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
)
}
}
if (conversation.isPinned) {
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.background(MaterialTheme.colorScheme.outline)
) {
Image(
modifier = Modifier
.height(14.dp)
.align(Alignment.Center),
painter = painterResource(id = UiR.drawable.ic_round_push_pin_24),
contentDescription = "Pin icon"
)
}
}
if (conversation.isOnline) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(
if (conversation.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
}
)
.padding(2.dp)
.align(Alignment.BottomEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
if (conversation.isBirthday) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(16.dp)
.background(
if (conversation.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
}
)
.padding(2.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(BirthdayColor)
) {
Image(
modifier = Modifier
.align(Alignment.Center)
.size(10.dp),
painter = painterResource(id = UiR.drawable.round_cake_24),
contentDescription = "Birthday icon"
)
}
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = conversation.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp)
)
Row {
if (conversation.interactionText != null) {
Text(
text = conversation.interactionText,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
DotsFlashing(
modifier = Modifier
.align(Alignment.Bottom)
.padding(bottom = 7.dp),
dotSize = 4.dp,
dotColor = MaterialTheme.colorScheme.primary
)
} else {
conversation.attachmentImage?.getResourcePainter()?.let { painter ->
Column {
Spacer(modifier = Modifier.height(4.dp))
Image(
modifier = Modifier.size(14.dp),
painter = painter,
contentDescription = "attachment image",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer)
)
}
Spacer(modifier = Modifier.width(2.dp))
}
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
modifier = Modifier.weight(1f),
text = conversation.message,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(modifier = Modifier.width(4.dp))
Column {
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = conversation.date,
style = MaterialTheme.typography.bodySmall
)
}
conversation.unreadCount?.let { count ->
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.CenterHorizontally)
) {
Text(
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center),
text = count,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
Spacer(modifier = Modifier.width(24.dp))
}
AnimatedVisibility(conversation.isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
conversation.options.forEach { option ->
ElevatedAssistChip(
onClick = { onOptionClicked(conversation, option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
painter = painter,
contentDescription = "Chip icon",
modifier = Modifier.size(16.dp)
)
}
},
label = {
Text(text = option.title.getString().orEmpty())
}
)
}
}
}
}
val bottomSpacerHeight by animateDpAsState(
targetValue = if (conversation.isExpanded) 4.dp else 8.dp,
label = "bottomSpacerHeight"
)
Spacer(modifier = Modifier.height(bottomSpacerHeight))
}
}
}
@@ -0,0 +1,109 @@
package com.meloda.app.fast.conversations.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.conversations.model.ConversationOption
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConversationsListComposable(
onConversationsClick: (Int) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues
) {
val coroutineScope = rememberCoroutineScope()
val conversations = screenState.conversations
LazyColumn(
modifier = modifier,
state = state
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
items = conversations,
key = UiConversation::id,
) { conversation ->
val isUserAccount by remember(conversation) {
derivedStateOf {
conversation.id == UserConfig.userId
}
}
ConversationItem(
onItemClick = onConversationsClick,
onItemLongClick = onConversationsLongClick,
onOptionClicked = onOptionClicked,
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
Spacer(modifier = Modifier.height(8.dp))
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
state.scrollToItem(14)
state.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
}
}
}
}
@@ -0,0 +1,441 @@
package com.meloda.app.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
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.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
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.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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.conversations.ConversationsViewModel
import com.meloda.app.fast.conversations.ConversationsViewModelImpl
import com.meloda.app.fast.conversations.model.ConversationsScreenState
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.components.FullScreenLoader
import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.ui.ErrorView
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import com.meloda.app.fast.designsystem.R as UiR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ConversationsScreen(
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (conversationId: Int) -> Unit,
onListScrollingUp: (Boolean) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
) {
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val context = LocalContext.current
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
val view = LocalView.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val currentTheme = LocalTheme.current
val maxLines by remember {
derivedStateOf {
if (currentTheme.multiline) 2 else 1
}
}
val listState = rememberLazyListState()
val isListScrollingUp = listState.isScrollingUp()
LaunchedEffect(isListScrollingUp) {
onListScrollingUp(isListScrollingUp)
}
val paginationConditionMet by remember {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
viewModel.onMetPaginationCondition()
}
}
val hazeState = remember { HazeState() }
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.usingBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val pullToRefreshAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "pullToRefreshAlpha",
animationSpec = tween(durationMillis = 50)
)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(modifier = Modifier.fillMaxWidth()) {
TopAppBar(
title = {
Text(
text = stringResource(
id = if (screenState.isLoading) UiR.string.title_loading
else UiR.string.title_conversations
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
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 = {
viewModel.onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = UiR.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = if (currentTheme.usingBlur) toolbarColorAlpha else 1f
)
),
modifier = Modifier
.then(
if (currentTheme.usingBlur) {
Modifier.hazeChild(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.conversations.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
},
floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
AnimatedVisibility(
visible = isListScrollingUp,
modifier = Modifier.navigationBarsPadding(),
enter = slideIn { IntOffset(0, 400) },
exit = slideOut { IntOffset(0, 400) }
) {
FloatingActionButton(
onClick = {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
modifier = Modifier.rotate(rotation.value)
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button"
)
}
}
}
) { padding ->
when {
baseError is BaseError.SessionExpired -> {
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = { onError(BaseError.SessionExpired) }
)
}
screenState.isLoading && screenState.conversations.isEmpty() -> FullScreenLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding())
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
ConversationsListComposable(
onConversationsClick = { id ->
onNavigateToMessagesHistory(id)
viewModel.onConversationItemClick(id)
},
onConversationsLongClick = viewModel::onConversationItemLongClick,
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.usingBlur) {
Modifier.haze(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = viewModel::onOptionClicked,
padding = padding
)
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
viewModel.onRefresh()
}
}
LaunchedEffect(screenState.isLoading) {
if (!screenState.isLoading) {
pullToRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.alpha(pullToRefreshAlpha)
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
contentColor = MaterialTheme.colorScheme.primary
)
}
}
}
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
}
// 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) {
val conversationId = showOptions.showDeleteDialog
DeleteDialog(
conversationId = conversationId,
viewModel = viewModel
)
}
showOptions.showPinDialog?.let { conversation ->
PinDialog(
conversation = conversation,
viewModel = viewModel
)
}
}
@Composable
fun DeleteDialog(
conversationId: Int,
viewModel: ConversationsViewModel
) {
MaterialDialog(
title = UiText.Resource(UiR.string.confirm_delete_conversation),
confirmText = UiText.Resource(UiR.string.action_delete),
confirmAction = { viewModel.onDeleteDialogPositiveClick(conversationId) },
cancelText = UiText.Resource(UiR.string.cancel),
onDismissAction = viewModel::onDeleteDialogDismissed
)
}
@Composable
fun PinDialog(
conversation: UiConversation,
viewModel: ConversationsViewModel
) {
MaterialDialog(
title = UiText.Resource(
if (conversation.isPinned) UiR.string.confirm_unpin_conversation
else UiR.string.confirm_pin_conversation
),
confirmText = UiText.Resource(
if (conversation.isPinned) UiR.string.action_unpin
else UiR.string.action_pin
),
confirmAction = {
viewModel.onPinDialogPositiveClick(conversation)
},
cancelText = UiText.Resource(UiR.string.cancel),
onDismissAction = viewModel::onPinDialogDismissed
)
}
@Composable
private fun LazyListState.isScrollingUp(): Boolean {
var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) }
return remember(this) {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex > firstVisibleItemIndex
} else {
previousScrollOffset >= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}
@@ -0,0 +1,848 @@
package com.meloda.app.fast.conversations.util
import android.content.res.Resources
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.month
import com.meloda.app.fast.common.UiImage
import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.extensions.orDots
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.common.util.TimeUtils
import com.meloda.app.fast.conversations.model.ActionState
import com.meloda.app.fast.conversations.model.UiConversation
import com.meloda.app.fast.data.VkMemoryCache
import com.meloda.app.fast.designsystem.ImmutableList
import com.meloda.app.fast.model.InteractionType
import com.meloda.app.fast.model.api.PeerType
import com.meloda.app.fast.model.api.data.AttachmentType
import com.meloda.app.fast.model.api.domain.VkAttachment
import com.meloda.app.fast.model.api.domain.VkConversation
import com.meloda.app.fast.model.api.domain.VkMessage
import java.util.Calendar
import java.util.Locale
import kotlin.math.ln
import kotlin.math.pow
import com.meloda.app.fast.designsystem.R as UiR
fun VkConversation.asPresentation(
resources: Resources,
useContactName: Boolean = false
): UiConversation = UiConversation(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(this, useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(resources, (lastMessage?.date ?: -1) * 1000L),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else getAttachmentConversationIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = extractReadCondition(this, lastMessage),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = false,
options = ImmutableList.empty()
)
fun VkConversation.extractAvatar() = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) null
else user?.photo200
}
PeerType.GROUP -> {
group?.photo200
}
PeerType.CHAT -> {
photo200
}
}?.let(UiImage::Url) ?: UiImage.Resource(UiR.drawable.ic_account_circle_cut)
private fun extractTitle(
conversation: VkConversation,
useContactName: Boolean,
resources: Resources
) = when (conversation.peerType) {
PeerType.USER -> {
if (isAccount(conversation.id)) {
UiText.Resource(UiR.string.favorites)
} else {
val userName = conversation.user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName
} else {
user.fullName
}
}
UiText.Simple(userName.orDots())
}
}
PeerType.GROUP -> UiText.Simple(conversation.group?.name.orDots())
PeerType.CHAT -> UiText.Simple(conversation.title.orDots())
}.parseString(resources).orDots()
private fun extractUnreadCount(
lastMessage: VkMessage?,
conversation: VkConversation
): String? = when {
lastMessage?.isOut == false && !conversation.isInUnread() -> null
conversation.unreadCount == 0 -> null
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
else -> {
val exp = (ln(conversation.unreadCount.toDouble()) / ln(1000.0)).toInt()
val suffix = "KMBT"[exp - 1]
val result = conversation.unreadCount / 1000.0.pow(exp.toDouble())
if (result.toLong().toDouble() == result) {
String.format(Locale.getDefault(), "%.0f%s", result, suffix)
} else {
String.format(Locale.getDefault(), "%.1f%s", result, suffix)
}
}
}
private fun extractMessage(
resources: Resources,
lastMessage: VkMessage?,
peerId: Int,
peerType: PeerType
): AnnotatedString {
val youPrefix = UiText.Resource(UiR.string.you_message_prefix)
.parseString(resources)
.orDots()
val actionMessage = extractActionText(
lastMessage = lastMessage,
resources = resources,
youPrefix = youPrefix
)
val attachmentIcon: UiImage? = extractAttachmentIcon(lastMessage)
val attachmentText: AnnotatedString? =
if (attachmentIcon != null) null
else extractAttachmentText(resources, lastMessage)
val forwardsMessage =
if (lastMessage?.text != null) null
else extractForwardsText(resources, lastMessage)
val messageText = lastMessage?.text.orEmpty()
val prefixText: AnnotatedString? = when {
actionMessage != null -> null
lastMessage == null -> null
peerId == UserConfig.userId -> null
!peerType.isChat() && !lastMessage.isOut -> null
lastMessage.isOut -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(youPrefix)
}
}
else ->
when {
lastMessage.user?.firstName.orEmpty().isNotEmpty() -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(lastMessage.user?.firstName)
}
}
lastMessage.group?.name.orEmpty().isNotEmpty() -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(lastMessage.group?.name)
}
}
else -> null
}
}
val prefix = buildAnnotatedString {
if (prefixText != null) {
append(prefixText)
append(": ")
}
}
val finalText = when {
actionMessage != null -> {
prefix + actionMessage
}
forwardsMessage != null -> {
prefix + forwardsMessage
}
attachmentText != null -> {
prefix + attachmentText
}
else ->
messageText
.replace("\n", " ")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("<br>", " ")
.replace("&gt;", ">")
.replace("&lt;", "<")
.replace("<br/>", " ")
.replace("&ndash;", "-")
.trim()
.let { text -> getTextWithVisualizedMentions(text, Color.Red) }
.let { text -> prefix + text }
}
return finalText
}
private fun extractActionText(
lastMessage: VkMessage?,
resources: Resources,
youPrefix: String
): AnnotatedString? {
if (lastMessage == null) return null
val fromId = lastMessage.fromId
val text = lastMessage.actionText.orDots()
val groupName = lastMessage.group?.name.orDots()
val userName = lastMessage.user?.fullName.orDots()
val actionGroupName = lastMessage.actionGroup?.name.orDots()
val actionUserName = lastMessage.actionUser?.fullName.orDots()
val memberId = lastMessage.actionMemberId
val isMemberUser = (memberId ?: 0) > 0
val isMemberGroup = (memberId ?: 0) < 0
val prefix = when {
lastMessage.fromId == UserConfig.userId -> youPrefix
lastMessage.isGroup() -> groupName
lastMessage.isUser() -> userName
else -> null
}.orDots()
val memberPrefix = when {
memberId == UserConfig.userId -> youPrefix
isMemberUser -> actionUserName
isMemberGroup -> actionGroupName
else -> null
}.orDots()
return buildAnnotatedString {
when (lastMessage.action) {
null -> return null
VkMessage.Action.CHAT_CREATE -> {
val string = UiText.ResourceParams(
UiR.string.message_action_chat_created,
listOf(prefix, text)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = textStartIndex,
end = textStartIndex + text.length
)
}
VkMessage.Action.CHAT_TITLE_UPDATE -> {
val string = UiText.ResourceParams(
UiR.string.message_action_chat_renamed,
listOf(prefix, text)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = textStartIndex,
end = textStartIndex + text.length
)
}
VkMessage.Action.CHAT_PHOTO_UPDATE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_photo_update,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PHOTO_REMOVE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_photo_remove,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_KICK_USER -> {
if (memberId == fromId) {
UiText.ResourceParams(
UiR.string.message_action_chat_user_left,
listOf(memberPrefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = memberPrefix.length
)
} else {
val postfix =
if (memberId == UserConfig.userId) youPrefix.lowercase()
else lastMessage.actionUser.toString()
val string = UiText.ResourceParams(
UiR.string.message_action_chat_user_kicked,
listOf(prefix, postfix)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = postfixStartIndex,
end = postfixStartIndex + postfix.length
)
}
}
VkMessage.Action.CHAT_INVITE_USER -> {
if (memberId == lastMessage.fromId) {
UiText.ResourceParams(
UiR.string.message_action_chat_user_returned,
listOf(memberPrefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = memberPrefix.length
)
} else {
val postfix =
if (memberId == UserConfig.userId) youPrefix.lowercase()
else lastMessage.actionUser.toString()
val string = UiText.ResourceParams(
UiR.string.message_action_chat_user_invited,
listOf(memberPrefix, postfix)
).parseString(resources).orEmpty()
append(string)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = postfixStartIndex,
end = postfixStartIndex + postfix.length
)
}
}
VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> {
UiText.ResourceParams(
UiR.string.message_action_chat_user_joined_by_link,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> {
UiText.ResourceParams(
UiR.string.message_action_chat_user_joined_by_call,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> {
UiText.ResourceParams(
UiR.string.message_action_chat_user_joined_by_call_link,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PIN_MESSAGE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_pin_message,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_unpin_message,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_SCREENSHOT -> {
UiText.ResourceParams(
UiR.string.message_action_chat_screenshot,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_STYLE_UPDATE -> {
UiText.ResourceParams(
UiR.string.message_action_chat_style_update,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
}
}
}
private fun extractAttachmentIcon(
lastMessage: VkMessage?
): UiImage? = when {
lastMessage == null -> null
lastMessage.text == null -> null
!lastMessage.forwards.isNullOrEmpty() -> {
if (lastMessage.forwards.orEmpty().size == 1) {
UiImage.Resource(UiR.drawable.ic_attachment_forwarded_message)
} else {
UiImage.Resource(UiR.drawable.ic_attachment_forwarded_messages)
}
}
else -> {
lastMessage.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
lastMessage.geoType?.let {
return UiImage.Resource(UiR.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(UiR.drawable.ic_baseline_attach_file_24)
}
}
}
}
private fun extractAttachmentText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
lastMessage == null -> null
lastMessage.geoType != null -> {
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
when (lastMessage.geoType) {
"point" -> {
UiText.Resource(UiR.string.message_geo_point)
.parseString(resources)
.let(::append)
}
else -> {
UiText.Resource(UiR.string.message_geo)
.parseString(resources)
.let(::append)
}
}
}
}
}
lastMessage.hasAttachments() -> {
buildAnnotatedString {
val attachments = lastMessage.attachments.orEmpty()
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
if (attachments.size == 1) {
getAttachmentUiText(attachments.first())
.parseString(resources)
.let(::append)
} else {
when {
isAttachmentsHaveOneType(attachments) -> {
getAttachmentUiText(attachments.first(), attachments.size)
.parseString(resources)
.let(::append)
}
attachments.any { it.type == AttachmentType.ARTIST } -> {
getAttachmentUiText(
attachments.first { it.type == AttachmentType.ARTIST }
)
.parseString(resources)
.let(::append)
}
else -> {
UiText.Resource(UiR.string.message_attachments_many)
.parseString(resources)
.let(::append)
}
}
}
}
}
}
else -> null
}
private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
return when (attachmentType) {
AttachmentType.PHOTO -> UiR.drawable.ic_attachment_photo
AttachmentType.VIDEO -> UiR.drawable.ic_attachment_video
AttachmentType.AUDIO -> UiR.drawable.ic_attachment_audio
AttachmentType.FILE -> UiR.drawable.ic_attachment_file
AttachmentType.LINK -> UiR.drawable.ic_attachment_link
AttachmentType.AUDIO_MESSAGE -> UiR.drawable.ic_attachment_voice
AttachmentType.MINI_APP -> UiR.drawable.ic_attachment_mini_app
AttachmentType.STICKER -> UiR.drawable.ic_attachment_sticker
AttachmentType.GIFT -> UiR.drawable.ic_attachment_gift
AttachmentType.WALL -> UiR.drawable.ic_attachment_wall
AttachmentType.GRAFFITI -> UiR.drawable.ic_attachment_graffiti
AttachmentType.POLL -> UiR.drawable.ic_attachment_poll
AttachmentType.WALL_REPLY -> UiR.drawable.ic_attachment_wall_reply
AttachmentType.CALL -> UiR.drawable.ic_attachment_call
AttachmentType.GROUP_CALL_IN_PROGRESS -> UiR.drawable.ic_attachment_group_call
AttachmentType.STORY -> UiR.drawable.ic_attachment_story
AttachmentType.UNKNOWN -> null
AttachmentType.CURATOR -> null
AttachmentType.EVENT -> null
AttachmentType.WIDGET -> null
AttachmentType.ARTIST -> null
AttachmentType.AUDIO_PLAYLIST -> null
AttachmentType.PODCAST -> null
}?.let(UiImage::Resource)
}
private fun isAttachmentsHaveOneType(attachments: List<VkAttachment>): Boolean {
if (attachments.isEmpty()) return true
if (attachments.size == 1) return true
val firstType = attachments.first().type
for (attachment in attachments) {
if (firstType != attachment.type) return false
}
return true
}
private fun extractForwardsText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
lastMessage == null -> null
lastMessage.hasForwards() -> buildAnnotatedString {
val forwards = lastMessage.forwards.orEmpty()
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(
UiText.Resource(
if (forwards.size == 1) UiR.string.forwarded_message
else UiR.string.forwarded_messages
).parseString(resources)
)
}
}
else -> null
}
private fun getTextWithVisualizedMentions(
originalText: String,
mentionColor: Color,
): AnnotatedString = buildAnnotatedString {
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
// TODO: 25/04/2024, Danil Nikolaev: check why not working ([id279494346|@iworld2rist] да убери ты Елену Шлипс от меня)
val result = 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 text = matchResult.groups[3]?.value ?: ""
val replaced =
text.substring(startIndex, endIndex + 1)
.replace("[$idPrefix$id|$text]", text)
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toIntOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
append(result)
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
addStyle(
style = SpanStyle(color = mentionColor),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = mention.idPrefix,
annotation = mention.id.toString(),
start = startIndex,
end = endIndex
)
}
}
data class MentionIndex(
val id: Int,
val idPrefix: String,
val indexRange: IntRange
)
private fun getAttachmentUiText(
attachment: VkAttachment,
size: Int = 1,
): UiText {
if (attachment.type.isMultiple()) {
return when (attachment.type) {
AttachmentType.PHOTO -> UiR.plurals.attachment_photos
AttachmentType.VIDEO -> UiR.plurals.attachment_videos
AttachmentType.AUDIO -> UiR.plurals.attachment_audios
AttachmentType.FILE -> UiR.plurals.attachment_files
else -> throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
}.let { resId -> UiText.QuantityResource(resId, size) }
}
return when (attachment.type) {
AttachmentType.UNKNOWN,
AttachmentType.PHOTO,
AttachmentType.VIDEO,
AttachmentType.AUDIO,
AttachmentType.FILE -> {
throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
}
AttachmentType.LINK -> UiR.string.message_attachments_link
AttachmentType.AUDIO_MESSAGE -> UiR.string.message_attachments_audio_message
AttachmentType.MINI_APP -> UiR.string.message_attachments_mini_app
AttachmentType.STICKER -> UiR.string.message_attachments_sticker
AttachmentType.GIFT -> UiR.string.message_attachments_gift
AttachmentType.WALL -> UiR.string.message_attachments_wall
AttachmentType.GRAFFITI -> UiR.string.message_attachments_graffiti
AttachmentType.POLL -> UiR.string.message_attachments_poll
AttachmentType.WALL_REPLY -> UiR.string.message_attachments_wall_reply
AttachmentType.CALL -> UiR.string.message_attachments_call
AttachmentType.GROUP_CALL_IN_PROGRESS -> UiR.string.message_attachments_call_in_progress
AttachmentType.CURATOR -> UiR.string.message_attachments_curator
AttachmentType.EVENT -> UiR.string.message_attachments_event
AttachmentType.STORY -> UiR.string.message_attachments_story
AttachmentType.WIDGET -> UiR.string.message_attachments_widget
AttachmentType.ARTIST -> UiR.string.message_attachments_artist
AttachmentType.AUDIO_PLAYLIST -> UiR.string.message_attachments_audio_playlist
AttachmentType.PODCAST -> UiR.string.message_attachments_podcast
}.let(UiText::Resource)
}
private fun getAttachmentConversationIcon(message: VkMessage?): UiImage? {
return message?.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
message.geoType?.let {
return UiImage.Resource(UiR.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(UiR.drawable.ic_baseline_attach_file_24)
}
}
}
private fun extractBirthday(conversation: VkConversation): Boolean {
val birthday = conversation.user?.birthday ?: return false
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
if (splitBirthday.isEmpty()) return false
return if (splitBirthday.size > 1) {
val (day, month) = splitBirthday
val birthdayCalendar = Calendar.getInstance().also { calendar ->
calendar.dayOfMonth = day
calendar.month = month - 1
}
val nowCalendar = Calendar.getInstance()
nowCalendar.dayOfMonth == birthdayCalendar.dayOfMonth &&
nowCalendar.month == birthdayCalendar.month
} else false
}
private fun extractReadCondition(
conversation: VkConversation,
lastMessage: VkMessage?
): Boolean = (lastMessage?.isOut == true && conversation.isOutUnread()) ||
(lastMessage?.isOut == false && conversation.isInUnread())
private fun isAccount(peerId: Int) = peerId == UserConfig.userId
private fun extractInteractionText(
resources: Resources,
conversation: VkConversation
): String? {
val interactionType = InteractionType.parse(conversation.interactionType)
val interactiveUsers = extractInteractionUsers(conversation)
val typingText =
if (interactionType == null) {
null
} else {
if (!conversation.peerType.isChat() && interactiveUsers.size == 1) {
when (interactionType) {
InteractionType.File -> UiR.string.chat_interaction_uploading_file
InteractionType.Photo -> UiR.string.chat_interaction_uploading_photo
InteractionType.Typing -> UiR.string.chat_interaction_typing
InteractionType.Video -> UiR.string.chat_interaction_uploading_video
InteractionType.VoiceMessage -> UiR.string.chat_interaction_recording_audio_message
}.let(UiText::Resource)
} else {
if (interactiveUsers.size == 1) {
UiR.string.chat_interaction_chat_single_typing
} else {
UiR.string.chat_interaction_chat_typing
}.let { resId ->
UiText.ResourceParams(
resId,
listOf(interactiveUsers.joinToString(separator = ", "))
)
}
}.parseString(resources)
}
return typingText
}
private fun extractInteractionUsers(conversation: VkConversation): List<String> {
return conversation.interactionIds.mapNotNull { id ->
when {
id > 0 -> VkMemoryCache.getUser(id)?.fullName
id < 0 -> VkMemoryCache.getGroup(id)?.name
else -> null
}
}
}