Release 0.2.0 (#150)
Release Notes * Bumped haze, agp, and guava dependencies * Implemented ordering functionality for friends list * Added scroll to top feature in friends and conversations screens * Improved messages handling * Fixed coloring issues * Cache improvements * Implemented logout functionality * Implemented new authorization flow (no auto-token re-request) * Added support for sticker pack preview attachments * Bump LongPoll to version 19 * Markdown support for messages bubbles * Adjust app name font size based on screen width --------- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
+690
-255
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
package dev.meloda.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
|
||||
@Immutable
|
||||
sealed class MessageDialog {
|
||||
data class MessageOptions(val message: VkMessage) : MessageDialog()
|
||||
data class MessagePin(val messageId: Long) : MessageDialog()
|
||||
data class MessageUnpin(val messageId: Long) : 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()
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class MessageNavigation {
|
||||
|
||||
data class ChatMaterials(
|
||||
val peerId: Long,
|
||||
val cmId: Long
|
||||
) : MessageNavigation()
|
||||
}
|
||||
+84
@@ -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
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
|
||||
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
|
||||
|
||||
+11
-5
@@ -1,18 +1,19 @@
|
||||
package dev.meloda.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
import dev.meloda.fast.model.api.domain.VkAttachment
|
||||
import dev.meloda.fast.model.api.domain.VkConversation
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
|
||||
@Immutable
|
||||
data class MessagesHistoryScreenState(
|
||||
val conversationId: Int,
|
||||
val conversationId: Long,
|
||||
val title: String,
|
||||
val status: String?,
|
||||
val avatar: UiImage,
|
||||
val messages: List<UiItem>,
|
||||
val message: TextFieldValue,
|
||||
val attachments: List<VkAttachment>,
|
||||
val isLoading: Boolean,
|
||||
@@ -20,7 +21,10 @@ data class MessagesHistoryScreenState(
|
||||
val isPaginationExhausted: Boolean,
|
||||
val actionMode: ActionMode,
|
||||
val chatImageUrl: String?,
|
||||
val conversation: VkConversation
|
||||
val conversation: VkConversation,
|
||||
val pinnedMessage: VkMessage?,
|
||||
val pinnedTitle: String?,
|
||||
val pinnedSummary: AnnotatedString?
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -29,7 +33,6 @@ data class MessagesHistoryScreenState(
|
||||
title = "",
|
||||
status = null,
|
||||
avatar = UiImage.Color(0),
|
||||
messages = emptyList(),
|
||||
message = TextFieldValue(),
|
||||
attachments = emptyList(),
|
||||
isLoading = true,
|
||||
@@ -37,7 +40,10 @@ data class MessagesHistoryScreenState(
|
||||
isPaginationExhausted = false,
|
||||
actionMode = ActionMode.Record,
|
||||
chatImageUrl = null,
|
||||
conversation = VkConversation.EMPTY
|
||||
conversation = VkConversation.EMPTY,
|
||||
pinnedMessage = null,
|
||||
pinnedTitle = null,
|
||||
pinnedSummary = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -4,18 +4,18 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
|
||||
sealed class UiItem(
|
||||
open val id: Int,
|
||||
val cmId: Int
|
||||
open val id: Long,
|
||||
val cmId: Long
|
||||
) {
|
||||
|
||||
data class Message(
|
||||
override val id: Int,
|
||||
val conversationMessageId: Int,
|
||||
val text: String?,
|
||||
override val id: Long,
|
||||
val conversationMessageId: Long,
|
||||
val text: AnnotatedString?,
|
||||
val isOut: Boolean,
|
||||
val fromId: Int,
|
||||
val fromId: Long,
|
||||
val date: String,
|
||||
val randomId: Int,
|
||||
val randomId: Long,
|
||||
val isInChat: Boolean,
|
||||
val name: String,
|
||||
val showDate: Boolean,
|
||||
@@ -24,13 +24,16 @@ sealed class UiItem(
|
||||
val avatar: UiImage,
|
||||
val isEdited: Boolean,
|
||||
val isRead: Boolean,
|
||||
val sendingStatus: SendingStatus = SendingStatus.SENT
|
||||
val sendingStatus: SendingStatus,
|
||||
val isSelected: Boolean,
|
||||
val isPinned: Boolean,
|
||||
val isImportant: Boolean
|
||||
) : UiItem(id, conversationMessageId)
|
||||
|
||||
data class ActionMessage(
|
||||
override val id: Int,
|
||||
val conversationMessageId: Int,
|
||||
override val id: Long,
|
||||
val conversationMessageId: Long,
|
||||
val text: AnnotatedString,
|
||||
val actionCmId: Int?
|
||||
val actionCmId: Long?
|
||||
) : UiItem(id, conversationMessageId)
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,17 +27,17 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
|
||||
fun NavGraphBuilder.messagesHistoryScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
|
||||
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit
|
||||
) {
|
||||
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
|
||||
MessagesHistoryRoute(
|
||||
onError = onError,
|
||||
onBack = onBack,
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onNavigateToChatMaterials = onNavigateToChatMaterials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Int) {
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Long) {
|
||||
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
|
||||
}
|
||||
|
||||
+5
-3
@@ -27,13 +27,15 @@ fun ActionMessageItem(
|
||||
Text(
|
||||
text = item.text,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(
|
||||
horizontal = 32.dp,
|
||||
vertical = 4.dp
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.then(
|
||||
if (item.actionCmId != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
}
|
||||
else Modifier
|
||||
} else Modifier
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
|
||||
.fillMaxWidth()
|
||||
|
||||
+57
-46
@@ -1,6 +1,7 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -26,65 +27,75 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
|
||||
@Composable
|
||||
fun IncomingMessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
message: UiItem.Message,
|
||||
animate: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
|
||||
else Modifier
|
||||
),
|
||||
) {
|
||||
if (message.isInChat) {
|
||||
Image(
|
||||
painter =
|
||||
message.avatar.extractUrl()?.let { url ->
|
||||
rememberAsyncImagePainter(
|
||||
model = url,
|
||||
imageLoader = context.imageLoader
|
||||
)
|
||||
} ?: painterResource(id = message.avatar.extractResId()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 6.dp)
|
||||
.size(28.dp)
|
||||
.alpha(if (message.showAvatar) 1f else 0f)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column {
|
||||
AnimatedVisibility(visible = message.showName) {
|
||||
Text(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
if (message.isInChat) {
|
||||
Image(
|
||||
painter =
|
||||
message.avatar.extractUrl()?.let { url ->
|
||||
rememberAsyncImagePainter(
|
||||
model = url,
|
||||
imageLoader = LocalContext.current.imageLoader
|
||||
)
|
||||
} ?: painterResource(id = message.avatar.extractResId()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.widthIn(max = 140.dp),
|
||||
text = message.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
.padding(bottom = 6.dp)
|
||||
.size(28.dp)
|
||||
.alpha(if (message.showAvatar) 1f else 0f)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text,
|
||||
isOut = false,
|
||||
date = message.date,
|
||||
edited = message.isEdited,
|
||||
animate = animate,
|
||||
isRead = message.isRead,
|
||||
sendingStatus = message.sendingStatus
|
||||
)
|
||||
Column {
|
||||
AnimatedVisibility(visible = message.showName) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.widthIn(max = 140.dp),
|
||||
text = message.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text,
|
||||
isOut = false,
|
||||
date = message.date,
|
||||
edited = message.isEdited,
|
||||
isRead = message.isRead,
|
||||
sendingStatus = message.sendingStatus,
|
||||
pinned = message.isPinned,
|
||||
important = message.isImportant,
|
||||
isSelected = message.isSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.fillMaxWidth(0.25f))
|
||||
}
|
||||
}
|
||||
|
||||
+152
-72
@@ -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,113 +21,192 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String?,
|
||||
text: AnnotatedString?,
|
||||
isOut: Boolean,
|
||||
date: String?,
|
||||
edited: Boolean,
|
||||
animate: Boolean,
|
||||
isRead: Boolean,
|
||||
sendingStatus: SendingStatus
|
||||
sendingStatus: SendingStatus,
|
||||
pinned: Boolean,
|
||||
important: Boolean,
|
||||
isSelected: Boolean
|
||||
) {
|
||||
val theme = LocalThemeConfig.current
|
||||
val backgroundColor = if (!isOut) {
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
|
||||
val textColor = if (!isOut) {
|
||||
val contentColor = if (!isOut) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
) {
|
||||
val minDateContainerWidth = remember(edited, isOut) {
|
||||
val mainPart = if (edited) 50.dp else 30.dp
|
||||
val readIndicatorPart = if (isOut) 14.dp else 0.dp
|
||||
|
||||
mainPart + readIndicatorPart
|
||||
}
|
||||
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
|
||||
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 (animate) Modifier.animateContentSize() else Modifier),
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = dateContainerWidth)
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.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 + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
|
||||
if (text != null) {
|
||||
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
|
||||
{
|
||||
Text(
|
||||
text = kotlin.run {
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
|
||||
text.spanStyles.map { spanStyleRange ->
|
||||
val updatedSpanStyle =
|
||||
if (spanStyleRange.item.color == Color.Red) {
|
||||
spanStyleRange.item.copy(color =
|
||||
if (isOut) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
} else {
|
||||
spanStyleRange.item
|
||||
}
|
||||
|
||||
builder.addStyle(
|
||||
style = updatedSpanStyle,
|
||||
start = spanStyleRange.start,
|
||||
end = spanStyleRange.end
|
||||
)
|
||||
}
|
||||
|
||||
text.paragraphStyles.forEach { style ->
|
||||
builder.addStyle(
|
||||
style = style.item,
|
||||
start = style.start,
|
||||
end = style.end
|
||||
)
|
||||
}
|
||||
|
||||
builder.toAnnotatedString()
|
||||
},
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
SelectionContainer {
|
||||
textLambda.invoke()
|
||||
}
|
||||
} else {
|
||||
textLambda.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.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),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
}
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
)
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
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.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.MaterialDialog
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
dialog: MessageDialog?,
|
||||
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (MessageDialog) -> Unit = {},
|
||||
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (dialog) {
|
||||
null -> Unit
|
||||
|
||||
is MessageDialog.MessageOptions -> {
|
||||
MessageOptionsDialog(
|
||||
screenState = screenState,
|
||||
message = dialog.message,
|
||||
onDismissed = { onDismissed(dialog) },
|
||||
onItemPicked = { bundle -> onItemPicked(dialog, bundle) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = listOf(dialog.message),
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagesDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = dialog.messages,
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagePin,
|
||||
is MessageDialog.MessageUnpin -> {
|
||||
MessagePinStateDialog(
|
||||
pin = dialog is MessageDialog.MessagePin,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageMarkImportance -> {
|
||||
MessageImportanceDialog(
|
||||
important = dialog.isImportant,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageSpam -> {
|
||||
MessageSpamDialog(
|
||||
spam = dialog.isSpam,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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.any { it.peerId == UserConfig.userId }
|
||||
&& messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldBeDisabled by remember(messages) {
|
||||
mutableStateOf(
|
||||
messages.any { it.peerId == UserConfig.userId }
|
||||
|| messages.all(VkMessage::isFailed)
|
||||
|| !messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(R.string.delete_message_title),
|
||||
confirmText = stringResource(R.string.action_delete),
|
||||
confirmAction = {
|
||||
onConfirmed(
|
||||
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
|
||||
)
|
||||
},
|
||||
cancelText = stringResource(R.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(R.string.delete_message_for_everyone))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagePinStateDialog(
|
||||
pin: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (pin) R.string.pin_message_title
|
||||
else R.string.unpin_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (pin) R.string.pin_message_text
|
||||
else R.string.unpin_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (pin) R.string.action_pin
|
||||
else R.string.action_unpin
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageImportanceDialog(
|
||||
important: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (important) R.string.important_message_title
|
||||
else R.string.unimportant_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (important) R.string.important_message_text
|
||||
else R.string.unimportant_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (important) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageSpamDialog(
|
||||
spam: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (spam) R.string.spam_message_title
|
||||
else R.string.unspam_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (spam) R.string.spam_message_text
|
||||
else R.string.unspam_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (spam) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import dev.meloda.fast.messageshistory.model.MessageNavigation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
|
||||
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
|
||||
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(navigationEvent) {
|
||||
val needToConsume = when (val navigation = navigationEvent) {
|
||||
null -> false
|
||||
|
||||
is MessageNavigation.ChatMaterials -> {
|
||||
val (peerId, cmId) = navigation
|
||||
onNavigateToChatMaterials(peerId, cmId)
|
||||
true
|
||||
}
|
||||
}
|
||||
if (needToConsume) viewModel.onNavigationConsumed()
|
||||
}
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
messages = messages.toImmutableList(),
|
||||
uiMessages = uiMessages.toImmutableList(),
|
||||
scrollIndex = scrollIndex,
|
||||
selectedMessages = selectedMessages.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onClose = viewModel::onCloseButtonClicked,
|
||||
onScrolledToIndex = viewModel::onScrolledToIndex,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onTopBarClicked = viewModel::onTopBarClicked,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
|
||||
onMessageClicked = viewModel::onMessageClicked,
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
+295
-149
@@ -1,13 +1,17 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -34,6 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -75,60 +80,29 @@ import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.HapticFeedbackConstantsCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
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.MessagesHistoryScreenState
|
||||
import dev.meloda.fast.messageshistory.util.firstMessage
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
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.components.IconButton
|
||||
import dev.meloda.fast.ui.components.VkErrorView
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
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 dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Composable
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onRefreshDropdownItemClicked = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
@@ -137,27 +111,51 @@ fun MessagesHistoryRoute(
|
||||
@Composable
|
||||
fun MessagesHistoryScreen(
|
||||
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
|
||||
messages: ImmutableList<VkMessage> = emptyImmutableList(),
|
||||
uiMessages: ImmutableList<UiItem> = emptyImmutableList(),
|
||||
scrollIndex: Int? = null,
|
||||
selectedMessages: ImmutableList<VkMessage> = emptyImmutableList(),
|
||||
baseError: BaseError? = null,
|
||||
canPaginate: Boolean = false,
|
||||
showEmojiButton: Boolean = false,
|
||||
onBack: () -> Unit = {},
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
|
||||
onRefreshDropdownItemClicked: () -> Unit = {},
|
||||
onToggleAnimationsDropdownItemClicked: (Boolean) -> Unit = {},
|
||||
onClose: () -> Unit = {},
|
||||
onScrolledToIndex: () -> Unit = {},
|
||||
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
|
||||
onTopBarClicked: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
onPaginationConditionsMet: () -> Unit = {},
|
||||
onMessageInputChanged: (TextFieldValue) -> Unit = {},
|
||||
onAttachmentButtonClicked: () -> Unit = {},
|
||||
onActionButtonClicked: () -> Unit = {},
|
||||
onEmojiButtonLongClicked: () -> Unit = {}
|
||||
onEmojiButtonLongClicked: () -> Unit = {},
|
||||
onMessageClicked: (Long) -> Unit = {},
|
||||
onMessageLongClicked: (Long) -> Unit = {},
|
||||
onPinnedMessageClicked: (Long) -> Unit = {},
|
||||
onUnpinMessageButtonClicked: () -> Unit = {},
|
||||
onDeleteSelectedButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val preferences: SharedPreferences = koinInject()
|
||||
val currentTheme = LocalThemeConfig.current
|
||||
|
||||
val theme = LocalThemeConfig.current
|
||||
val listState = rememberLazyListState()
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
LaunchedEffect(scrollIndex) {
|
||||
if (scrollIndex != null) {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(scrollIndex)
|
||||
onScrolledToIndex()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(
|
||||
enabled = selectedMessages.isNotEmpty(),
|
||||
onBack = onClose
|
||||
)
|
||||
|
||||
val pinnedMessage = screenState.pinnedMessage
|
||||
|
||||
val paginationConditionMet by remember(canPaginate, listState) {
|
||||
derivedStateOf {
|
||||
@@ -177,12 +175,24 @@ fun MessagesHistoryScreen(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
val toolbarColorAlpha by animateFloatAsState(
|
||||
targetValue = if (!listState.canScrollForward) 1f else 0f,
|
||||
val topBarContainerColorAlpha by animateFloatAsState(
|
||||
targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f,
|
||||
label = "toolbarColorAlpha",
|
||||
animationSpec = tween(durationMillis = 50)
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutLinearInEasing
|
||||
)
|
||||
)
|
||||
|
||||
val topBarContainerColor by animateColorAsState(
|
||||
targetValue =
|
||||
if (theme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface
|
||||
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
label = "toolbarColorAlpha",
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutLinearInEasing
|
||||
)
|
||||
)
|
||||
|
||||
var messageBarHeight by remember {
|
||||
@@ -191,54 +201,97 @@ fun MessagesHistoryScreen(
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val showReplyAction by remember(selectedMessages) {
|
||||
derivedStateOf { selectedMessages.size == 1 }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
|
||||
.then(
|
||||
if (theme.enableBlur) {
|
||||
Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick()
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (currentTheme.enableBlur) {
|
||||
Modifier.hazeChild(
|
||||
if (theme.enableBlur) {
|
||||
Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick()
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (screenState.isLoading && messages.isEmpty()) Modifier
|
||||
else Modifier.clickable {
|
||||
onTopBarClicked()
|
||||
}
|
||||
),
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val avatar = screenState.avatar.getImage()
|
||||
if (avatar is Painter) {
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.avatar.getImage(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
|
||||
)
|
||||
if (selectedMessages.isEmpty()) {
|
||||
val avatar = screenState.avatar.getImage()
|
||||
if (screenState.conversationId == UserConfig.userId) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(24.dp),
|
||||
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24),
|
||||
contentDescription = "Favorites icon",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (avatar is Painter) {
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.avatar.getImage(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (screenState.isLoading) stringResource(id = UiR.string.title_loading)
|
||||
else screenState.title,
|
||||
text = when {
|
||||
screenState.isLoading -> stringResource(id = UiR.string.title_loading)
|
||||
selectedMessages.size > 0 -> "(${selectedMessages.size})"
|
||||
else -> screenState.title
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
@@ -246,73 +299,109 @@ fun MessagesHistoryScreen(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (selectedMessages.isEmpty()) onBack()
|
||||
else onClose()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
imageVector = if (selectedMessages.isEmpty()) {
|
||||
Icons.AutoMirrored.Rounded.ArrowBack
|
||||
} else {
|
||||
Icons.Rounded.Close
|
||||
},
|
||||
contentDescription = "Back button"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(
|
||||
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
|
||||
)
|
||||
),
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { dropDownMenuExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreVert,
|
||||
contentDescription = "Options"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
|
||||
expanded = dropDownMenuExpanded,
|
||||
onDismissRequest = {
|
||||
dropDownMenuExpanded = false
|
||||
},
|
||||
offset = DpOffset(x = (-4).dp, y = (-60).dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
|
||||
// TODO: 11/07/2024, Danil Nikolaev: to VM
|
||||
|
||||
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
|
||||
onChatMaterialsDropdownItemClicked(
|
||||
screenState.conversationId,
|
||||
screenState.messages.firstMessage().conversationMessageId
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = "Materials")
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onRefreshDropdownItemClicked()
|
||||
dropDownMenuExpanded = false
|
||||
},
|
||||
text = {
|
||||
Text(text = "Refresh")
|
||||
},
|
||||
leadingIcon = {
|
||||
if (selectedMessages.isNotEmpty()) {
|
||||
AnimatedVisibility(showReplyAction) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
painter = painterResource(UiR.drawable.round_reply_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.round_reply_all_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.round_forward_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteSelectedButtonClicked) {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.round_delete_outline_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = { dropDownMenuExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.MoreVert,
|
||||
contentDescription = "Options"
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
|
||||
expanded = dropDownMenuExpanded,
|
||||
onDismissRequest = {
|
||||
dropDownMenuExpanded = false
|
||||
},
|
||||
offset = DpOffset(x = (-4).dp, y = (-60).dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onRefresh()
|
||||
dropDownMenuExpanded = false
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(UiR.string.action_refresh))
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val showHorizontalProgressBar by remember(screenState) {
|
||||
derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() }
|
||||
derivedStateOf { screenState.isLoading && messages.isNotEmpty() }
|
||||
}
|
||||
if (showHorizontalProgressBar) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
@@ -320,6 +409,19 @@ fun MessagesHistoryScreen(
|
||||
AnimatedVisibility(!showHorizontalProgressBar) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (!screenState.isLoading && pinnedMessage != null) {
|
||||
PinnedMessageContainer(
|
||||
modifier = Modifier,
|
||||
pinnedMessage = requireNotNull(pinnedMessage),
|
||||
title = screenState.pinnedTitle.orDots(),
|
||||
summary = screenState.pinnedSummary,
|
||||
canChangePin = screenState.conversation.canChangePin,
|
||||
onPinnedMessageClicked = onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
@@ -331,18 +433,32 @@ fun MessagesHistoryScreen(
|
||||
.padding(bottom = padding.calculateBottomPadding()),
|
||||
) {
|
||||
MessagesList(
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
hazeState = hazeState,
|
||||
listState = listState,
|
||||
immutableMessages = ImmutableList.copyOf(screenState.messages),
|
||||
hasPinnedMessage = pinnedMessage != null,
|
||||
uiMessages = uiMessages,
|
||||
isPaginating = screenState.isPaginating,
|
||||
messageBarHeight = messageBarHeight,
|
||||
onRequestScrollToCmId = { cmId ->
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(
|
||||
index = screenState.messages.indexOfMessageByCmId(cmId)
|
||||
)
|
||||
val index = uiMessages.values.indexOfMessageByCmId(cmId)
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(index = index)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMessageClicked = { id ->
|
||||
if (selectedMessages.isNotEmpty()) {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
|
||||
}
|
||||
}
|
||||
onMessageClicked(id)
|
||||
},
|
||||
onMessageLongClicked = onMessageLongClicked
|
||||
)
|
||||
|
||||
Column(
|
||||
@@ -362,13 +478,28 @@ fun MessagesHistoryScreen(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(36.dp))
|
||||
.then(
|
||||
if (theme.enableBlur) {
|
||||
Modifier
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.ultraThin()
|
||||
)
|
||||
.border(
|
||||
1.dp, MaterialTheme.colorScheme.outlineVariant,
|
||||
RoundedCornerShape(36.dp)
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.animateContentSize()
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(36.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
|
||||
.background(
|
||||
if (theme.enableBlur) Color.Transparent
|
||||
else MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
|
||||
)
|
||||
.onGloballyPositioned {
|
||||
messageBarHeight = with(density) {
|
||||
it.size.height.toDp()
|
||||
@@ -386,7 +517,9 @@ fun MessagesHistoryScreen(
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstantsCompat.REJECT
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
for (i in 20 downTo 0 step 4) {
|
||||
@@ -405,7 +538,9 @@ fun MessagesHistoryScreen(
|
||||
},
|
||||
onLongClick = {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.LONG_PRESS)
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstantsCompat.LONG_PRESS
|
||||
)
|
||||
}
|
||||
onEmojiButtonLongClicked()
|
||||
},
|
||||
@@ -447,8 +582,11 @@ fun MessagesHistoryScreen(
|
||||
Column(verticalArrangement = Arrangement.Bottom) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onAttachmentButtonClicked()
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstantsCompat.REJECT
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
for (i in 20 downTo 0 step 4) {
|
||||
@@ -484,7 +622,9 @@ fun MessagesHistoryScreen(
|
||||
onClick = {
|
||||
if (screenState.actionMode == ActionMode.Record) {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstantsCompat.REJECT
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
for (i in 20 downTo 0 step 4) {
|
||||
@@ -535,8 +675,14 @@ fun MessagesHistoryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (screenState.isLoading && screenState.messages.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
when {
|
||||
screenState.isLoading && messages.values.isEmpty() -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
baseError != null -> {
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-41
@@ -1,54 +1,63 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.haze
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessagesList(
|
||||
modifier: Modifier = Modifier,
|
||||
hasPinnedMessage: Boolean,
|
||||
hazeState: HazeState,
|
||||
listState: LazyListState,
|
||||
immutableMessages: ImmutableList<UiItem>,
|
||||
uiMessages: ImmutableList<UiItem>,
|
||||
isPaginating: Boolean,
|
||||
messageBarHeight: Dp,
|
||||
onRequestScrollToCmId: (cmId: Int) -> Unit = {}
|
||||
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
|
||||
onMessageClicked: (Long) -> Unit = {},
|
||||
onMessageLongClicked: (Long) -> Unit = {}
|
||||
) {
|
||||
val enableAnimations = remember {
|
||||
AppSettings.Experimental.moreAnimations
|
||||
}
|
||||
val messages = remember(immutableMessages) {
|
||||
immutableMessages.toList()
|
||||
}
|
||||
val currentTheme = LocalThemeConfig.current
|
||||
val theme = LocalThemeConfig.current
|
||||
val view = LocalView.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (currentTheme.enableBlur) {
|
||||
Modifier.haze(state = hazeState)
|
||||
if (theme.enableBlur) {
|
||||
Modifier.hazeSource(state = hazeState)
|
||||
} else Modifier
|
||||
),
|
||||
state = listState,
|
||||
@@ -65,7 +74,7 @@ fun MessagesList(
|
||||
}
|
||||
|
||||
items(
|
||||
items = messages,
|
||||
items = uiMessages.values,
|
||||
key = UiItem::id,
|
||||
contentType = { item ->
|
||||
when (item) {
|
||||
@@ -77,6 +86,12 @@ fun MessagesList(
|
||||
when (item) {
|
||||
is UiItem.ActionMessage -> {
|
||||
ActionMessageItem(
|
||||
modifier = Modifier.then(
|
||||
if (theme.enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
) else Modifier
|
||||
),
|
||||
item = item,
|
||||
onClick = {
|
||||
if (item.actionCmId != null) {
|
||||
@@ -87,37 +102,65 @@ fun MessagesList(
|
||||
}
|
||||
|
||||
is UiItem.Message -> {
|
||||
if (item.isOut) {
|
||||
OutgoingMessageBubble(
|
||||
modifier =
|
||||
Modifier.then(
|
||||
if (enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
)
|
||||
else Modifier
|
||||
),
|
||||
message = item,
|
||||
animate = enableAnimations
|
||||
)
|
||||
} else {
|
||||
IncomingMessageBubble(
|
||||
modifier =
|
||||
Modifier.then(
|
||||
if (enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
)
|
||||
else Modifier
|
||||
),
|
||||
message = item,
|
||||
animate = enableAnimations
|
||||
)
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (item.isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (theme.enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
) else Modifier
|
||||
)
|
||||
.combinedClickable(
|
||||
onLongClick = {
|
||||
if (AppSettings.General.enableHaptic) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
}
|
||||
onMessageLongClicked(item.id)
|
||||
},
|
||||
onClick = { onMessageClicked(item.id) }
|
||||
),
|
||||
color = backgroundColor
|
||||
) {
|
||||
if (item.isOut) {
|
||||
OutgoingMessageBubble(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.then(
|
||||
if (theme.enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
)
|
||||
else Modifier
|
||||
),
|
||||
message = item
|
||||
)
|
||||
} else {
|
||||
IncomingMessageBubble(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.then(
|
||||
if (theme.enableAnimations) Modifier.animateItem(
|
||||
fadeInSpec = null,
|
||||
fadeOutSpec = null
|
||||
)
|
||||
else Modifier
|
||||
),
|
||||
message = item
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -130,6 +173,10 @@ fun MessagesList(
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPinnedMessage) {
|
||||
Spacer(modifier = Modifier.height(56.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
|
||||
+14
-6
@@ -1,5 +1,6 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -11,34 +12,41 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
|
||||
@Composable
|
||||
fun OutgoingMessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
message: UiItem.Message,
|
||||
animate: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
|
||||
else Modifier
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.fillMaxWidth(0.75f),
|
||||
.fillMaxWidth(0.85f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text.orDots(),
|
||||
text = message.text,
|
||||
isOut = true,
|
||||
date = message.date,
|
||||
edited = message.isEdited,
|
||||
animate = animate,
|
||||
isRead = message.isRead,
|
||||
sendingStatus = message.sendingStatus
|
||||
sendingStatus = message.sendingStatus,
|
||||
pinned = message.isPinned,
|
||||
important = message.isImportant,
|
||||
isSelected = message.isSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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: (Long) -> 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
package dev.meloda.fast.messageshistory.util
|
||||
|
||||
import com.conena.nanokt.collections.indexOfFirstOrNull
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
|
||||
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
|
||||
|
||||
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
|
||||
|
||||
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
|
||||
fun List<UiItem>.indexOfMessageById(messageId: Long): Int =
|
||||
indexOfFirst { it.id == messageId }
|
||||
|
||||
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
|
||||
fun List<UiItem>.findMessageById(messageId: Long): UiItem.Message? =
|
||||
firstOrNull { it.id == messageId } as UiItem.Message?
|
||||
|
||||
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
|
||||
indexOfFirst { it.cmId == cmId }
|
||||
fun List<UiItem>.indexOfMessageByCmId(cmId: Long): Int? =
|
||||
indexOfFirstOrNull { it.cmId == cmId }
|
||||
|
||||
fun List<UiItem>.findMessageByCmId(cmId: Int): UiItem.Message =
|
||||
fun List<UiItem>.findMessageByCmId(cmId: Long): UiItem.Message =
|
||||
first { it.cmId == cmId } as UiItem.Message
|
||||
|
||||
+162
-6
@@ -1,10 +1,15 @@
|
||||
package dev.meloda.fast.messageshistory.util
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.AnnotatedString.Annotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.StringAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
import dev.meloda.fast.common.model.UiText
|
||||
@@ -15,6 +20,7 @@ import dev.meloda.fast.data.VkMemoryCache
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.model.api.PeerType
|
||||
import dev.meloda.fast.model.api.domain.FormatDataType
|
||||
import dev.meloda.fast.model.api.domain.VkConversation
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.R
|
||||
@@ -22,7 +28,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
private fun isAccount(fromId: Int) = fromId == UserConfig.userId
|
||||
private fun isAccount(fromId: Long) = fromId == UserConfig.userId
|
||||
|
||||
fun VkMessage.extractAvatar() = when {
|
||||
isUser() -> {
|
||||
@@ -96,11 +102,12 @@ fun VkMessage.asPresentation(
|
||||
showName: Boolean,
|
||||
prevMessage: VkMessage?,
|
||||
nextMessage: VkMessage?,
|
||||
showTimeInActionMessages: Boolean
|
||||
showTimeInActionMessages: Boolean,
|
||||
isSelected: Boolean
|
||||
): UiItem = when {
|
||||
action != null -> UiItem.ActionMessage(
|
||||
id = id,
|
||||
conversationMessageId = conversationMessageId,
|
||||
conversationMessageId = cmId,
|
||||
text = extractActionText(
|
||||
resources = resourceProvider.resources,
|
||||
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
|
||||
@@ -111,8 +118,12 @@ fun VkMessage.asPresentation(
|
||||
|
||||
else -> UiItem.Message(
|
||||
id = id,
|
||||
conversationMessageId = conversationMessageId,
|
||||
text = text,
|
||||
conversationMessageId = cmId,
|
||||
text = extractTextWithVisualizedMentions(
|
||||
isOut = isOut,
|
||||
originalText = text,
|
||||
formatData = formatData
|
||||
),
|
||||
isOut = isOut,
|
||||
fromId = fromId,
|
||||
date = extractDate(),
|
||||
@@ -126,9 +137,13 @@ fun VkMessage.asPresentation(
|
||||
isEdited = updateTime != null,
|
||||
isRead = isRead(conversation),
|
||||
sendingStatus = when {
|
||||
isFailed() -> SendingStatus.FAILED
|
||||
id <= 0 -> SendingStatus.SENDING
|
||||
else -> SendingStatus.SENT
|
||||
}
|
||||
},
|
||||
isSelected = isSelected,
|
||||
isPinned = isPinned,
|
||||
isImportant = isImportant
|
||||
)
|
||||
}
|
||||
|
||||
@@ -537,3 +552,144 @@ fun VkMessage.extractActionText(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 04-Apr-25, Danil Nikolaev: get rid of method duplication
|
||||
fun extractTextWithVisualizedMentions(
|
||||
isOut: Boolean,
|
||||
originalText: String?,
|
||||
formatData: VkMessage.FormatData?
|
||||
): AnnotatedString? {
|
||||
if (originalText == null) return null
|
||||
|
||||
val annotations =
|
||||
mutableListOf<AnnotatedString.Range<out Annotation>>()
|
||||
|
||||
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
|
||||
|
||||
val mentions = mutableListOf<MentionIndex>()
|
||||
|
||||
var currentIndex = 0
|
||||
val replacements = mutableListOf<Pair<IntRange, String>>()
|
||||
|
||||
val newText = regex.replace(originalText) { matchResult ->
|
||||
val idPrefix = matchResult.groups[1]?.value.orEmpty()
|
||||
val startIndex = matchResult.range.first
|
||||
val endIndex = matchResult.range.last
|
||||
|
||||
val id = matchResult.groups[2]?.value ?: ""
|
||||
|
||||
val replaced = matchResult.groups[3]?.value.orEmpty()
|
||||
|
||||
val indexRange =
|
||||
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
|
||||
|
||||
replacements.add(indexRange to replaced)
|
||||
|
||||
mentions += MentionIndex(
|
||||
id = id.toLongOrNull() ?: -1,
|
||||
idPrefix = idPrefix,
|
||||
indexRange = indexRange
|
||||
)
|
||||
|
||||
currentIndex += replaced.length - (endIndex - startIndex + 1)
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
mentions.forEach { mention ->
|
||||
val startIndex = mention.indexRange.first
|
||||
val endIndex = mention.indexRange.last
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(mention.id.toString()),
|
||||
tag = mention.idPrefix,
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
}
|
||||
|
||||
if (formatData == null) return AnnotatedString(text = newText, annotations = annotations)
|
||||
|
||||
var current = 0
|
||||
|
||||
val newOffsets = formatData.items.map { (offset, length) ->
|
||||
val r = replacements.filter { (range, _) ->
|
||||
(range - current) collidesWith (offset..<offset + length) || offset > range.first
|
||||
}
|
||||
|
||||
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
|
||||
|
||||
offset + current
|
||||
}
|
||||
|
||||
formatData.items.forEachIndexed { index, item ->
|
||||
val offset = newOffsets[index]
|
||||
|
||||
val spanStyle = when (item.type) {
|
||||
FormatDataType.BOLD -> {
|
||||
SpanStyle(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
FormatDataType.ITALIC -> {
|
||||
SpanStyle(fontStyle = FontStyle.Italic)
|
||||
}
|
||||
|
||||
FormatDataType.UNDERLINE -> {
|
||||
SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
|
||||
FormatDataType.URL -> {
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(item.url.orEmpty()),
|
||||
start = offset,
|
||||
end = offset + item.length,
|
||||
tag = newText.substring(offset, offset + item.length)
|
||||
)
|
||||
|
||||
if (isOut) {
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
} else {
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = spanStyle,
|
||||
start = offset,
|
||||
end = offset + item.length
|
||||
)
|
||||
}
|
||||
|
||||
return AnnotatedString(text = newText, annotations = annotations)
|
||||
}
|
||||
|
||||
data class MentionIndex(
|
||||
val id: Long,
|
||||
val idPrefix: String,
|
||||
val indexRange: IntRange
|
||||
)
|
||||
|
||||
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
|
||||
return this.start < other.endInclusive && other.start < this.endInclusive
|
||||
}
|
||||
|
||||
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
|
||||
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
|
||||
}
|
||||
|
||||
operator fun ClosedRange<Int>.minus(other: Int): ClosedRange<Int> {
|
||||
return (this.start - other)..(this.endInclusive - other)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user