Upstream changes (#23)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
+871
@@ -0,0 +1,871 @@
|
||||
package com.meloda.app.fast.messageshistory
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.conena.nanokt.collections.indexOfFirstOrNull
|
||||
import com.conena.nanokt.collections.indexOfOrNull
|
||||
import com.conena.nanokt.text.isEmptyOrBlank
|
||||
import com.meloda.app.fast.common.UserConfig
|
||||
import com.meloda.app.fast.common.extensions.listenValue
|
||||
import com.meloda.app.fast.common.extensions.setValue
|
||||
import com.meloda.app.fast.common.extensions.updateValue
|
||||
import com.meloda.app.fast.data.LongPollUpdatesParser
|
||||
import com.meloda.app.fast.data.VkMemoryCache
|
||||
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.SettingsKeys
|
||||
import com.meloda.app.fast.messageshistory.model.ActionMode
|
||||
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
|
||||
import com.meloda.app.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import com.meloda.app.fast.messageshistory.util.asPresentation
|
||||
import com.meloda.app.fast.messageshistory.util.extractAvatar
|
||||
import com.meloda.app.fast.messageshistory.util.extractShowName
|
||||
import com.meloda.app.fast.messageshistory.util.extractTitle
|
||||
import com.meloda.app.fast.model.BaseError
|
||||
import com.meloda.app.fast.model.LongPollEvent
|
||||
import com.meloda.app.fast.model.api.domain.VkAttachment
|
||||
import com.meloda.app.fast.model.api.domain.VkMessage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.random.Random
|
||||
|
||||
interface MessagesHistoryViewModel {
|
||||
|
||||
val screenState: StateFlow<MessagesHistoryScreenState>
|
||||
|
||||
val baseError: StateFlow<BaseError?>
|
||||
val imagesToPreload: StateFlow<List<String>>
|
||||
|
||||
val currentOffset: StateFlow<Int>
|
||||
|
||||
val canPaginate: StateFlow<Boolean>
|
||||
|
||||
fun onAttachmentButtonClicked()
|
||||
fun onInputChanged(newText: String)
|
||||
fun onEmojiButtonClicked()
|
||||
fun onActionButtonClicked()
|
||||
fun onTopAppBarMenuClicked(id: Int)
|
||||
fun setArguments(arguments: MessagesHistoryArguments)
|
||||
|
||||
fun onMetPaginationCondition()
|
||||
fun onShowDatesClicked(showDates: Boolean)
|
||||
fun onShowNamesClicked(showNames: Boolean)
|
||||
fun onEnableAnimationsClicked(enableAnimations: Boolean)
|
||||
}
|
||||
|
||||
class MessagesHistoryViewModelImpl(
|
||||
private val messagesUseCase: MessagesUseCase,
|
||||
private val conversationsUseCase: ConversationsUseCase,
|
||||
private val preferences: SharedPreferences,
|
||||
private val resources: Resources,
|
||||
updatesParser: LongPollUpdatesParser,
|
||||
) : MessagesHistoryViewModel, ViewModel() {
|
||||
|
||||
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
|
||||
|
||||
override val baseError = MutableStateFlow<BaseError?>(null)
|
||||
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
|
||||
|
||||
override val currentOffset = MutableStateFlow(0)
|
||||
|
||||
override val canPaginate = MutableStateFlow(false)
|
||||
|
||||
private val messages = MutableStateFlow<List<VkMessage>>(emptyList())
|
||||
|
||||
private var lastMessageText: String? = null
|
||||
|
||||
private val sendingMessages: MutableList<VkMessage> = mutableListOf()
|
||||
|
||||
init {
|
||||
updatesParser.onNewMessage(::handleNewMessage)
|
||||
updatesParser.onMessageEdited(::handleEditedMessage)
|
||||
updatesParser.onMessageIncomingRead(::handleReadIncomingEvent)
|
||||
updatesParser.onMessageOutgoingRead(::handleReadOutgoingEvent)
|
||||
}
|
||||
|
||||
override fun onAttachmentButtonClicked() {
|
||||
|
||||
}
|
||||
|
||||
override fun onInputChanged(newText: String) {
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
message = newText,
|
||||
actionMode = if (newText.isEmptyOrBlank()) ActionMode.Record
|
||||
else ActionMode.Send
|
||||
)
|
||||
}
|
||||
|
||||
screenState.value.copy(message = newText).let { newValue ->
|
||||
screenState.updateValue(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEmojiButtonClicked() {
|
||||
|
||||
}
|
||||
|
||||
override fun onActionButtonClicked() {
|
||||
when (screenState.value.actionMode) {
|
||||
ActionMode.Delete -> {
|
||||
|
||||
}
|
||||
|
||||
ActionMode.Edit -> {
|
||||
|
||||
}
|
||||
|
||||
ActionMode.Record -> {
|
||||
|
||||
}
|
||||
|
||||
ActionMode.Send -> sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTopAppBarMenuClicked(id: Int) {
|
||||
when (id) {
|
||||
0 -> loadMessagesHistory(0)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun setArguments(arguments: MessagesHistoryArguments) {
|
||||
if (arguments.conversationId == screenState.value.conversationId) return
|
||||
|
||||
screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) }
|
||||
loadMessagesHistory()
|
||||
}
|
||||
|
||||
override fun onMetPaginationCondition() {
|
||||
currentOffset.update { screenState.value.messages.size }
|
||||
loadMessagesHistory()
|
||||
}
|
||||
|
||||
override fun onShowDatesClicked(showDates: Boolean) {
|
||||
preferences.edit { putBoolean(SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES, showDates) }
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
messages = old.messages.map { message ->
|
||||
message.copy(showDate = showDates)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShowNamesClicked(showNames: Boolean) {
|
||||
preferences.edit { putBoolean(SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES, showNames) }
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
messages = old.messages.map { message ->
|
||||
message.copy(
|
||||
showName = if (showNames) {
|
||||
val index = messages.value.indexOfFirst { it.id == message.id }
|
||||
val domainMessage = messages.value[index]
|
||||
val prevMessage = messages.value.getOrNull(index + 1)
|
||||
|
||||
domainMessage.extractShowName(prevMessage)
|
||||
} else false
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnableAnimationsClicked(enableAnimations: Boolean) {
|
||||
preferences.edit {
|
||||
putBoolean(
|
||||
SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES,
|
||||
enableAnimations
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) {
|
||||
val message = event.message
|
||||
|
||||
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
|
||||
if (message.peerId != screenState.value.conversationId) return
|
||||
|
||||
val randomIds = messages.value.map(VkMessage::randomId)
|
||||
if (message.randomId != 0 && message.randomId in randomIds) return
|
||||
|
||||
val newMessages = screenState.value.messages.toMutableList()
|
||||
val prevMessage = messages.value.firstOrNull()
|
||||
|
||||
messages.setValue { old ->
|
||||
old.toMutableList().also { it.add(0, message) }
|
||||
}
|
||||
|
||||
val newMessage = message.asPresentation(
|
||||
showDate = false,
|
||||
showName = false,
|
||||
prevMessage = prevMessage,
|
||||
nextMessage = null
|
||||
)
|
||||
newMessages.add(0, newMessage)
|
||||
|
||||
prevMessage?.let { prev ->
|
||||
newMessages[1] = prev.asPresentation(
|
||||
showDate = false,
|
||||
showName = false,
|
||||
prevMessage = prevMessage,
|
||||
nextMessage = messages.value.first()
|
||||
)
|
||||
}
|
||||
|
||||
screenState.setValue { old -> old.copy(messages = newMessages) }
|
||||
}
|
||||
|
||||
private fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) {
|
||||
val message = event.message
|
||||
if (message.peerId != screenState.value.conversationId) return
|
||||
|
||||
screenState.value.messages
|
||||
.indexOfFirstOrNull { it.id == message.id }
|
||||
?.let { index ->
|
||||
val newMessage = message.asPresentation(
|
||||
showDate = false,
|
||||
showName = false,
|
||||
prevMessage = messages.value.getOrNull(index + 1),
|
||||
nextMessage = messages.value.getOrNull(index - 1)
|
||||
)
|
||||
|
||||
val newMessages = screenState.value.messages.toMutableList()
|
||||
newMessages[index] = newMessage
|
||||
|
||||
screenState.setValue { old -> old.copy(messages = newMessages) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReadIncomingEvent(event: LongPollEvent.VkMessageReadIncomingEvent) {
|
||||
|
||||
}
|
||||
|
||||
private fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) {
|
||||
|
||||
}
|
||||
|
||||
private fun loadMessagesHistory(offset: Int = currentOffset.value) {
|
||||
Log.d("MessagesHistoryViewModel", "loadMessagesHistory: $offset")
|
||||
|
||||
messagesUseCase.getMessagesHistory(
|
||||
conversationId = screenState.value.conversationId,
|
||||
count = MESSAGES_LOAD_COUNT,
|
||||
offset = offset,
|
||||
).listenValue { state ->
|
||||
state.processState(
|
||||
error = { error ->
|
||||
|
||||
},
|
||||
success = { response ->
|
||||
val messages = response.messages
|
||||
val fullMessages = if (offset == 0) {
|
||||
messages
|
||||
} else {
|
||||
this.messages.value.plus(messages)
|
||||
}.sorted()
|
||||
|
||||
val conversations = response.conversations
|
||||
|
||||
imagesToPreload.setValue {
|
||||
messages.mapNotNull { it.extractAvatar().extractUrl() }
|
||||
}
|
||||
|
||||
messagesUseCase.storeMessages(messages)
|
||||
conversationsUseCase.storeConversations(conversations)
|
||||
|
||||
val showDate =
|
||||
preferences.getBoolean(SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES, false)
|
||||
val showName =
|
||||
preferences.getBoolean(SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES, false)
|
||||
|
||||
val loadedMessages = fullMessages.mapIndexed { index, message ->
|
||||
message.asPresentation(
|
||||
showDate = showDate,
|
||||
showName = showName,
|
||||
prevMessage = messages.getOrNull(index + 1),
|
||||
nextMessage = messages.getOrNull(index - 1),
|
||||
)
|
||||
}
|
||||
|
||||
val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT
|
||||
|
||||
val paginationExhausted = !itemsCountSufficient &&
|
||||
screenState.value.messages.isNotEmpty()
|
||||
var newState = screenState.value.copy(
|
||||
isPaginationExhausted = paginationExhausted,
|
||||
)
|
||||
|
||||
conversations
|
||||
.firstOrNull { it.id == screenState.value.conversationId }
|
||||
?.let { conversation ->
|
||||
newState = newState.copy(
|
||||
title = conversation.extractTitle(
|
||||
useContactName = preferences.getBoolean(
|
||||
SettingsKeys.KEY_USE_CONTACT_NAMES,
|
||||
SettingsKeys.DEFAULT_VALUE_USE_CONTACT_NAMES
|
||||
),
|
||||
resources = resources
|
||||
),
|
||||
avatar = conversation.extractAvatar()
|
||||
)
|
||||
}
|
||||
|
||||
this.messages.emit(fullMessages)
|
||||
screenState.setValue { newState.copy(messages = loadedMessages) }
|
||||
canPaginate.setValue { itemsCountSufficient }
|
||||
}
|
||||
)
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
isLoading = offset == 0 && state.isLoading(),
|
||||
isPaginating = offset > 0 && state.isLoading()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<VkMessage>.sorted(): List<VkMessage> {
|
||||
return sortedWith { m1, m2 ->
|
||||
val dateDiff = m2.date - m1.date
|
||||
if (dateDiff != 0) {
|
||||
dateDiff
|
||||
} else {
|
||||
val idDiff = m2.id - m1.id
|
||||
idDiff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage() {
|
||||
lastMessageText = screenState.value.message
|
||||
|
||||
val newMessage = VkMessage(
|
||||
id = -1 - sendingMessages.size,
|
||||
text = lastMessageText,
|
||||
isOut = true,
|
||||
peerId = screenState.value.conversationId,
|
||||
fromId = UserConfig.userId,
|
||||
date = (System.currentTimeMillis() / 1000).toInt(),
|
||||
randomId = Random.nextInt(),
|
||||
action = null,
|
||||
actionMemberId = null,
|
||||
actionText = null,
|
||||
actionConversationMessageId = null,
|
||||
actionMessage = null,
|
||||
updateTime = null,
|
||||
important = false,
|
||||
forwards = null,
|
||||
attachments = null,
|
||||
replyMessage = null,
|
||||
geoType = null,
|
||||
user = VkMemoryCache.getUser(UserConfig.userId),
|
||||
group = null,
|
||||
actionUser = null,
|
||||
actionGroup = null
|
||||
)
|
||||
sendingMessages += newMessage
|
||||
|
||||
val newMessages = screenState.value.messages.toMutableList()
|
||||
val newUiMessage = newMessage.asPresentation(
|
||||
showDate = false,
|
||||
showName = false,
|
||||
prevMessage = messages.value.firstOrNull(),
|
||||
nextMessage = null
|
||||
)
|
||||
newMessages.add(0, newUiMessage)
|
||||
|
||||
messages.setValue { old ->
|
||||
listOf(newMessage).plus(old)
|
||||
}
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
message = "",
|
||||
actionMode = ActionMode.Record,
|
||||
messages = listOf(newUiMessage).plus(old.messages)
|
||||
)
|
||||
}
|
||||
|
||||
messagesUseCase.sendMessage(
|
||||
peerId = screenState.value.conversationId,
|
||||
randomId = newMessage.randomId,
|
||||
message = newMessage.text,
|
||||
replyTo = null,
|
||||
attachments = null
|
||||
).listenValue { state ->
|
||||
state.processState(
|
||||
error = { error ->
|
||||
sendingMessages -= newMessage
|
||||
},
|
||||
success = { messageId ->
|
||||
sendingMessages += newMessage
|
||||
|
||||
val messages = screenState.value.messages.toMutableList()
|
||||
|
||||
messages.indexOfOrNull(newUiMessage)?.let { index ->
|
||||
messages[index] = messages[index].copy(id = messageId)
|
||||
}
|
||||
|
||||
screenState.setValue { old -> old.copy(messages = messages) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsImportant(
|
||||
messagesIds: List<Int>,
|
||||
important: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// sendRequest(
|
||||
// request = {
|
||||
// messagesRepository.markAsImportant(
|
||||
// MessagesMarkAsImportantRequest(
|
||||
// messagesIds = messagesIds,
|
||||
// important = important
|
||||
// )
|
||||
// )
|
||||
// },
|
||||
// onResponse = { response ->
|
||||
// val markedIds = response.response ?: emptyList()
|
||||
// // TODO: 25.08.2023, Danil Nikolaev: update messages
|
||||
// }
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
fun pinMessage(
|
||||
peerId: Int,
|
||||
messageId: Int? = null,
|
||||
conversationMessageId: Int? = null,
|
||||
pin: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// if (pin) {
|
||||
// val pinnedMessage = sendRequest {
|
||||
// messagesRepository.pin(
|
||||
// MessagesPinMessageRequest(
|
||||
// peerId = peerId,
|
||||
// messageId = messageId,
|
||||
// conversationMessageId = conversationMessageId
|
||||
// )
|
||||
// )
|
||||
// } ?: return@launch
|
||||
//
|
||||
// // TODO: 25.08.2023, Danil Nikolaev: update message
|
||||
// } else {
|
||||
// val unpinnedMessage = sendRequest {
|
||||
// messagesRepository.unpin(MessagesUnPinMessageRequest(peerId = peerId))
|
||||
// } ?: return@launch
|
||||
//
|
||||
// // TODO: 25.08.2023, Danil Nikolaev: update message
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMessage(
|
||||
peerId: Int,
|
||||
messagesIds: List<Int>? = null,
|
||||
conversationsMessagesIds: List<Int>? = null,
|
||||
isSpam: Boolean? = null,
|
||||
deleteForAll: Boolean? = null,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// sendRequest {
|
||||
// messagesRepository.delete(
|
||||
// MessagesDeleteRequest(
|
||||
// peerId = peerId,
|
||||
// messagesIds = messagesIds,
|
||||
// conversationsMessagesIds = conversationsMessagesIds,
|
||||
// isSpam = isSpam,
|
||||
// deleteForAll = deleteForAll
|
||||
// )
|
||||
// )
|
||||
// } ?: return@launch
|
||||
|
||||
// TODO: 25.08.2023, Danil Nikolaev: handle deleting
|
||||
}
|
||||
}
|
||||
|
||||
fun editMessage(
|
||||
originalMessage: VkMessage,
|
||||
peerId: Int,
|
||||
messageId: Int,
|
||||
newText: String? = null,
|
||||
attachments: List<VkAttachment>? = null,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// sendRequest {
|
||||
// messagesRepository.edit(
|
||||
// MessagesEditRequest(
|
||||
// peerId = peerId,
|
||||
// messageId = messageId,
|
||||
// message = newText,
|
||||
// attachments = attachments
|
||||
// )
|
||||
// )
|
||||
// } ?: return@launch
|
||||
|
||||
// TODO: 25.08.2023, Danil Nikolaev: update message
|
||||
}
|
||||
}
|
||||
|
||||
fun readMessage(peerId: Int, messageId: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// sendRequest {
|
||||
// messagesRepository.markAsRead(peerId, startMessageId = messageId)
|
||||
// } ?: return@launch
|
||||
|
||||
// TODO: 25.08.2023, Danil Nikolaev: update messages
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MESSAGES_LOAD_COUNT = 30
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO: 25.08.2023, Danil Nikolaev: this and down below - rewrite
|
||||
// suspend fun uploadPhoto(
|
||||
// peerId: Int,
|
||||
// photo: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// suspendCoroutine {
|
||||
// viewModelScope.launch {
|
||||
// val uploadServerUrl = getPhotoMessageUploadServer(peerId)
|
||||
// val uploadedFileInfo = uploadPhotoToServer(uploadServerUrl, photo, name)
|
||||
//
|
||||
// val savedAttachment = saveMessagePhoto(
|
||||
// uploadedFileInfo.first,
|
||||
// uploadedFileInfo.second,
|
||||
// uploadedFileInfo.third
|
||||
// )
|
||||
//
|
||||
// it.resume(savedAttachment)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun getPhotoMessageUploadServer(peerId: Int) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { photosRepository.getMessagesUploadServer(peerId) }
|
||||
// ).response?.let { response ->
|
||||
// continuation.resume(response.uploadUrl)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun uploadPhotoToServer(
|
||||
// uploadUrl: String,
|
||||
// photo: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// val requestBody = photo.asRequestBody("image/*".toMediaType())
|
||||
// val body = MultipartBody.Part.createFormData("photo", name, requestBody)
|
||||
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { photosRepository.uploadPhoto(uploadUrl, body) }
|
||||
// ).let { response ->
|
||||
// continuation.resume(Triple(response.server, response.photo, response.hash))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun saveMessagePhoto(
|
||||
// server: Int,
|
||||
// photo: String,
|
||||
// hash: String,
|
||||
// ) = suspendCoroutine<VkAttachment> { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = {
|
||||
// photosRepository.saveMessagePhoto(
|
||||
// PhotosSaveMessagePhotoRequest(photo, server, hash)
|
||||
// )
|
||||
// }
|
||||
// ).response?.first()?.toDomain()?.let(continuation::resume)
|
||||
// }
|
||||
// }
|
||||
|
||||
// suspend fun uploadVideo(
|
||||
// file: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// suspendCoroutine {
|
||||
// viewModelScope.launch {
|
||||
// val uploadInfo = getVideoMessageUploadServer()
|
||||
//
|
||||
// uploadVideoToServer(
|
||||
// uploadInfo.first,
|
||||
// file,
|
||||
// name
|
||||
// )
|
||||
//
|
||||
// it.resume(uploadInfo.second)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun getVideoMessageUploadServer() {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { videosRepository.save() }
|
||||
// ).response?.let { response ->
|
||||
// val uploadUrl = response.uploadUrl
|
||||
// val video = VkVideoDomain(
|
||||
// id = response.videoId,
|
||||
// ownerId = response.ownerId,
|
||||
// images = emptyList(),
|
||||
// firstFrames = null,
|
||||
// accessKey = response.accessKey,
|
||||
// title = response.title
|
||||
// )
|
||||
//
|
||||
// continuation.resume(uploadUrl to video)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun uploadVideoToServer(
|
||||
// uploadUrl: String,
|
||||
// file: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// viewModelScope.launch {
|
||||
// val requestBody = file.asRequestBody()
|
||||
// val body = MultipartBody.Part.createFormData("video_file", name, requestBody)
|
||||
//
|
||||
// sendRequest(
|
||||
// onError = { exception -> throw exception },
|
||||
// request = { videosRepository.upload(uploadUrl, body) }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
// suspend fun uploadAudio(
|
||||
// file: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// suspendCoroutine {
|
||||
// viewModelScope.launch {
|
||||
// val uploadUrl = getAudioUploadServer()
|
||||
// val uploadInfo = uploadAudioToServer(uploadUrl, file, name)
|
||||
// val saveInfo = saveMessageAudio(
|
||||
// uploadInfo.first, uploadInfo.second, uploadInfo.third
|
||||
// )
|
||||
//
|
||||
// it.resume(saveInfo)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun getAudioUploadServer() {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { audiosRepository.getUploadServer() }
|
||||
// ).response?.uploadUrl?.let(continuation::resume)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun uploadAudioToServer(
|
||||
// uploadUrl: String,
|
||||
// file: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// val requestBody = file.asRequestBody()
|
||||
// val body = MultipartBody.Part.createFormData("file", name, requestBody)
|
||||
//
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { audiosRepository.upload(uploadUrl, body) }
|
||||
// ).let { response ->
|
||||
// response.error?.let { error -> throw ApiException(error = error) }
|
||||
//
|
||||
// continuation.resume(
|
||||
// Triple(response.server, response.audio.notNull(), response.hash)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun saveMessageAudio(
|
||||
// server: Int,
|
||||
// audio: String,
|
||||
// hash: String,
|
||||
// ) {
|
||||
// suspendCoroutine<VkAttachment> { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { audiosRepository.save(server, audio, hash) }
|
||||
// ).response?.toDomain()?.let(continuation::resume)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// suspend fun uploadFile(
|
||||
// peerId: Int,
|
||||
// file: File,
|
||||
// name: String,
|
||||
// type: FilesRepository.FileType,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// val uploadServerUrl = getFileMessageUploadServer(peerId, type)
|
||||
// val uploadedFileInfo = uploadFileToServer(uploadServerUrl, file, name)
|
||||
// val savedAttachmentPair = saveMessageFile(uploadedFileInfo)
|
||||
//
|
||||
// continuation.resume(savedAttachmentPair.second)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun getFileMessageUploadServer(
|
||||
// peerId: Int,
|
||||
// type: FilesRepository.FileType,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// val uploadServerResponse = sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { filesRepository.getMessagesUploadServer(peerId, type) }
|
||||
// ).response.notNull()
|
||||
//
|
||||
// continuation.resume(uploadServerResponse.uploadUrl)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun uploadFileToServer(
|
||||
// uploadUrl: String,
|
||||
// file: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// val requestBody = file.asRequestBody()
|
||||
// val body = MultipartBody.Part.createFormData("file", name, requestBody)
|
||||
//
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { filesRepository.uploadFile(uploadUrl, body) }
|
||||
// ).let { response ->
|
||||
// response.error?.let { error -> throw ApiException(error = error) }
|
||||
//
|
||||
// continuation.resume(response.file.notNull())
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun saveMessageFile(file: String) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
// onError = { exception ->
|
||||
// continuation.resumeWithException(exception)
|
||||
// true
|
||||
// },
|
||||
// request = { filesRepository.saveMessageFile(file) }
|
||||
// ).response?.let { response ->
|
||||
// val type = response.type
|
||||
// val attachmentFile =
|
||||
// response.file?.toDomain() ?: response.voiceMessage?.toDomain()
|
||||
//
|
||||
// continuation.resume(type to attachmentFile.notNull())
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
//data class MessagesLoadedEvent(
|
||||
// val count: Int,
|
||||
// val conversations: HashMap<Int, VkConversationDomain>,
|
||||
// val messages: List<VkMessageDomain>,
|
||||
// val profiles: HashMap<Int, VkUserDomain>,
|
||||
// val groups: HashMap<Int, VkGroupDomain>,
|
||||
//) : VkEvent()
|
||||
//
|
||||
//data class MessagesMarkAsImportantEvent(val messagesIds: List<Int>, val important: Boolean) :
|
||||
// VkEvent()
|
||||
//
|
||||
//data class MessagesPinEvent(val message: VkMessageDomain) : VkEvent()
|
||||
//
|
||||
//object MessagesUnpinEvent : VkEvent()
|
||||
//
|
||||
//data class MessagesDeleteEvent(val peerId: Int, val messagesIds: List<Int>) : VkEvent()
|
||||
//
|
||||
//data class MessagesEditEvent(val message: VkMessageDomain) : VkEvent()
|
||||
//
|
||||
//data class MessagesReadEvent(
|
||||
// val isOut: Boolean,
|
||||
// val peerId: Int,
|
||||
// val messageId: Int,
|
||||
//) : VkEvent()
|
||||
//
|
||||
//data class MessagesNewEvent(
|
||||
// val message: VkMessageDomain,
|
||||
// val profiles: HashMap<Int, VkUserDomain>,
|
||||
// val groups: HashMap<Int, VkGroupDomain>,
|
||||
//) : VkEvent()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.meloda.app.fast.messageshistory.di
|
||||
|
||||
import com.meloda.app.fast.data.api.messages.MessagesUseCase
|
||||
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModel
|
||||
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import com.meloda.app.fast.messageshistory.domain.MessagesUseCaseImpl
|
||||
import com.meloda.app.fast.messageshistory.validation.MessagesHistoryValidator
|
||||
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 messagesHistoryModule = module {
|
||||
singleOf(::MessagesUseCaseImpl) bind MessagesUseCase::class
|
||||
singleOf(::MessagesHistoryValidator)
|
||||
viewModelOf(::MessagesHistoryViewModelImpl) bind MessagesHistoryViewModel::class
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.meloda.app.fast.messageshistory.domain
|
||||
|
||||
import com.meloda.app.fast.data.State
|
||||
import com.meloda.app.fast.data.api.messages.MessagesHistoryDomain
|
||||
import com.meloda.app.fast.data.api.messages.MessagesRepository
|
||||
import com.meloda.app.fast.data.api.messages.MessagesUseCase
|
||||
import com.meloda.app.fast.data.mapToState
|
||||
import com.meloda.app.fast.model.api.domain.VkAttachment
|
||||
import com.meloda.app.fast.model.api.domain.VkMessage
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
class MessagesUseCaseImpl(
|
||||
private val messagesRepository: MessagesRepository
|
||||
) : MessagesUseCase {
|
||||
|
||||
override fun getMessagesHistory(
|
||||
conversationId: Int,
|
||||
count: Int?,
|
||||
offset: Int?
|
||||
): Flow<State<MessagesHistoryDomain>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val newState = messagesRepository.getMessagesHistory(
|
||||
conversationId = conversationId,
|
||||
offset = offset,
|
||||
count = count
|
||||
).mapToState()
|
||||
|
||||
emit(newState)
|
||||
}
|
||||
|
||||
override fun getById(
|
||||
messageId: Int,
|
||||
extended: Boolean?,
|
||||
fields: String?
|
||||
): Flow<State<VkMessage?>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val newState = messagesRepository.getMessageById(
|
||||
messagesIds = listOf(messageId),
|
||||
extended = extended,
|
||||
fields = fields
|
||||
).mapToState()
|
||||
emit(newState)
|
||||
}
|
||||
|
||||
override fun getByIds(
|
||||
messageIds: List<Int>,
|
||||
extended: Boolean?,
|
||||
fields: String?
|
||||
): Flow<State<List<VkMessage>>> = flow {}
|
||||
// flow {
|
||||
// emit(State.Loading)
|
||||
//
|
||||
// val newState = messagesRepository.getById(
|
||||
// params = MessagesGetByIdRequest(
|
||||
// messagesIds = messageIds,
|
||||
// extended = extended,
|
||||
// fields = fields
|
||||
// )
|
||||
// ).fold(
|
||||
// onSuccess = { response ->
|
||||
// val messages = response.items
|
||||
// val usersMap =
|
||||
// VkUsersMap.forUsers(response.profiles.orEmpty().map(VkUserData::mapToDomain))
|
||||
// val groupsMap =
|
||||
// VkGroupsMap.forGroups(response.groups.orEmpty().map(VkGroupData::mapToDomain))
|
||||
//
|
||||
// com.meloda.app.fast.network.State.Success(
|
||||
// messages.map { message ->
|
||||
// message.mapToDomain(
|
||||
// user = usersMap.messageUser(message),
|
||||
// group = groupsMap.messageGroup(message),
|
||||
// actionUser = usersMap.messageActionUser(message),
|
||||
// actionGroup = groupsMap.messageActionGroup(message)
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
// },
|
||||
// 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 sendMessage(
|
||||
peerId: Int,
|
||||
randomId: Int,
|
||||
message: String?,
|
||||
replyTo: Int?,
|
||||
attachments: List<VkAttachment>?
|
||||
): Flow<State<Int>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val newState = messagesRepository.send(
|
||||
peerId = peerId,
|
||||
randomId = randomId,
|
||||
message = message,
|
||||
replyTo = replyTo,
|
||||
attachments = attachments
|
||||
).mapToState()
|
||||
|
||||
emit(newState)
|
||||
}
|
||||
|
||||
override fun markAsRead(
|
||||
peerId: Int,
|
||||
startMessageId: Int
|
||||
): Flow<State<Int>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val newState = messagesRepository.markAsRead(
|
||||
peerId = peerId,
|
||||
startMessageId = startMessageId
|
||||
).mapToState()
|
||||
|
||||
emit(newState)
|
||||
}
|
||||
|
||||
override suspend fun storeMessage(message: VkMessage) {
|
||||
messagesRepository.storeMessages(listOf(message))
|
||||
}
|
||||
|
||||
override suspend fun storeMessages(messages: List<VkMessage>) {
|
||||
messagesRepository.storeMessages(messages)
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.meloda.app.fast.messageshistory.model
|
||||
|
||||
sealed class ActionMode {
|
||||
|
||||
data object Send : ActionMode()
|
||||
data object Record : ActionMode()
|
||||
data object Edit : ActionMode()
|
||||
data object Delete : ActionMode()
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.meloda.app.fast.messageshistory.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.meloda.app.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.meloda.app.fast.common.UiImage
|
||||
import com.meloda.app.fast.model.api.domain.VkAttachment
|
||||
|
||||
@Immutable
|
||||
data class MessagesHistoryScreenState(
|
||||
val conversationId: Int,
|
||||
val title: String,
|
||||
val status: String?,
|
||||
val avatar: UiImage,
|
||||
val messages: List<UiMessage>,
|
||||
val message: String,
|
||||
val attachments: List<VkAttachment>,
|
||||
val isLoading: Boolean,
|
||||
val isPaginating: Boolean,
|
||||
val isPaginationExhausted: Boolean,
|
||||
val actionMode: ActionMode,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val EMPTY: MessagesHistoryScreenState = MessagesHistoryScreenState(
|
||||
conversationId = -1,
|
||||
title = "",
|
||||
status = null,
|
||||
avatar = UiImage.Color(0),
|
||||
messages = emptyList(),
|
||||
message = "",
|
||||
attachments = emptyList(),
|
||||
isLoading = true,
|
||||
isPaginating = false,
|
||||
isPaginationExhausted = false,
|
||||
actionMode = ActionMode.Record,
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.meloda.app.fast.messageshistory.model
|
||||
|
||||
sealed interface MessagesHistoryValidationResult {
|
||||
|
||||
data object Empty : MessagesHistoryValidationResult
|
||||
data object AttachmentsEmpty : MessagesHistoryValidationResult
|
||||
data object MessageEmpty : MessagesHistoryValidationResult
|
||||
data object Valid : MessagesHistoryValidationResult
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meloda.app.fast.messageshistory.model
|
||||
|
||||
import com.meloda.app.fast.common.UiImage
|
||||
|
||||
data class UiMessage(
|
||||
val id: Int,
|
||||
val text: String?,
|
||||
val isOut: Boolean,
|
||||
val fromId: Int,
|
||||
val date: String,
|
||||
val randomId: Int,
|
||||
val isInChat: Boolean,
|
||||
val name: String,
|
||||
val showDate: Boolean,
|
||||
val showAvatar: Boolean,
|
||||
val showName: Boolean,
|
||||
val avatar: UiImage,
|
||||
val isEdited: Boolean
|
||||
)
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.meloda.app.fast.messageshistory.navigation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModel
|
||||
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import com.meloda.app.fast.messageshistory.model.MessagesHistoryArguments
|
||||
import com.meloda.app.fast.messageshistory.presentation.MessagesHistoryScreen
|
||||
import com.meloda.app.fast.model.BaseError
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
@Serializable
|
||||
data class MessagesHistory(val arguments: MessagesHistoryArguments)
|
||||
|
||||
val MessagesHistoryNavType = object : NavType<MessagesHistoryArguments>(isNullableAllowed = false) {
|
||||
override fun get(bundle: Bundle, key: String): MessagesHistoryArguments? =
|
||||
BundleCompat.getParcelable(bundle, key, MessagesHistoryArguments::class.java)
|
||||
|
||||
override fun parseValue(value: String): MessagesHistoryArguments = Json.decodeFromString(value)
|
||||
|
||||
override fun serializeAsValue(value: MessagesHistoryArguments): String =
|
||||
Json.encodeToString(value)
|
||||
|
||||
override fun put(bundle: Bundle, key: String, value: MessagesHistoryArguments) {
|
||||
bundle.putParcelable(key, value)
|
||||
}
|
||||
|
||||
override val name: String = "MessagesHistoryArguments"
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.messagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChatAttachments: () -> Unit
|
||||
) {
|
||||
composable<MessagesHistory>(
|
||||
typeMap = mapOf(typeOf<MessagesHistoryArguments>() to MessagesHistoryNavType)
|
||||
) { backStackEntry ->
|
||||
val arguments: MessagesHistory = backStackEntry.toRoute()
|
||||
|
||||
val viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
viewModel.setArguments(arguments.arguments)
|
||||
|
||||
MessagesHistoryScreen(
|
||||
onError = onError,
|
||||
onBack = onBack,
|
||||
onNavigateToChatMaterials = onNavigateToChatAttachments,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Int) {
|
||||
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.meloda.app.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.meloda.app.fast.messageshistory.model.UiMessage
|
||||
|
||||
@Composable
|
||||
fun IncomingMessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
message: UiMessage,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
if (message.isInChat) {
|
||||
Image(
|
||||
painter =
|
||||
message.avatar.extractUrl()?.let { url ->
|
||||
rememberAsyncImagePainter(
|
||||
model = url,
|
||||
imageLoader = context.imageLoader
|
||||
)
|
||||
} ?: painterResource(id = message.avatar.extractResId()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 6.dp)
|
||||
.size(28.dp)
|
||||
.alpha(if (message.showAvatar) 1f else 0f)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column {
|
||||
AnimatedVisibility(visible = message.showName) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.widthIn(max = 140.dp),
|
||||
text = message.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text,
|
||||
isOut = false,
|
||||
date = message.date,
|
||||
edited = message.isEdited,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.meloda.app.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String?,
|
||||
isOut: Boolean,
|
||||
date: String?,
|
||||
edited: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(
|
||||
if (isOut) {
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
) {
|
||||
if (text != null) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.align(Alignment.Center)
|
||||
.animateContentSize()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// val dateContainerWidth by animateDpAsState(
|
||||
// targetValue = if (edited) 50.dp else 30.dp,
|
||||
// label = "dateContainerWidth"
|
||||
// )
|
||||
|
||||
// AnimatedVisibility(
|
||||
// date != null,
|
||||
// modifier = Modifier
|
||||
// .width(dateContainerWidth)
|
||||
// .align(Alignment.BottomEnd)
|
||||
// ) {
|
||||
// Row(modifier = Modifier.fillMaxWidth()) {
|
||||
// if (edited) {
|
||||
// Icon(
|
||||
// imageVector = Icons.Rounded.Create,
|
||||
// contentDescription = null,
|
||||
// modifier = Modifier.size(14.dp)
|
||||
// )
|
||||
// Spacer(modifier = Modifier.width(4.dp))
|
||||
// }
|
||||
// Text(
|
||||
// text = date.orEmpty(),
|
||||
// style = MaterialTheme.typography.labelSmall
|
||||
// )
|
||||
// Spacer(modifier = Modifier.width(2.dp))
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
package com.meloda.app.fast.messageshistory.presentation
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.imeNestedScroll
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.HapticFeedbackConstantsCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.meloda.app.fast.datastore.SettingsKeys
|
||||
import com.meloda.app.fast.designsystem.ImmutableList
|
||||
import com.meloda.app.fast.designsystem.LocalTheme
|
||||
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModel
|
||||
import com.meloda.app.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import com.meloda.app.fast.messageshistory.model.ActionMode
|
||||
import com.meloda.app.fast.model.BaseError
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
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 com.meloda.app.fast.designsystem.R as UiR
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
ExperimentalLayoutApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun MessagesHistoryScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChatMaterials: () -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
val preferences: SharedPreferences = koinInject()
|
||||
val currentTheme = LocalTheme.current
|
||||
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
|
||||
val messages = screenState.messages
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val paginationConditionMet by remember {
|
||||
derivedStateOf {
|
||||
canPaginate &&
|
||||
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(paginationConditionMet) {
|
||||
if (paginationConditionMet && !screenState.isPaginating) {
|
||||
viewModel.onMetPaginationCondition()
|
||||
}
|
||||
}
|
||||
|
||||
var dropDownMenuExpanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val hazeSate = remember { HazeState() }
|
||||
|
||||
var datesShown by remember {
|
||||
mutableStateOf(
|
||||
preferences.getBoolean(
|
||||
SettingsKeys.KEY_SHOW_DATE_UNDER_BUBBLES,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var namesShown by remember {
|
||||
mutableStateOf(
|
||||
preferences.getBoolean(
|
||||
SettingsKeys.KEY_SHOW_NAME_IN_BUBBLES,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var animationsEnabled by remember {
|
||||
mutableStateOf(
|
||||
preferences.getBoolean(
|
||||
SettingsKeys.KEY_ENABLE_ANIMATIONS_IN_MESSAGES,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
val toolbarColorAlpha by animateFloatAsState(
|
||||
targetValue = if (!listState.canScrollForward) 1f else 0f,
|
||||
label = "toolbarColorAlpha",
|
||||
animationSpec = tween(durationMillis = 50)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (currentTheme.usingBlur) {
|
||||
Modifier.hazeChild(
|
||||
state = hazeSate,
|
||||
style = HazeMaterials.thick()
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
title = {
|
||||
Text(
|
||||
text =
|
||||
if (screenState.isLoading) stringResource(id = UiR.string.title_loading)
|
||||
else screenState.title
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = "Back button"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(
|
||||
alpha = if (currentTheme.usingBlur) toolbarColorAlpha else 1f
|
||||
)
|
||||
),
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { dropDownMenuExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreVert,
|
||||
contentDescription = "Options"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
|
||||
expanded = dropDownMenuExpanded,
|
||||
onDismissRequest = {
|
||||
dropDownMenuExpanded = false
|
||||
},
|
||||
offset = DpOffset(x = (-4).dp, y = (-60).dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
onNavigateToChatMaterials()
|
||||
},
|
||||
text = {
|
||||
Text(text = "Materials")
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
viewModel.onTopAppBarMenuClicked(0)
|
||||
dropDownMenuExpanded = false
|
||||
},
|
||||
text = {
|
||||
Text(text = "Refresh")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (preferences.getBoolean(
|
||||
SettingsKeys.KEY_SHOW_DEBUG_CATEGORY,
|
||||
false
|
||||
)
|
||||
) {
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = if (datesShown) "Hide dates" else "Show dates")
|
||||
},
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
datesShown = !datesShown
|
||||
viewModel.onShowDatesClicked(datesShown)
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = if (namesShown) "Hide names" else "Show names")
|
||||
},
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
namesShown = !namesShown
|
||||
viewModel.onShowNamesClicked(namesShown)
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = if (animationsEnabled) "Disable animations" else "Enable animations")
|
||||
},
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
animationsEnabled = !animationsEnabled
|
||||
viewModel.onEnableAnimationsClicked(animationsEnabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (screenState.isLoading && messages.isNotEmpty()) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
|
||||
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
|
||||
.padding(bottom = padding.calculateBottomPadding()),
|
||||
) {
|
||||
MessagesList(
|
||||
hazeState = hazeSate,
|
||||
listState = listState,
|
||||
immutableMessages = ImmutableList.copyOf(messages),
|
||||
isPaginating = screenState.isPaginating,
|
||||
enableAnimations = animationsEnabled
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomStart)
|
||||
.background(Color.Transparent)
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 60.dp)
|
||||
.imeNestedScroll(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
if (
|
||||
preferences.getBoolean(
|
||||
SettingsKeys.KEY_SHOW_EMOJI_BUTTON,
|
||||
SettingsKeys.DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON
|
||||
)
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val rotation = remember { Animatable(0f) }
|
||||
|
||||
IconButton(
|
||||
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_outline_emoji_emotions_24),
|
||||
contentDescription = "Emoji button",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = screenState.message,
|
||||
onValueChange = viewModel::onInputChanged,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
placeholder = { Text(text = stringResource(id = UiR.string.message_input_hint)) }
|
||||
)
|
||||
|
||||
IconButton(onClick = viewModel::onAttachmentButtonClicked) {
|
||||
Icon(
|
||||
painter = painterResource(id = UiR.drawable.round_attach_file_24),
|
||||
contentDescription = "Add attachment button",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.rotate(30f)
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val rotation = remember { Animatable(0f) }
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (screenState.actionMode == ActionMode.Record) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewModel.onActionButtonClicked()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.rotate(rotation.value)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = when (screenState.actionMode) {
|
||||
ActionMode.Delete -> UiR.drawable.round_delete_outline_24
|
||||
ActionMode.Edit -> UiR.drawable.ic_round_done_24
|
||||
ActionMode.Record -> UiR.drawable.ic_round_mic_none_24
|
||||
ActionMode.Send -> UiR.drawable.round_send_24
|
||||
}
|
||||
),
|
||||
contentDescription = when (screenState.actionMode) {
|
||||
ActionMode.Delete -> "Delete message button"
|
||||
ActionMode.Edit -> "Edit message button"
|
||||
ActionMode.Record -> "Record audio message button"
|
||||
ActionMode.Send -> "Send message button"
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (screenState.isLoading && messages.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.meloda.app.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meloda.app.fast.designsystem.ImmutableList
|
||||
import com.meloda.app.fast.designsystem.LocalTheme
|
||||
import com.meloda.app.fast.messageshistory.model.UiMessage
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.haze
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun MessagesList(
|
||||
modifier: Modifier = Modifier,
|
||||
hazeState: HazeState,
|
||||
listState: LazyListState,
|
||||
immutableMessages: ImmutableList<UiMessage>,
|
||||
isPaginating: Boolean,
|
||||
enableAnimations: Boolean
|
||||
) {
|
||||
val messages = immutableMessages.toList()
|
||||
val currentTheme = LocalTheme.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (currentTheme.usingBlur) {
|
||||
Modifier.haze(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.regular()
|
||||
)
|
||||
} else Modifier
|
||||
),
|
||||
state = listState,
|
||||
reverseLayout = true
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(68.dp))
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = messages,
|
||||
key = UiMessage::id,
|
||||
) { message ->
|
||||
if (message.isOut) {
|
||||
OutgoingMessageBubble(
|
||||
modifier =
|
||||
Modifier.then(
|
||||
if (enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
)
|
||||
else Modifier
|
||||
),
|
||||
message = message,
|
||||
)
|
||||
} else {
|
||||
IncomingMessageBubble(
|
||||
modifier =
|
||||
Modifier.then(
|
||||
if (enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
)
|
||||
else Modifier
|
||||
),
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
AnimatedVisibility(isPaginating) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.height(64.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.meloda.app.fast.messageshistory.presentation
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meloda.app.fast.common.extensions.orDots
|
||||
import com.meloda.app.fast.messageshistory.model.UiMessage
|
||||
|
||||
@Composable
|
||||
fun OutgoingMessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
message: UiMessage,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.fillMaxWidth(0.75f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text.orDots(),
|
||||
isOut = true,
|
||||
date = null,
|
||||
edited = message.isEdited,
|
||||
)
|
||||
|
||||
if (message.showDate) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
text = message.date,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.meloda.app.fast.messageshistory.util
|
||||
|
||||
import android.content.res.Resources
|
||||
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.data.VkMemoryCache
|
||||
import com.meloda.app.fast.designsystem.R
|
||||
import com.meloda.app.fast.messageshistory.model.UiMessage
|
||||
import com.meloda.app.fast.model.api.PeerType
|
||||
import com.meloda.app.fast.model.api.domain.VkConversation
|
||||
import com.meloda.app.fast.model.api.domain.VkMessage
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import com.meloda.app.fast.designsystem.R as UiR
|
||||
|
||||
private fun isAccount(fromId: Int) = fromId == UserConfig.userId
|
||||
|
||||
fun VkMessage.extractAvatar() = when {
|
||||
isUser() -> {
|
||||
if (isAccount(id)) null
|
||||
else user?.photo200
|
||||
}
|
||||
|
||||
isGroup() -> {
|
||||
group?.photo200
|
||||
}
|
||||
|
||||
else -> null
|
||||
}?.let(UiImage::Url) ?: UiImage.Resource(UiR.drawable.ic_account_circle_cut)
|
||||
|
||||
fun VkMessage.extractDate(): String =
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L)
|
||||
|
||||
fun VkMessage.extractTitle(): String = when {
|
||||
isUser() -> "%s %s".format(
|
||||
user?.firstName.orDots(),
|
||||
user?.lastName?.firstOrNull()?.toString().orEmpty().plus(".")
|
||||
)
|
||||
|
||||
isGroup() -> group?.name.orDots()
|
||||
|
||||
else -> throw IllegalStateException("Message is not from user nor group. fromId: $fromId")
|
||||
}
|
||||
|
||||
fun VkConversation.extractAvatar(): UiImage = when (peerType) {
|
||||
PeerType.USER -> {
|
||||
if (isAccount(id)) null
|
||||
else user?.photo200
|
||||
}
|
||||
|
||||
PeerType.GROUP -> {
|
||||
group?.photo200
|
||||
}
|
||||
|
||||
PeerType.CHAT -> {
|
||||
photo200
|
||||
}
|
||||
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
|
||||
|
||||
fun VkConversation.extractTitle(
|
||||
useContactName: Boolean,
|
||||
resources: Resources
|
||||
) = when (peerType) {
|
||||
PeerType.USER -> {
|
||||
if (isAccount(id)) {
|
||||
UiText.Resource(UiR.string.favorites)
|
||||
} else {
|
||||
val userName = user?.let { user ->
|
||||
if (useContactName) {
|
||||
VkMemoryCache.getContact(user.id)?.name
|
||||
} else {
|
||||
user.fullName
|
||||
}
|
||||
}
|
||||
|
||||
UiText.Simple(userName.orDots())
|
||||
}
|
||||
}
|
||||
|
||||
PeerType.GROUP -> UiText.Simple(group?.name.orDots())
|
||||
PeerType.CHAT -> UiText.Simple(title.orDots())
|
||||
}.parseString(resources).orDots()
|
||||
|
||||
fun VkMessage.asPresentation(
|
||||
showDate: Boolean,
|
||||
showName: Boolean,
|
||||
prevMessage: VkMessage?,
|
||||
nextMessage: VkMessage?
|
||||
): UiMessage = UiMessage(
|
||||
id = id,
|
||||
text = text,
|
||||
isOut = isOut,
|
||||
fromId = fromId,
|
||||
date = extractDate(),
|
||||
randomId = randomId,
|
||||
isInChat = isPeerChat(),
|
||||
name = extractTitle(),
|
||||
showDate = showDate,
|
||||
showAvatar = extractShowAvatar(nextMessage),
|
||||
showName = showName && extractShowName(prevMessage),
|
||||
avatar = extractAvatar(),
|
||||
isEdited = updateTime != null
|
||||
)
|
||||
|
||||
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
|
||||
if (isOut) return false
|
||||
return nextMessage == null || nextMessage.fromId != fromId
|
||||
}
|
||||
|
||||
fun VkMessage.extractShowName(prevMessage: VkMessage?): Boolean {
|
||||
if (isOut || !isPeerChat()) return false
|
||||
return prevMessage == null || prevMessage.fromId != fromId
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.meloda.app.fast.messageshistory.validation
|
||||
|
||||
import com.meloda.app.fast.common.extensions.addIf
|
||||
import com.meloda.app.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import com.meloda.app.fast.messageshistory.model.MessagesHistoryValidationResult
|
||||
|
||||
class MessagesHistoryValidator {
|
||||
|
||||
fun validate(screenState: MessagesHistoryScreenState): List<MessagesHistoryValidationResult> {
|
||||
val results = mutableListOf<MessagesHistoryValidationResult>()
|
||||
|
||||
results.addIf(MessagesHistoryValidationResult.MessageEmpty) {
|
||||
screenState.message.isBlank()
|
||||
}
|
||||
|
||||
results.addIf(MessagesHistoryValidationResult.AttachmentsEmpty) {
|
||||
screenState.attachments.isEmpty()
|
||||
}
|
||||
|
||||
if (results.size == 2) {
|
||||
results += MessagesHistoryValidationResult.Empty
|
||||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
return listOf(MessagesHistoryValidationResult.Valid)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user