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) { when (this) {
is State.Error -> { is State.Error -> {
error(this)
any() any()
error(this)
} }
State.Idle -> idle() State.Idle -> idle()
@@ -55,8 +55,8 @@ inline fun <T> State<T>.processState(
State.Loading -> loading() State.Loading -> loading()
is State.Success -> { is State.Success -> {
success(data)
any() any()
success(data)
} }
} }
} }
@@ -56,15 +56,22 @@ interface MessagesRepository {
peerId: Int peerId: Int
): ApiResult<Int, RestApiErrorDomain> ): 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( suspend fun delete(
// params: MessagesMarkAsImportantRequest peerId: Int,
// ): ApiResult<List<Int>, RestApiErrorDomain> messageIds: List<Int>?,
// conversationMessageIds: List<Int>?,
// suspend fun delete( spam: Boolean,
// params: MessagesDeleteRequest deleteForAll: Boolean
// ): ApiResult<Unit, RestApiErrorDomain> ): ApiResult<List<Any>, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>)
// //
// suspend fun edit( // suspend fun edit(
// params: MessagesEditRequest // 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.VkMessage
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest 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.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest 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.MessagesMarkAsReadRequest
import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest 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.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
@@ -240,10 +242,40 @@ class MessagesRepositoryImpl(
override suspend fun unpin( override suspend fun unpin(
peerId: Int peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesUnPinMessageRequest(peerId = peerId) val requestModel = MessagesUnpinMessageRequest(peerId = peerId)
messagesService.unpin(requestModel.map).mapApiDefault() 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>) { override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
@@ -57,6 +57,19 @@ interface MessagesUseCase {
peerId: Int peerId: Int
): Flow<State<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 storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<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 { override fun createChat(userIds: List<Int>?, title: String?): Flow<State<Int>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = repository.createChat(userIds, title).mapToState() val newState = repository.createChat(userIds, title).mapToState()
emit(newState) emit(newState)
} }
@@ -126,8 +124,42 @@ class MessagesUseCaseImpl(
override fun unpin(peerId: Int): Flow<State<Int>> = flow { override fun unpin(peerId: Int): Flow<State<Int>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = repository.unpin(peerId = peerId).mapToState() 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) emit(newState)
} }
@@ -1,7 +1,9 @@
package dev.meloda.fast.model.api.domain package dev.meloda.fast.model.api.domain
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.database.VkMessageEntity import dev.meloda.fast.model.database.VkMessageEntity
@Immutable
data class VkMessage( data class VkMessage(
val id: Int, val id: Int,
val conversationMessageId: Int, val conversationMessageId: Int,
@@ -21,6 +23,7 @@ data class VkMessage(
val pinnedAt: Int?, val pinnedAt: Int?,
val isPinned: Boolean, val isPinned: Boolean,
val isImportant: Boolean = false, val isImportant: Boolean = false,
val isSpam: Boolean = false,
val forwards: List<VkMessage>?, val forwards: List<VkMessage>?,
val attachments: List<VkAttachment>?, val attachments: List<VkAttachment>?,
@@ -55,6 +58,8 @@ data class VkMessage(
fun isUpdated(): Boolean = updateTime != null && updateTime > 0 fun isUpdated(): Boolean = updateTime != null && updateTime > 0
fun isFailed(): Boolean = id <= -500_000
enum class Action(val value: String) { enum class Action(val value: String) {
CHAT_CREATE("chat_create"), CHAT_CREATE("chat_create"),
CHAT_PHOTO_UPDATE("chat_photo_update"), 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> val map: Map<String, String>
get() = mapOf("peer_id" to peerId.toString()) get() = mapOf("peer_id" to peerId.toString())
} }
@@ -69,17 +69,17 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError> ): ApiResult<ApiResponse<Int>, RestApiError>
// @FormUrlEncoded @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant) @POST(MessagesUrls.MARK_AS_IMPORTANT)
// suspend fun markAsImportant( suspend fun markAsImportant(
// @FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
// ): ApiResult<ApiResponse<List<Int>>, RestApiError> ): ApiResult<ApiResponse<List<Int>>, RestApiError>
//
// @FormUrlEncoded @FormUrlEncoded
// @POST(MessagesUrls.Delete) @POST(MessagesUrls.DELETE)
// suspend fun delete( suspend fun delete(
// @FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
// ): ApiResult<ApiResponse<Unit>, RestApiError> ): ApiResult<ApiResponse<List<Any>>, RestApiError>
// //
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.Edit) // @POST(MessagesUrls.Edit)
@@ -110,13 +110,12 @@ fun MaterialDialog(
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onPlaced { isPlaced = true } .onPlaced { isPlaced = true }
) { ) {
Spacer(modifier = Modifier.height(8.dp))
if (text != null && title == null) { if (text != null && title == null) {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
} }
if (text != null) { if (text != null) {
Spacer(modifier = Modifier.height(8.dp))
Row { Row {
Spacer(modifier = Modifier.width(24.dp)) Spacer(modifier = Modifier.width(24.dp))
Text( Text(
@@ -137,8 +136,6 @@ fun MaterialDialog(
selectionType = selectionType, selectionType = selectionType,
items = alertItems, items = alertItems,
onItemClick = { index -> onItemClick = { index ->
onItemClick?.invoke(index)
if (selectionType == SelectionType.None) { if (selectionType == SelectionType.None) {
onDismissRequest.invoke() onDismissRequest.invoke()
} else { } else {
@@ -149,6 +146,8 @@ fun MaterialDialog(
alertItems = newItems alertItems = newItems
} }
onItemClick?.invoke(index)
}, },
onItemCheckedChanged = { index -> onItemCheckedChanged = { index ->
val newItems = alertItems.toMutableList() val newItems = alertItems.toMutableList()
@@ -161,11 +160,7 @@ fun MaterialDialog(
) )
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
} else { } else {
if (customContent != null) { customContent?.invoke(this)
Spacer(modifier = Modifier.height(4.dp))
customContent.invoke(this)
Spacer(modifier = Modifier.height(10.dp))
}
} }
} }
@@ -183,7 +183,7 @@ fun AppTheme(
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts), bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts), labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts),
labelMedium = MaterialTheme.typography.labelMedium.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() 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" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillColor="@android:color/white" 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> </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="sign_out_confirm">При выходе из учётной записи с устройства будут удалены все связанные с ней данные. Продолжить?</string>
<string name="yes">Да</string> <string name="yes">Да</string>
<string name="no">Нет</string> <string name="no">Нет</string>
<string name="message_context_action_retry">Повторить</string>
<string name="message_context_action_reply">Ответить</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_forward">Переслать</string>
<string name="message_context_action_mark_as_important">Пометить как важное</string> <string name="message_context_action_mark_as_important">Пометить как важное</string>
<string name="message_context_action_unmark_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_unpin">Открепить</string>
<string name="message_context_action_edit">Изменить</string> <string name="message_context_action_edit">Изменить</string>
<string name="message_context_action_delete">Удалить</string> <string name="message_context_action_delete">Удалить</string>
<string name="message_context_action_read">Прочитать</string>
<string name="message_context_action_copy">Скопировать</string> <string name="message_context_action_copy">Скопировать</string>
<string name="confirm_delete_message">Удалить сообщение?</string> <string name="confirm_delete_message">Удалить сообщение?</string>
<string name="message_delete_for_all">Для всех</string> <string name="message_delete_for_all">Для всех</string>
@@ -31,6 +34,8 @@
<string name="confirm_pin_conversation">Закрепить чат?</string> <string name="confirm_pin_conversation">Закрепить чат?</string>
<string name="action_pin">Закрепить</string> <string name="action_pin">Закрепить</string>
<string name="action_unpin">Открепить</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_outgoing">Исходящий вызов</string>
<string name="message_call_type_incoming">Входящий вызов</string> <string name="message_call_type_incoming">Входящий вызов</string>
<string name="message_call_state_ended">Закончился</string> <string name="message_call_state_ended">Закончился</string>
@@ -233,4 +238,19 @@
<string name="chat_attachment_files">Файлы</string> <string name="chat_attachment_files">Файлы</string>
<string name="chat_attachment_links">Ссылки</string> <string name="chat_attachment_links">Ссылки</string>
<string name="message_context_action_mark_as_spam">Пометить как спам</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> </resources>
+27
View File
@@ -126,7 +126,9 @@
<string name="no">No</string> <string name="no">No</string>
<string name="time_format">Time: %1$s</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_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_forward">Forward</string>
<string name="message_context_action_mark_as_important">Mark as important</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> <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_unpin">Unpin</string>
<string name="message_context_action_edit">Edit</string> <string name="message_context_action_edit">Edit</string>
<string name="message_context_action_delete">Delete</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="message_context_action_copy">Copy</string>
<string name="confirm_delete_message">Delete the message?</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="confirm_pin_conversation">Pin the conversation?</string>
<string name="action_pin">Pin</string> <string name="action_pin">Pin</string>
<string name="action_unpin">Unpin</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_outgoing">Outgoing call</string>
<string name="message_call_type_incoming">Incoming call</string> <string name="message_call_type_incoming">Incoming call</string>
<string name="message_call_state_ended">Ended</string> <string name="message_call_state_ended">Ended</string>
@@ -301,4 +306,26 @@
<string name="chat_attachment_music">Music</string> <string name="chat_attachment_music">Music</string>
<string name="chat_attachment_files">Files</string> <string name="chat_attachment_files">Files</string>
<string name="chat_attachment_links">Links</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> </resources>
@@ -286,7 +286,7 @@ fun ConversationsScreen(
} }
) { ) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24), painter = painterResource(id = UiR.drawable.round_create_24),
contentDescription = "Add chat button" 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 title: String,
val status: String?, val status: String?,
val avatar: UiImage, val avatar: UiImage,
val messages: List<UiItem>,
val message: TextFieldValue, val message: TextFieldValue,
val attachments: List<VkAttachment>, val attachments: List<VkAttachment>,
val isLoading: Boolean, val isLoading: Boolean,
@@ -34,7 +33,6 @@ data class MessagesHistoryScreenState(
title = "", title = "",
status = null, status = null,
avatar = UiImage.Color(0), avatar = UiImage.Color(0),
messages = emptyList(),
message = TextFieldValue(), message = TextFieldValue(),
attachments = emptyList(), attachments = emptyList(),
isLoading = true, isLoading = true,
@@ -26,7 +26,8 @@ sealed class UiItem(
val isRead: Boolean, val isRead: Boolean,
val sendingStatus: SendingStatus, val sendingStatus: SendingStatus,
val isSelected: Boolean, val isSelected: Boolean,
val isPinned: Boolean val isPinned: Boolean,
val isImportant: Boolean
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
data class ActionMessage( data class ActionMessage(
@@ -90,7 +90,9 @@ fun IncomingMessageBubble(
edited = message.isEdited, edited = message.isEdited,
isRead = message.isRead, isRead = message.isRead,
sendingStatus = message.sendingStatus, 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.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -20,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -42,7 +44,9 @@ fun MessageBubble(
edited: Boolean, edited: Boolean,
isRead: Boolean, isRead: Boolean,
sendingStatus: SendingStatus, sendingStatus: SendingStatus,
pinned: Boolean pinned: Boolean,
important: Boolean,
isSelected: Boolean
) { ) {
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) { val backgroundColor = if (!isOut) {
@@ -68,12 +72,15 @@ fun MessageBubble(
) )
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) { ) {
val minDateContainerWidth = remember(edited, isOut) { val minDateContainerWidth by remember(edited, isOut, pinned, important) {
derivedStateOf {
val mainPart = if (edited) 50.dp else 30.dp val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp val readIndicatorPart = if (isOut) 14.dp else 0.dp
val pinnedIndicatorPart = if (pinned) 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( val dateContainerWidth by animateDpAsState(
@@ -82,6 +89,8 @@ fun MessageBubble(
) )
if (text != null) { if (text != null) {
val textLambda: @Composable () -> Unit = remember {
{
Text( Text(
text = text, text = text,
modifier = Modifier modifier = Modifier
@@ -94,6 +103,16 @@ fun MessageBubble(
color = textColor color = textColor
) )
} }
}
if (isSelected) {
SelectionContainer {
textLambda.invoke()
}
} else {
textLambda.invoke()
}
}
Row( Row(
modifier = Modifier modifier = Modifier
@@ -101,6 +120,14 @@ fun MessageBubble(
.defaultMinSize(minWidth = dateContainerWidth) .defaultMinSize(minWidth = dateContainerWidth)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), .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) { if (pinned) {
Icon( Icon(
painter = painterResource(UiR.drawable.ic_round_push_pin_24), painter = painterResource(UiR.drawable.ic_round_push_pin_24),
@@ -119,6 +146,7 @@ fun MessageBubble(
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
Text( Text(
text = date.orEmpty(), text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import android.os.Bundle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState 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.outlined.MoreVert
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -53,6 +55,7 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -64,7 +67,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color 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.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage 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.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
import dev.meloda.fast.messageshistory.model.ActionMode 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.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.util.firstMessage import dev.meloda.fast.messageshistory.util.firstMessage
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError 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.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.MaterialDialog 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.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList 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 dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
import java.util.concurrent.TimeUnit
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@Composable @Composable
@@ -119,10 +127,12 @@ fun MessagesHistoryRoute(
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>() viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() 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 selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val showMessageOptions by viewModel.showMessageOptions.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject() val userSettings: UserSettings = koinInject()
@@ -130,8 +140,10 @@ fun MessagesHistoryRoute(
MessagesHistoryScreen( MessagesHistoryScreen(
screenState = screenState, screenState = screenState,
messages = messages.toImmutableList(),
uiMessages = uiMessages.toImmutableList(),
scrollIndex = scrollIndex, scrollIndex = scrollIndex,
selectedMessages = ImmutableList.copyOf(selectedMessages), selectedMessages = selectedMessages.toImmutableList(),
baseError = baseError, baseError = baseError,
canPaginate = canPaginate, canPaginate = canPaginate,
showEmojiButton = showEmojiButton, showEmojiButton = showEmojiButton,
@@ -149,43 +161,300 @@ fun MessagesHistoryRoute(
onMessageClicked = viewModel::onMessageClicked, onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked, onMessageLongClicked = viewModel::onMessageLongClicked,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked, onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
) )
if (showMessageOptions != null) { HandleDialogs(
val message = showMessageOptions!! screenState = screenState,
messageDialog = messageDialog,
val messageOptions = mutableListOf( onConfirmed = viewModel::onDialogConfirmed,
stringResource(UiR.string.message_context_action_reply), onCancelled = viewModel::onDialogCancelled,
stringResource(UiR.string.message_context_action_forward) onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
) )
}
@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
is MessageDialog.MessageOptions -> {
MessageOptionsDialog(
screenState = screenState,
message = messageDialog.message,
onDismissed = { onDismissed(messageDialog) },
onItemPicked = { bundle -> onItemPicked(messageDialog, bundle) }
)
}
is MessageDialog.MessageDelete -> {
MessageDeleteDialog(
messages = listOf(messageDialog.message),
onConfirmed = { onConfirmed(messageDialog, it) },
onDismissed = { onDismissed(messageDialog) }
)
}
is MessageDialog.MessagesDelete -> {
MessageDeleteDialog(
messages = messageDialog.messages,
onConfirmed = { onConfirmed(messageDialog, it) },
onDismissed = { onDismissed(messageDialog) }
)
}
is MessageDialog.MessagePin,
is MessageDialog.MessageUnpin -> {
MessagePinStateDialog(
pin = messageDialog is MessageDialog.MessagePin,
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
onDismissed = { onDismissed(messageDialog) }
)
}
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) { if (message.isPeerChat() && screenState.conversation.canChangePin) {
messageOptions += stringResource( options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
if (message.isPinned) UiR.string.message_context_action_unpin }
else UiR.string.message_context_action_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
}
) )
} }
messageOptions += stringResource(UiR.string.message_context_action_copy) MaterialDialog(onDismissRequest = onDismissed) {
messageOptions += stringResource( messageOptions
if (message.isImportant) UiR.string.message_context_action_unmark_as_important .forEachIndexed { index, (title, painter, tintColor) ->
else UiR.string.message_context_action_mark_as_important 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))
}
)
}
}
}
// if (!message.isOut) { @Composable
// messageOptions += "Mark as spam" fun MessageDeleteDialog(
// } messages: List<VkMessage>,
onConfirmed: (Bundle) -> Unit = {},
onDismissed: () -> Unit = {},
) {
var forEveryone by remember {
mutableStateOf(messages.all(VkMessage::isOut))
}
messageOptions += stringResource(UiR.string.message_context_action_delete) val shouldBeDisabled by remember(messages) {
mutableStateOf(messages.any(VkMessage::isFailed) || !messages.all(VkMessage::isOut))
}
MaterialDialog( MaterialDialog(
onDismissRequest = viewModel::onMessageOptionsDialogDismissed, onDismissRequest = onDismissed,
selectionType = SelectionType.None, title = stringResource(UiR.string.delete_message_title),
items = ImmutableList.copyOf(messageOptions), confirmText = stringResource(UiR.string.action_delete),
confirmText = stringResource(UiR.string.ok) 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( @OptIn(
@@ -196,8 +465,10 @@ fun MessagesHistoryRoute(
@Composable @Composable
fun MessagesHistoryScreen( fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY, screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
messages: ImmutableList<VkMessage> = emptyImmutableList(),
uiMessages: ImmutableList<UiItem> = emptyImmutableList(),
scrollIndex: Int? = null, scrollIndex: Int? = null,
selectedMessages: ImmutableList<Int> = ImmutableList.empty(), selectedMessages: ImmutableList<VkMessage> = emptyImmutableList(),
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
@@ -215,7 +486,8 @@ fun MessagesHistoryScreen(
onMessageClicked: (Int) -> Unit = {}, onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {}, onMessageLongClicked: (Int) -> Unit = {},
onPinnedMessageClicked: (Int) -> Unit = {}, onPinnedMessageClicked: (Int) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {} onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -288,8 +560,8 @@ fun MessagesHistoryScreen(
val density = LocalDensity.current val density = LocalDensity.current
val showReplyAction by remember(screenState) { val showReplyAction by remember(selectedMessages) {
mutableStateOf(selectedMessages.size == 1) derivedStateOf { selectedMessages.size == 1 }
} }
Scaffold( Scaffold(
@@ -414,6 +686,12 @@ fun MessagesHistoryScreen(
} }
} }
) { ) {
Icon(
painter = painterResource(UiR.drawable.round_forward_24),
contentDescription = null
)
}
IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon( Icon(
painter = painterResource(UiR.drawable.round_delete_outline_24), painter = painterResource(UiR.drawable.round_delete_outline_24),
contentDescription = null contentDescription = null
@@ -449,7 +727,7 @@ fun MessagesHistoryScreen(
// TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat) // TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat)
onChatMaterialsDropdownItemClicked( onChatMaterialsDropdownItemClicked(
screenState.conversationId, screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId uiMessages.values.firstMessage().conversationMessageId
) )
}, },
text = { text = {
@@ -483,7 +761,7 @@ fun MessagesHistoryScreen(
) )
val showHorizontalProgressBar by remember(screenState) { val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() } derivedStateOf { screenState.isLoading && messages.isNotEmpty() }
} }
if (showHorizontalProgressBar) { if (showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -493,51 +771,15 @@ fun MessagesHistoryScreen(
} }
if (!screenState.isLoading && pinnedMessage != null) { if (!screenState.isLoading && pinnedMessage != null) {
Row( PinnedMessageContainer(
modifier = Modifier modifier = Modifier,
.fillMaxWidth() pinnedMessage = requireNotNull(pinnedMessage),
.height(56.dp) title = screenState.pinnedTitle.orDots(),
.clickable { onPinnedMessageClicked(pinnedMessage!!.id) } summary = screenState.pinnedSummary,
.padding(horizontal = 16.dp), canChangePin = screenState.conversation.canChangePin,
verticalAlignment = Alignment.CenterVertically onPinnedMessageClicked = onPinnedMessageClicked,
) { onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
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
)
}
}
}
HorizontalDivider() HorizontalDivider()
} }
} }
@@ -551,16 +793,17 @@ fun MessagesHistoryScreen(
.padding(bottom = padding.calculateBottomPadding()), .padding(bottom = padding.calculateBottomPadding()),
) { ) {
MessagesList( MessagesList(
modifier = Modifier.align(Alignment.BottomStart),
hazeState = hazeState, hazeState = hazeState,
listState = listState, listState = listState,
hasPinnedMessage = pinnedMessage != null, hasPinnedMessage = pinnedMessage != null,
immutableMessages = ImmutableList.copyOf(screenState.messages), uiMessages = uiMessages,
isPaginating = screenState.isPaginating, isPaginating = screenState.isPaginating,
messageBarHeight = messageBarHeight, messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId -> onRequestScrollToCmId = { cmId ->
coroutineScope.launch { coroutineScope.launch {
listState.animateScrollToItem( listState.animateScrollToItem(
index = screenState.messages.indexOfMessageByCmId(cmId) index = uiMessages.values.indexOfMessageByCmId(cmId)
) )
} }
}, },
@@ -775,7 +1018,7 @@ fun MessagesHistoryScreen(
} }
when { when {
screenState.isLoading && screenState.messages.isEmpty() -> { screenState.isLoading && messages.values.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} }
@@ -42,15 +42,15 @@ fun MessagesList(
hasPinnedMessage: Boolean, hasPinnedMessage: Boolean,
hazeState: HazeState, hazeState: HazeState,
listState: LazyListState, listState: LazyListState,
immutableMessages: ImmutableList<UiItem>, uiMessages: ImmutableList<UiItem>,
isPaginating: Boolean, isPaginating: Boolean,
messageBarHeight: Dp, messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {}, onRequestScrollToCmId: (cmId: Int) -> Unit = {},
onMessageClicked: (Int) -> Unit = {}, onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {} onMessageLongClicked: (Int) -> Unit = {}
) { ) {
val messages = remember(immutableMessages) { val messages = remember(uiMessages) {
immutableMessages.toList() uiMessages.toList()
} }
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val view = LocalView.current val view = LocalView.current
@@ -44,7 +44,9 @@ fun OutgoingMessageBubble(
edited = message.isEdited, edited = message.isEdited,
isRead = message.isRead, isRead = message.isRead,
sendingStatus = message.sendingStatus, 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, showName: Boolean,
prevMessage: VkMessage?, prevMessage: VkMessage?,
nextMessage: VkMessage?, nextMessage: VkMessage?,
showTimeInActionMessages: Boolean showTimeInActionMessages: Boolean,
isSelected: Boolean
): UiItem = when { ): UiItem = when {
action != null -> UiItem.ActionMessage( action != null -> UiItem.ActionMessage(
id = id, id = id,
@@ -126,11 +127,13 @@ fun VkMessage.asPresentation(
isEdited = updateTime != null, isEdited = updateTime != null,
isRead = isRead(conversation), isRead = isRead(conversation),
sendingStatus = when { sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT else -> SendingStatus.SENT
}, },
isSelected = false, isSelected = isSelected,
isPinned = isPinned 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -139,8 +140,11 @@ fun EditTextAlert(
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always actionInvokeDismiss = ActionInvokeDismiss.Always
) { ) {
Row(modifier = Modifier.fillMaxWidth()) { Row(
Spacer(modifier = Modifier.width(20.dp)) modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
TextField( TextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -155,8 +159,8 @@ fun EditTextAlert(
placeholder = { Text(text = "Value") }, placeholder = { Text(text = "Value") },
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
) )
Spacer(modifier = Modifier.width(20.dp))
} }
Spacer(modifier = Modifier.height(8.dp))
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {