a shit ton features, improvements and fixes in messages history screen and others

This commit is contained in:
2025-03-29 02:51:49 +03:00
parent da9644cde1
commit f02822a011
35 changed files with 1341 additions and 476 deletions
@@ -46,8 +46,8 @@ inline fun <T> State<T>.processState(
) {
when (this) {
is State.Error -> {
error(this)
any()
error(this)
}
State.Idle -> idle()
@@ -55,8 +55,8 @@ inline fun <T> State<T>.processState(
State.Loading -> loading()
is State.Success -> {
success(data)
any()
success(data)
}
}
}
@@ -56,15 +56,22 @@ interface MessagesRepository {
peerId: Int
): ApiResult<Int, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>)
suspend fun markAsImportant(
peerId: Int,
messageIds: List<Int>?,
conversationMessageIds: List<Int>?,
important: Boolean
): ApiResult<List<Int>, RestApiErrorDomain>
// suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain>
//
// suspend fun delete(
// params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain>
suspend fun delete(
peerId: Int,
messageIds: List<Int>?,
conversationMessageIds: List<Int>?,
spam: Boolean,
deleteForAll: Boolean
): ApiResult<List<Any>, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>)
//
// suspend fun edit(
// params: MessagesEditRequest
@@ -16,13 +16,15 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsReadRequest
import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
@@ -240,10 +242,40 @@ class MessagesRepositoryImpl(
override suspend fun unpin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesUnPinMessageRequest(peerId = peerId)
val requestModel = MessagesUnpinMessageRequest(peerId = peerId)
messagesService.unpin(requestModel.map).mapApiDefault()
}
override suspend fun markAsImportant(
peerId: Int,
messageIds: List<Int>?,
conversationMessageIds: List<Int>?,
important: Boolean
): ApiResult<List<Int>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesMarkAsImportantRequest(
messagesIds = messageIds.orEmpty(),
important = important
)
messagesService.markAsImportant(requestModel.map).mapApiDefault()
}
override suspend fun delete(
peerId: Int,
messageIds: List<Int>?,
conversationMessageIds: List<Int>?,
spam: Boolean,
deleteForAll: Boolean
): ApiResult<List<Any>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesDeleteRequest(
peerId = peerId,
messagesIds = messageIds,
conversationsMessagesIds = conversationMessageIds,
isSpam = spam,
deleteForAll = deleteForAll
)
messagesService.delete(requestModel.map).mapApiDefault()
}
override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity))
}
@@ -57,6 +57,19 @@ interface MessagesUseCase {
peerId: Int
): Flow<State<Int>>
fun markAsImportant(
peerId: Int,
messageIds: List<Int>,
important: Boolean
): Flow<State<List<Int>>>
fun delete(
peerId: Int,
messageIds: List<Int>,
spam: Boolean = false,
deleteForAll: Boolean = false
): Flow<State<List<Any>>>
suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>)
}
@@ -102,9 +102,7 @@ class MessagesUseCaseImpl(
override fun createChat(userIds: List<Int>?, title: String?): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.createChat(userIds, title).mapToState()
emit(newState)
}
@@ -126,8 +124,42 @@ class MessagesUseCaseImpl(
override fun unpin(peerId: Int): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.unpin(peerId = peerId).mapToState()
emit(newState)
}
override fun markAsImportant(
peerId: Int,
messageIds: List<Int>,
important: Boolean
): Flow<State<List<Int>>> = flow {
emit(State.Loading)
val newState = repository.markAsImportant(
peerId = peerId,
messageIds = messageIds,
conversationMessageIds = null,
important = important
).mapToState()
emit(newState)
}
override fun delete(
peerId: Int,
messageIds: List<Int>,
spam: Boolean,
deleteForAll: Boolean
): Flow<State<List<Any>>> = flow {
emit(State.Loading)
val newState = repository.delete(
peerId = peerId,
messageIds = messageIds,
conversationMessageIds = null,
spam = spam,
deleteForAll = deleteForAll
).mapToState()
emit(newState)
}
@@ -1,7 +1,9 @@
package dev.meloda.fast.model.api.domain
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.database.VkMessageEntity
@Immutable
data class VkMessage(
val id: Int,
val conversationMessageId: Int,
@@ -21,6 +23,7 @@ data class VkMessage(
val pinnedAt: Int?,
val isPinned: Boolean,
val isImportant: Boolean = false,
val isSpam: Boolean = false,
val forwards: List<VkMessage>?,
val attachments: List<VkAttachment>?,
@@ -55,6 +58,8 @@ data class VkMessage(
fun isUpdated(): Boolean = updateTime != null && updateTime > 0
fun isFailed(): Boolean = id <= -500_000
enum class Action(val value: String) {
CHAT_CREATE("chat_create"),
CHAT_PHOTO_UPDATE("chat_photo_update"),
@@ -119,7 +119,7 @@ data class MessagesPinMessageRequest(
}
data class MessagesUnPinMessageRequest(val peerId: Int) {
data class MessagesUnpinMessageRequest(val peerId: Int) {
val map: Map<String, String>
get() = mapOf("peer_id" to peerId.toString())
}
@@ -69,17 +69,17 @@ interface MessagesService {
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
// @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant(
// @FieldMap params: Map<String, String>
// ): ApiResult<ApiResponse<List<Int>>, RestApiError>
//
// @FormUrlEncoded
// @POST(MessagesUrls.Delete)
// suspend fun delete(
// @FieldMap params: Map<String, String>
// ): ApiResult<ApiResponse<Unit>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.MARK_AS_IMPORTANT)
suspend fun markAsImportant(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<List<Int>>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.DELETE)
suspend fun delete(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<List<Any>>, RestApiError>
//
// @FormUrlEncoded
// @POST(MessagesUrls.Edit)
@@ -110,13 +110,12 @@ fun MaterialDialog(
.verticalScroll(scrollState)
.onPlaced { isPlaced = true }
) {
Spacer(modifier = Modifier.height(8.dp))
if (text != null && title == null) {
Spacer(modifier = Modifier.height(20.dp))
}
if (text != null) {
Spacer(modifier = Modifier.height(8.dp))
Row {
Spacer(modifier = Modifier.width(24.dp))
Text(
@@ -137,8 +136,6 @@ fun MaterialDialog(
selectionType = selectionType,
items = alertItems,
onItemClick = { index ->
onItemClick?.invoke(index)
if (selectionType == SelectionType.None) {
onDismissRequest.invoke()
} else {
@@ -149,6 +146,8 @@ fun MaterialDialog(
alertItems = newItems
}
onItemClick?.invoke(index)
},
onItemCheckedChanged = { index ->
val newItems = alertItems.toMutableList()
@@ -161,11 +160,7 @@ fun MaterialDialog(
)
Spacer(modifier = Modifier.height(10.dp))
} else {
if (customContent != null) {
Spacer(modifier = Modifier.height(4.dp))
customContent.invoke(this)
Spacer(modifier = Modifier.height(10.dp))
}
customContent?.invoke(this)
}
}
@@ -183,7 +183,7 @@ fun AppTheme(
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = robotoFonts),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts)
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts),
)
}
@@ -65,3 +65,5 @@ class ImmutableList<T>(val values: List<T>) : Iterable<T> {
override fun iterator(): Iterator<T> = values.listIterator()
}
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15,20H5V7c0,-0.55 -0.45,-1 -1,-1h0C3.45,6 3,6.45 3,7v13c0,1.1 0.9,2 2,2h10c0.55,0 1,-0.45 1,-1v0C16,20.45 15.55,20 15,20zM20,16V4c0,-1.1 -0.9,-2 -2,-2H9C7.9,2 7,2.9 7,4v12c0,1.1 0.9,2 2,2h9C19.1,18 20,17.1 20,16zM18,16H9V4h9V16z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.46v3.04c0,0.28 0.22,0.5 0.5,0.5h3.04c0.13,0 0.26,-0.05 0.35,-0.15L17.81,9.94l-3.75,-3.75L3.15,17.1c-0.1,0.1 -0.15,0.22 -0.15,0.36zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>
@@ -1,9 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
android:pathData="M12,8V6.41c0,-0.89 1.08,-1.34 1.71,-0.71l5.59,5.59c0.39,0.39 0.39,1.02 0,1.41l-5.59,5.59c-0.63,0.63 -1.71,0.19 -1.71,-0.7V16H5c-0.55,0 -1,-0.45 -1,-1V9c0,-0.55 0.45,-1 1,-1h7z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.05,21.29c-0.39,0.39 -1.02,0.39 -1.41,0l-2.12,-2.12c-0.39,-0.39 -0.39,-1.02 0,-1.41h0c0.39,-0.39 1.02,-0.39 1.41,0l1.41,1.41l3.54,-3.54c0.39,-0.39 1.02,-0.39 1.41,0l0,0c0.39,0.39 0.39,1.02 0,1.41L18.05,21.29zM12.08,20H4c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2h16c1.1,0 2,0.9 2,2v6.68C21.09,12.25 20.08,12 19,12c-3.87,0 -7,3.13 -7,7C12,19.34 12.03,19.67 12.08,20zM11.47,12.67c0.32,0.2 0.74,0.2 1.06,0l7.07,-4.42C19.85,8.09 20,7.82 20,7.53c0,-0.67 -0.73,-1.07 -1.3,-0.72L12,11L5.3,6.81C4.73,6.46 4,6.86 4,7.53c0,0.29 0.15,0.56 0.4,0.72L11.47,12.67z" />
</vector>
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20.71,7.98L16.03,3.3c-0.19,-0.19 -0.45,-0.3 -0.71,-0.3H8.68c-0.26,0 -0.52,0.11 -0.7,0.29L3.29,7.98c-0.18,0.18 -0.29,0.44 -0.29,0.7v6.63c0,0.27 0.11,0.52 0.29,0.71l4.68,4.68c0.19,0.19 0.45,0.3 0.71,0.3h6.63c0.27,0 0.52,-0.11 0.71,-0.29l4.68,-4.68c0.19,-0.19 0.29,-0.44 0.29,-0.71V8.68c0.01,-0.26 -0.1,-0.52 -0.28,-0.7zM19,14.9L14.9,19H9.1L5,14.9V9.1L9.1,5h5.8L19,9.1v5.8z" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,16m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c-0.55,0 -1,0.45 -1,1v5c0,0.55 0.45,1 1,1s1,-0.45 1,-1V8c0,-0.55 -0.45,-1 -1,-1z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c0.55,0 1,0.45 1,1v1.33l7.2,7.2 0.51,-0.51c0.19,-0.19 0.29,-0.44 0.29,-0.71V8.68c0,-0.27 -0.11,-0.52 -0.29,-0.71l-4.68,-4.68c-0.19,-0.18 -0.45,-0.29 -0.71,-0.29H8.68c-0.26,0 -0.52,0.11 -0.7,0.29l-0.51,0.51 3.69,3.69c0.17,-0.29 0.48,-0.49 0.84,-0.49zM2.41,1.58L1,2.99l3.64,3.64 -1.35,1.35c-0.18,0.18 -0.29,0.44 -0.29,0.7v6.63c0,0.27 0.11,0.52 0.29,0.71l4.68,4.68c0.19,0.19 0.45,0.3 0.71,0.3h6.63c0.27,0 0.52,-0.11 0.71,-0.29l1.35,-1.35L21.01,23l1.41,-1.41L2.41,1.58zM12,17.3c-0.72,0 -1.3,-0.58 -1.3,-1.3 0,-0.72 0.58,-1.3 1.3,-1.3s1.3,0.58 1.3,1.3c0,0.72 -0.58,1.3 -1.3,1.3z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,17.27l4.15,2.51c0.76,0.46 1.69,-0.22 1.49,-1.08l-1.1,-4.72l3.67,-3.18c0.67,-0.58 0.31,-1.68 -0.57,-1.75l-4.83,-0.41l-1.89,-4.46c-0.34,-0.81 -1.5,-0.81 -1.84,0L9.19,8.63L4.36,9.04c-0.88,0.07 -1.24,1.17 -0.57,1.75l3.67,3.18l-1.1,4.72c-0.2,0.86 0.73,1.54 1.49,1.08L12,17.27z" />
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19.65,9.04l-4.84,-0.42 -1.89,-4.45c-0.34,-0.81 -1.5,-0.81 -1.84,0L9.19,8.63l-4.83,0.41c-0.88,0.07 -1.24,1.17 -0.57,1.75l3.67,3.18 -1.1,4.72c-0.2,0.86 0.73,1.54 1.49,1.08l4.15,-2.5 4.15,2.51c0.76,0.46 1.69,-0.22 1.49,-1.08l-1.1,-4.73 3.67,-3.18c0.67,-0.58 0.32,-1.68 -0.56,-1.75zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
</vector>
@@ -5,7 +5,9 @@
<string name="sign_out_confirm">При выходе из учётной записи с устройства будут удалены все связанные с ней данные. Продолжить?</string>
<string name="yes">Да</string>
<string name="no">Нет</string>
<string name="message_context_action_retry">Повторить</string>
<string name="message_context_action_reply">Ответить</string>
<string name="message_context_action_forward_here">Переслать сюда</string>
<string name="message_context_action_forward">Переслать</string>
<string name="message_context_action_mark_as_important">Пометить как важное</string>
<string name="message_context_action_unmark_as_important">Пометить как не важное</string>
@@ -15,6 +17,7 @@
<string name="message_context_action_unpin">Открепить</string>
<string name="message_context_action_edit">Изменить</string>
<string name="message_context_action_delete">Удалить</string>
<string name="message_context_action_read">Прочитать</string>
<string name="message_context_action_copy">Скопировать</string>
<string name="confirm_delete_message">Удалить сообщение?</string>
<string name="message_delete_for_all">Для всех</string>
@@ -31,6 +34,8 @@
<string name="confirm_pin_conversation">Закрепить чат?</string>
<string name="action_pin">Закрепить</string>
<string name="action_unpin">Открепить</string>
<string name="action_mark">Пометить</string>
<string name="action_unmark">Убрать пометку</string>
<string name="message_call_type_outgoing">Исходящий вызов</string>
<string name="message_call_type_incoming">Входящий вызов</string>
<string name="message_call_state_ended">Закончился</string>
@@ -233,4 +238,19 @@
<string name="chat_attachment_files">Файлы</string>
<string name="chat_attachment_links">Ссылки</string>
<string name="message_context_action_mark_as_spam">Пометить как спам</string>
<string name="pin_message_text">Вы уверены, что хотите закрепить это сообщение? Это изменение увидят все участники чата.</string>
<string name="unpin_message_title">Открепить сообщение</string>
<string name="unpin_message_text">Вы уверены, что хотите открепить это сообщение? Все участники чата увидят это изменение.</string>
<string name="delete_message_title">Удалить сообщение?</string>
<string name="delete_message_for_everyone">Для всех</string>
<string name="important_message_title">Пометить как важное</string>
<string name="important_message_text">Вы уверены, что хотите пометить это сообщение как важное?</string>
<string name="unimportant_message_text">Вы уверены, что хотите убрать пометку избранного у этого сообщения?</string>
<string name="spam_message_title">Пометить как спам</string>
<string name="spam_message_text">Вы уверены, что хотите пометить это сообщение как спам?</string>
<string name="unimportant_message_title">Убрать пометку избранного</string>
<string name="unspam_message_title">Убрать пометку спама</string>
<string name="unspam_message_text">Вы уверены, что хотите убрать пометку спама у этого сообщения?</string>
<string name="pin_message_title">Закрепить сообщение</string>
<string name="copied_to_clipboard">Скопировано в буфер обмена</string>
</resources>
+27
View File
@@ -126,7 +126,9 @@
<string name="no">No</string>
<string name="time_format">Time: %1$s</string>
<string name="message_context_action_retry">Retry</string>
<string name="message_context_action_reply">Reply</string>
<string name="message_context_action_forward_here">Forward here</string>
<string name="message_context_action_forward">Forward</string>
<string name="message_context_action_mark_as_important">Mark as important</string>
<string name="message_context_action_unmark_as_important">Unmark as important</string>
@@ -136,6 +138,7 @@
<string name="message_context_action_unpin">Unpin</string>
<string name="message_context_action_edit">Edit</string>
<string name="message_context_action_delete">Delete</string>
<string name="message_context_action_read">Read</string>
<string name="message_context_action_copy">Copy</string>
<string name="confirm_delete_message">Delete the message?</string>
@@ -156,6 +159,8 @@
<string name="confirm_pin_conversation">Pin the conversation?</string>
<string name="action_pin">Pin</string>
<string name="action_unpin">Unpin</string>
<string name="action_mark">Mark</string>
<string name="action_unmark">Unmark</string>
<string name="message_call_type_outgoing">Outgoing call</string>
<string name="message_call_type_incoming">Incoming call</string>
<string name="message_call_state_ended">Ended</string>
@@ -301,4 +306,26 @@
<string name="chat_attachment_music">Music</string>
<string name="chat_attachment_files">Files</string>
<string name="chat_attachment_links">Links</string>
<string name="pin_message_title">Pin message</string>
<string name="pin_message_text">Are you sure you want to pin this message? All chat members will see this change.</string>
<string name="unpin_message_title">Unpin message</string>
<string name="unpin_message_text">Are you sure you want to unpin this message? All chat members will see this change.</string>
<string name="delete_message_title">Delete the message?</string>
<string name="delete_message_for_everyone">For everyone</string>
<string name="important_message_title">Mark as important</string>
<string name="important_message_text">Are you sure you want to mark this message as important?</string>
<string name="unimportant_message_title">Unmark as important</string>
<string name="unimportant_message_text">Are you sure you want to unmark this message as important?</string>
<string name="spam_message_title">Mark as spam</string>
<string name="spam_message_text">Are you sure you want to mark this message as spam?</string>
<string name="unspam_message_title">Unmark as spam</string>
<string name="unspam_message_text">Are you sure you want to unmark this message as spam?</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
</resources>
@@ -286,7 +286,7 @@ fun ConversationsScreen(
}
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
painter = painterResource(id = UiR.drawable.round_create_24),
contentDescription = "Add chat button"
)
}
@@ -0,0 +1,21 @@
package dev.meloda.fast.messageshistory.model
import dev.meloda.fast.model.api.domain.VkMessage
sealed class MessageDialog {
data class MessageOptions(val message: VkMessage) : MessageDialog()
data class MessagePin(val messageId: Int) : MessageDialog()
data class MessageUnpin(val messageId: Int) : MessageDialog()
data class MessageDelete(val message: VkMessage) : MessageDialog()
data class MessagesDelete(val messages: List<VkMessage>) : MessageDialog()
data class MessageSpam(
val message: VkMessage,
val isSpam: Boolean
) : MessageDialog()
data class MessageMarkImportance(
val message: VkMessage,
val isImportant: Boolean
) : MessageDialog()
}
@@ -0,0 +1,84 @@
package dev.meloda.fast.messageshistory.model
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import dev.meloda.fast.ui.R
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class MessageOption(
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int
) : Parcelable {
data object Retry : MessageOption(
titleResId = R.string.message_context_action_retry,
iconResId = R.drawable.round_restart_alt_24
)
data object Reply : MessageOption(
titleResId = R.string.message_context_action_reply,
iconResId = R.drawable.round_reply_24
)
data object ForwardHere : MessageOption(
titleResId = R.string.message_context_action_forward_here,
iconResId = R.drawable.round_reply_all_24
)
data object Forward : MessageOption(
titleResId = R.string.message_context_action_forward,
iconResId = R.drawable.round_forward_24
)
data object Pin : MessageOption(
titleResId = R.string.message_context_action_pin,
iconResId = R.drawable.pin_outline_24
)
data object Unpin : MessageOption(
titleResId = R.string.message_context_action_unpin,
iconResId = R.drawable.pin_off_outline_24
)
data object Read : MessageOption(
titleResId = R.string.message_context_action_read,
iconResId = R.drawable.round_mark_email_read_24
)
data object Copy : MessageOption(
titleResId = R.string.message_context_action_copy,
iconResId = R.drawable.round_content_copy_24
)
data object MarkAsImportant : MessageOption(
titleResId = R.string.message_context_action_mark_as_important,
iconResId = R.drawable.round_star_24
)
data object UnmarkAsImportant : MessageOption(
titleResId = R.string.message_context_action_unmark_as_important,
iconResId = R.drawable.round_star_outline_24
)
data object MarkAsSpam : MessageOption(
titleResId = R.string.message_context_action_mark_as_spam,
iconResId = R.drawable.round_report_gmailerrorred_24
)
data object UnmarkAsSpam : MessageOption(
titleResId = R.string.message_context_action_unmark_as_spam,
iconResId = R.drawable.round_report_off_24
)
data object Edit : MessageOption(
titleResId = R.string.message_context_action_edit,
iconResId = R.drawable.round_create_24
)
data object Delete : MessageOption(
titleResId = R.string.message_context_action_delete,
iconResId = R.drawable.round_delete_outline_24
)
}
@@ -14,7 +14,6 @@ data class MessagesHistoryScreenState(
val title: String,
val status: String?,
val avatar: UiImage,
val messages: List<UiItem>,
val message: TextFieldValue,
val attachments: List<VkAttachment>,
val isLoading: Boolean,
@@ -34,7 +33,6 @@ data class MessagesHistoryScreenState(
title = "",
status = null,
avatar = UiImage.Color(0),
messages = emptyList(),
message = TextFieldValue(),
attachments = emptyList(),
isLoading = true,
@@ -26,7 +26,8 @@ sealed class UiItem(
val isRead: Boolean,
val sendingStatus: SendingStatus,
val isSelected: Boolean,
val isPinned: Boolean
val isPinned: Boolean,
val isImportant: Boolean
) : UiItem(id, conversationMessageId)
data class ActionMessage(
@@ -90,7 +90,9 @@ fun IncomingMessageBubble(
edited = message.isEdited,
isRead = message.isRead,
sendingStatus = message.sendingStatus,
pinned = message.isPinned
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
)
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
@@ -20,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -42,7 +44,9 @@ fun MessageBubble(
edited: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus,
pinned: Boolean
pinned: Boolean,
important: Boolean,
isSelected: Boolean
) {
val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) {
@@ -68,12 +72,15 @@ fun MessageBubble(
)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
val minDateContainerWidth by remember(edited, isOut, pinned, important) {
derivedStateOf {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
val importantIndicatorPart = if (important) 14.dp else 0.dp
mainPart + readIndicatorPart + pinnedIndicatorPart
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
}
}
val dateContainerWidth by animateDpAsState(
@@ -82,17 +89,29 @@ fun MessageBubble(
)
if (text != null) {
Text(
text = text,
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
color = textColor
)
val textLambda: @Composable () -> Unit = remember {
{
Text(
text = text,
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
color = textColor
)
}
}
if (isSelected) {
SelectionContainer {
textLambda.invoke()
}
} else {
textLambda.invoke()
}
}
Row(
@@ -101,6 +120,14 @@ fun MessageBubble(
.defaultMinSize(minWidth = dateContainerWidth)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
if (important) {
Icon(
painter = painterResource(UiR.drawable.round_star_24),
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
if (pinned) {
Icon(
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
@@ -119,6 +146,7 @@ fun MessageBubble(
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall,
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -39,6 +40,7 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -53,6 +55,7 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -64,7 +67,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
@@ -74,12 +76,12 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
@@ -93,22 +95,28 @@ import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessageOption
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.util.firstMessage
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import java.util.concurrent.TimeUnit
import dev.meloda.fast.ui.R as UiR
@Composable
@@ -119,10 +127,12 @@ fun MessagesHistoryRoute(
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val messages by viewModel.messages.collectAsStateWithLifecycle()
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
val messageDialog by viewModel.messageDialog.collectAsStateWithLifecycle()
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val showMessageOptions by viewModel.showMessageOptions.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
@@ -130,8 +140,10 @@ fun MessagesHistoryRoute(
MessagesHistoryScreen(
screenState = screenState,
messages = messages.toImmutableList(),
uiMessages = uiMessages.toImmutableList(),
scrollIndex = scrollIndex,
selectedMessages = ImmutableList.copyOf(selectedMessages),
selectedMessages = selectedMessages.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
showEmojiButton = showEmojiButton,
@@ -149,43 +161,300 @@ fun MessagesHistoryRoute(
onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
)
if (showMessageOptions != null) {
val message = showMessageOptions!!
HandleDialogs(
screenState = screenState,
messageDialog = messageDialog,
onConfirmed = viewModel::onDialogConfirmed,
onCancelled = viewModel::onDialogCancelled,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
val messageOptions = mutableListOf(
stringResource(UiR.string.message_context_action_reply),
stringResource(UiR.string.message_context_action_forward)
)
@Composable
fun HandleDialogs(
screenState: MessagesHistoryScreenState,
messageDialog: MessageDialog?,
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
onCancelled: (MessageDialog) -> Unit = {},
onDismissed: (MessageDialog) -> Unit = {},
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
) {
when (messageDialog) {
null -> Unit
if (message.isPeerChat() && screenState.conversation.canChangePin) {
messageOptions += stringResource(
if (message.isPinned) UiR.string.message_context_action_unpin
else UiR.string.message_context_action_pin
is MessageDialog.MessageOptions -> {
MessageOptionsDialog(
screenState = screenState,
message = messageDialog.message,
onDismissed = { onDismissed(messageDialog) },
onItemPicked = { bundle -> onItemPicked(messageDialog, bundle) }
)
}
messageOptions += stringResource(UiR.string.message_context_action_copy)
messageOptions += stringResource(
if (message.isImportant) UiR.string.message_context_action_unmark_as_important
else UiR.string.message_context_action_mark_as_important
)
is MessageDialog.MessageDelete -> {
MessageDeleteDialog(
messages = listOf(messageDialog.message),
onConfirmed = { onConfirmed(messageDialog, it) },
onDismissed = { onDismissed(messageDialog) }
)
}
// if (!message.isOut) {
// messageOptions += "Mark as spam"
// }
is MessageDialog.MessagesDelete -> {
MessageDeleteDialog(
messages = messageDialog.messages,
onConfirmed = { onConfirmed(messageDialog, it) },
onDismissed = { onDismissed(messageDialog) }
)
}
messageOptions += stringResource(UiR.string.message_context_action_delete)
is MessageDialog.MessagePin,
is MessageDialog.MessageUnpin -> {
MessagePinStateDialog(
pin = messageDialog is MessageDialog.MessagePin,
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
onDismissed = { onDismissed(messageDialog) }
)
}
MaterialDialog(
onDismissRequest = viewModel::onMessageOptionsDialogDismissed,
selectionType = SelectionType.None,
items = ImmutableList.copyOf(messageOptions),
confirmText = stringResource(UiR.string.ok)
is MessageDialog.MessageMarkImportance -> {
MessageImportanceDialog(
important = messageDialog.isImportant,
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
onDismissed = { onDismissed(messageDialog) }
)
}
is MessageDialog.MessageSpam -> {
MessageSpamDialog(
spam = messageDialog.isSpam,
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
onDismissed = { onDismissed(messageDialog) }
)
}
}
}
@Composable
fun MessageOptionsDialog(
screenState: MessagesHistoryScreenState,
message: VkMessage,
onDismissed: () -> Unit = {},
onItemPicked: (Bundle) -> Unit
) {
val options = mutableListOf<MessageOption>()
if (message.isFailed()) {
options += MessageOption.Retry
} else {
options += MessageOption.Reply
options += MessageOption.ForwardHere
options += MessageOption.Forward
if (message.isPeerChat() && screenState.conversation.canChangePin) {
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
}
if (!message.isRead(screenState.conversation)) {
options += MessageOption.Read
}
options += MessageOption.Copy
if (message.isOut) {
val diff = System.currentTimeMillis() - message.date * 1000L
if (diff - TimeUnit.DAYS.toMillis(1) <= 0) {
options += MessageOption.Edit
}
}
options += if (message.isImportant) MessageOption.UnmarkAsImportant
else MessageOption.MarkAsImportant
if (!message.isOut) {
options += if (message.isSpam) MessageOption.UnmarkAsSpam
else MessageOption.MarkAsSpam
}
}
options += MessageOption.Delete
val messageOptions = options.map { option ->
Triple(
stringResource(option.titleResId),
painterResource(option.iconResId),
when {
option in listOf(
MessageOption.Delete,
MessageOption.MarkAsSpam
) -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
)
}
MaterialDialog(onDismissRequest = onDismissed) {
messageOptions
.forEachIndexed { index, (title, painter, tintColor) ->
DropdownMenuItem(
text = {
Row {
Text(text = title)
Spacer(modifier = Modifier.width(8.dp))
}
},
leadingIcon = {
Row {
Spacer(modifier = Modifier.width(8.dp))
Icon(
painter = painter,
contentDescription = null,
tint = tintColor
)
}
},
onClick = {
onDismissed()
val pickedOption = options[index]
onItemPicked(bundleOf("option" to pickedOption))
}
)
}
}
}
@Composable
fun MessageDeleteDialog(
messages: List<VkMessage>,
onConfirmed: (Bundle) -> Unit = {},
onDismissed: () -> Unit = {},
) {
var forEveryone by remember {
mutableStateOf(messages.all(VkMessage::isOut))
}
val shouldBeDisabled by remember(messages) {
mutableStateOf(messages.any(VkMessage::isFailed) || !messages.all(VkMessage::isOut))
}
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(UiR.string.delete_message_title),
confirmText = stringResource(UiR.string.action_delete),
confirmAction = {
onConfirmed(
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
)
},
cancelText = stringResource(UiR.string.cancel),
) {
Row(
modifier = Modifier
.then(
if (!shouldBeDisabled) {
Modifier.clickable { forEveryone = !forEveryone }
} else Modifier)
.fillMaxWidth()
.minimumInteractiveComponentSize()
.padding(start = 24.dp, end = 16.dp)
) {
Checkbox(
checked = forEveryone,
onCheckedChange = null,
enabled = !shouldBeDisabled
)
Spacer(modifier = Modifier.width(8.dp))
LocalContentAlpha(
alpha = if (shouldBeDisabled) ContentAlpha.disabled
else ContentAlpha.high
) {
Text(text = stringResource(UiR.string.delete_message_for_everyone))
}
}
}
}
@Composable
fun MessagePinStateDialog(
pin: Boolean,
onConfirmed: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(
if (pin) UiR.string.pin_message_title
else UiR.string.unpin_message_title
),
text = stringResource(
if (pin) UiR.string.pin_message_text
else UiR.string.unpin_message_text
),
confirmText = stringResource(
if (pin) UiR.string.action_pin
else UiR.string.action_unpin
),
confirmAction = onConfirmed,
cancelText = stringResource(UiR.string.cancel)
)
}
@Composable
fun MessageImportanceDialog(
important: Boolean,
onConfirmed: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(
if (important) UiR.string.important_message_title
else UiR.string.unimportant_message_title
),
text = stringResource(
if (important) UiR.string.important_message_text
else UiR.string.unimportant_message_text
),
confirmText = stringResource(
if (important) UiR.string.action_mark
else UiR.string.action_unmark
),
confirmAction = onConfirmed,
cancelText = stringResource(UiR.string.cancel)
)
}
@Composable
fun MessageSpamDialog(
spam: Boolean,
onConfirmed: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(
if (spam) UiR.string.spam_message_title
else UiR.string.unspam_message_title
),
text = stringResource(
if (spam) UiR.string.spam_message_text
else UiR.string.unspam_message_text
),
confirmText = stringResource(
if (spam) UiR.string.action_mark
else UiR.string.action_unmark
),
confirmAction = onConfirmed,
cancelText = stringResource(UiR.string.cancel)
)
}
@OptIn(
@@ -196,8 +465,10 @@ fun MessagesHistoryRoute(
@Composable
fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
messages: ImmutableList<VkMessage> = emptyImmutableList(),
uiMessages: ImmutableList<UiItem> = emptyImmutableList(),
scrollIndex: Int? = null,
selectedMessages: ImmutableList<Int> = ImmutableList.empty(),
selectedMessages: ImmutableList<VkMessage> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
@@ -215,7 +486,8 @@ fun MessagesHistoryScreen(
onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {},
onPinnedMessageClicked: (Int) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val coroutineScope = rememberCoroutineScope()
@@ -288,8 +560,8 @@ fun MessagesHistoryScreen(
val density = LocalDensity.current
val showReplyAction by remember(screenState) {
mutableStateOf(selectedMessages.size == 1)
val showReplyAction by remember(selectedMessages) {
derivedStateOf { selectedMessages.size == 1 }
}
Scaffold(
@@ -414,6 +686,12 @@ fun MessagesHistoryScreen(
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_forward_24),
contentDescription = null
)
}
IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon(
painter = painterResource(UiR.drawable.round_delete_outline_24),
contentDescription = null
@@ -449,7 +727,7 @@ fun MessagesHistoryScreen(
// TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId
uiMessages.values.firstMessage().conversationMessageId
)
},
text = {
@@ -483,7 +761,7 @@ fun MessagesHistoryScreen(
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() }
derivedStateOf { screenState.isLoading && messages.isNotEmpty() }
}
if (showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -493,51 +771,15 @@ fun MessagesHistoryScreen(
}
if (!screenState.isLoading && pinnedMessage != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onPinnedMessageClicked(pinnedMessage!!.id) }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.rotate(45f)
.alpha(0.5f),
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
contentDescription = null
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = screenState.pinnedTitle.orDots(),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
screenState.pinnedSummary?.let { summary ->
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(text = summary)
}
}
}
if (screenState.conversation.canChangePin) {
Spacer(modifier = Modifier.width(16.dp))
IconButton(onClick = onUnpinMessageButtonClicked) {
Icon(
modifier = Modifier.alpha(0.5f),
imageVector = Icons.Rounded.Close,
contentDescription = null
)
}
}
}
PinnedMessageContainer(
modifier = Modifier,
pinnedMessage = requireNotNull(pinnedMessage),
title = screenState.pinnedTitle.orDots(),
summary = screenState.pinnedSummary,
canChangePin = screenState.conversation.canChangePin,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
)
HorizontalDivider()
}
}
@@ -551,16 +793,17 @@ fun MessagesHistoryScreen(
.padding(bottom = padding.calculateBottomPadding()),
) {
MessagesList(
modifier = Modifier.align(Alignment.BottomStart),
hazeState = hazeState,
listState = listState,
hasPinnedMessage = pinnedMessage != null,
immutableMessages = ImmutableList.copyOf(screenState.messages),
uiMessages = uiMessages,
isPaginating = screenState.isPaginating,
messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId ->
coroutineScope.launch {
listState.animateScrollToItem(
index = screenState.messages.indexOfMessageByCmId(cmId)
index = uiMessages.values.indexOfMessageByCmId(cmId)
)
}
},
@@ -775,7 +1018,7 @@ fun MessagesHistoryScreen(
}
when {
screenState.isLoading && screenState.messages.isEmpty() -> {
screenState.isLoading && messages.values.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
@@ -42,15 +42,15 @@ fun MessagesList(
hasPinnedMessage: Boolean,
hazeState: HazeState,
listState: LazyListState,
immutableMessages: ImmutableList<UiItem>,
uiMessages: ImmutableList<UiItem>,
isPaginating: Boolean,
messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {},
onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {}
) {
val messages = remember(immutableMessages) {
immutableMessages.toList()
val messages = remember(uiMessages) {
uiMessages.toList()
}
val theme = LocalThemeConfig.current
val view = LocalView.current
@@ -44,7 +44,9 @@ fun OutgoingMessageBubble(
edited = message.isEdited,
isRead = message.isRead,
sendingStatus = message.sendingStatus,
pinned = message.isPinned
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
)
}
}
@@ -0,0 +1,88 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.IconButton
@Composable
fun PinnedMessageContainer(
modifier: Modifier = Modifier,
pinnedMessage: VkMessage,
title: String,
summary: AnnotatedString?,
canChangePin: Boolean,
onPinnedMessageClicked: (Int) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onPinnedMessageClicked(pinnedMessage.id) }
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.rotate(45f)
.alpha(0.5f),
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
summary?.let { summary ->
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(text = summary)
}
}
}
if (canChangePin) {
Spacer(modifier = Modifier.width(16.dp))
IconButton(onClick = onUnpinMessageButtonClicked) {
Icon(
modifier = Modifier.alpha(0.5f),
imageVector = Icons.Rounded.Close,
contentDescription = null
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
@@ -96,7 +96,8 @@ fun VkMessage.asPresentation(
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean
showTimeInActionMessages: Boolean,
isSelected: Boolean
): UiItem = when {
action != null -> UiItem.ActionMessage(
id = id,
@@ -126,11 +127,13 @@ fun VkMessage.asPresentation(
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = false,
isPinned = isPinned
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant
)
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -139,8 +140,11 @@ fun EditTextAlert(
cancelText = stringResource(id = R.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always
) {
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(20.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
TextField(
modifier = Modifier
.fillMaxWidth()
@@ -155,8 +159,8 @@ fun EditTextAlert(
placeholder = { Text(text = "Value") },
shape = RoundedCornerShape(10.dp),
)
Spacer(modifier = Modifier.width(20.dp))
}
Spacer(modifier = Modifier.height(8.dp))
}
LaunchedEffect(Unit) {