Upstream changes (#23)

This commit is contained in:
2024-07-11 02:12:32 +03:00
committed by GitHub
parent 8a6378f509
commit 3503ecffab
906 changed files with 23577 additions and 24115 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,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()
@@ -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
}
@@ -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)
}
}
@@ -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()
}
@@ -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
@@ -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,
)
}
}
@@ -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
}
@@ -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
)
@@ -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)))
}
@@ -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,
)
}
}
}
@@ -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))
// }
// }
}
}
@@ -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))
}
}
}
}
@@ -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()
)
}
}
}
@@ -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
)
}
}
}
}
@@ -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
}
@@ -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
}
}