update package name (even bigger one)

This commit is contained in:
2024-07-16 07:02:50 +03:00
parent 4f9e49003b
commit c8b1d72f08
367 changed files with 12 additions and 25 deletions
@@ -0,0 +1,623 @@
package dev.meloda.fast.conversations
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.ConversationsShowOptions
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.conversations.util.asPresentation
import dev.meloda.fast.conversations.util.extractAvatar
import dev.meloda.fast.data.LongPollUpdatesParser
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsUseCase
import dev.meloda.fast.data.api.messages.MessagesUseCase
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.util.ImmutableList
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 onPaginationConditionsMet()
fun onDeleteDialogDismissed()
fun onDeleteDialogPositiveClick()
fun onRefresh()
fun onConversationItemClick()
fun onConversationItemLongClick(conversation: UiConversation)
fun onPinDialogDismissed()
fun onPinDialogPositiveClick()
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 onPaginationConditionsMet() {
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() {
val conversationId = screenState.value.showOptions.showDeleteDialog ?: return
deleteConversation(conversationId)
hideOptions(conversationId)
onDeleteDialogDismissed()
}
override fun onRefresh() {
loadConversations(offset = 0)
}
override fun onConversationItemClick() {
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() {
val conversation = screenState.value.showOptions.showPinDialog ?: return
pinConversation(conversation.id, !conversation.isPinned)
hideOptions(conversation.id)
onPinDialogDismissed()
}
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 = LOAD_COUNT, offset = offset)
.listenValue { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
},
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
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)
}
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -0,0 +1,119 @@
package dev.meloda.fast.conversations.data
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.conversations.ConversationsUseCase
import dev.meloda.fast.data.mapToState
import dev.meloda.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<dev.meloda.fast.network.State<ConversationsResponseDomain>> = flow {
// emit(dev.meloda.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 -> dev.meloda.fast.network.State.Success(response.toDomain()) },
// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
//
// override fun pin(peerId: Int): Flow<dev.meloda.fast.network.State<Unit>> = flow {
// emit(dev.meloda.fast.network.State.Loading)
//
// val newState = conversationsRepository.pin(
// ConversationsPinRequest(peerId = peerId)
// ).fold(
// onSuccess = { dev.meloda.fast.network.State.Success(Unit) },
// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { dev.meloda.fast.network.State.UNKNOWN_ERROR },
// onHttpFailure = { result -> result.error.toStateApiError() },
// onApiFailure = { result -> result.error.toStateApiError() }
// )
// emit(newState)
// }
//
// override fun unpin(peerId: Int): Flow<dev.meloda.fast.network.State<Unit>> = flow {
// emit(dev.meloda.fast.network.State.Loading)
//
// val newState = conversationsRepository.unpin(
// ConversationsUnpinRequest(peerId = peerId)
// ).fold(
// onSuccess = { dev.meloda.fast.network.State.Success(Unit) },
// onNetworkFailure = { dev.meloda.fast.network.State.Error.ConnectionError },
// onUnknownFailure = { dev.meloda.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 dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.data.ConversationsUseCaseImpl
import dev.meloda.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 dev.meloda.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 dev.meloda.fast.conversations.model
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.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 dev.meloda.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 dev.meloda.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 dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.util.ImmutableList
@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,31 @@
package dev.meloda.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.common.extensions.navigation.sharedViewModel
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.model.BaseError
import kotlinx.serialization.Serializable
@Serializable
object Conversations
fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
navController: NavController,
) {
composable<Conversations> {
val viewModel: ConversationsViewModel =
it.sharedViewModel<ConversationsViewModelImpl>(navController = navController)
ConversationsRoute(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
viewModel = viewModel
)
}
}
@@ -0,0 +1,419 @@
package dev.meloda.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.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.util.getString
import dev.meloda.fast.ui.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 = avatarImage,
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 = kotlin.run {
val builder =
AnnotatedString.Builder(conversation.message.text)
conversation.message.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
} else {
spanStyleRange.item
}
builder.addStyle(
style = updatedSpanStyle,
start = spanStyleRange.start,
end = spanStyleRange.end
)
}
conversation.message.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
end = style.end
)
}
builder.toAnnotatedString()
},
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,116 @@
package dev.meloda.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 dev.meloda.fast.common.UserConfig
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding
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
val bottomPadding = LocalBottomPadding.current
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
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@@ -0,0 +1,432 @@
package dev.meloda.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.fadeIn
import androidx.compose.animation.fadeOut
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.Spacer
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.height
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.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.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 dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.ConversationsViewModelImpl
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.datastore.UserSettings
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.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
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 org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
@Composable
fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
viewModel: ConversationsViewModel = koinViewModel<ConversationsViewModelImpl>()
) {
val context = LocalContext.current
val userSettings: UserSettings = koinInject()
val enablePullToRefresh by userSettings.enablePullToRefresh.collectAsStateWithLifecycle()
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
ConversationsScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
enablePullToRefresh = enablePullToRefresh,
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
)
HandleDialogs(
screenState = screenState,
viewModel = viewModel
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
enablePullToRefresh: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val view = LocalView.current
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
}
val listState = rememberLazyListState()
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()
}
}
val hazeState = LocalHazeState.current
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.enableBlur || !listState.canScrollBackward)
MaterialTheme.colorScheme.surface
else
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
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 = {
onRefreshDropdownItemClicked()
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.enableBlur) toolbarColorAlpha else 1f
)
),
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
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) }
Column {
AnimatedVisibility(
visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) {
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"
)
}
}
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
) { padding ->
when {
baseError is BaseError.SessionExpired -> {
ErrorView(
text = "Session expired",
buttonText = "Log out",
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
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())
.then(
if (enablePullToRefresh) {
Modifier.nestedScroll(pullToRefreshState.nestedScrollConnection)
} else Modifier
)
) {
ConversationsListComposable(
onConversationsClick = onConversationItemClicked,
onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.enableBlur) {
Modifier.haze(
state = hazeState,
style = HazeMaterials.thick()
)
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
padding = padding
)
if (enablePullToRefresh) {
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
onRefresh()
}
}
LaunchedEffect(screenState.isLoading) {
if (!screenState.isLoading) {
pullToRefreshState.endRefresh()
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
contentColor = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
// 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)
)
}
if (showOptions.showPinDialog != null) {
val conversation = showOptions.showPinDialog
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)
)
}
}
@@ -0,0 +1,848 @@
package dev.meloda.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 dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.UserConfig
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.conversations.model.ActionState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.data.AttachmentType
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.util.ImmutableList
import java.util.Calendar
import java.util.Locale
import kotlin.math.ln
import kotlin.math.pow
import dev.meloda.fast.ui.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
AttachmentType.NARRATIVE -> null
AttachmentType.ARTICLE -> 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>>()
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 replaced = matchResult.groups[3]?.value.orEmpty()
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
AttachmentType.NARRATIVE -> UiR.string.message_attachments_narrative
AttachmentType.ARTICLE -> UiR.string.message_attachments_article
}.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
}
}
}