Refactor: Implement swipe-to-reply and redesign input bar

This commit introduces the ability to reply to a message by swiping it to the right. The message input bar and related components have been redesigned and refactored for a cleaner look and better user experience.

Key changes:
- Added a swipe-to-reply gesture on message bubbles.
- Redesigned the message `InputBar` with updated styling, animations, and rounded corners that adapt to the reply state.
- Renamed `MessagesHistoryInputBar` to a more generic `InputBar`.
- Introduced `FastTextField`, a customized `BasicTextField`, for better performance and control.
- Replaced `IconButton` with `FastIconButton` and `RippledClickContainer` in several places for consistent click handling.
- Refactored `PinnedMessageContainer` and `ReplyContainer` with improved UI.
- Updated the Compose BOM to `2025.12.00`.
This commit is contained in:
2025-12-06 03:35:14 +03:00
parent c666bd46f3
commit f48878f003
18 changed files with 468 additions and 200 deletions
@@ -24,7 +24,7 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun IconButton(
fun FastIconButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
@@ -0,0 +1,109 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FastTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
// If color is not provided via the text style, use content color as a default
val textColor =
textStyle.color.takeOrElse {
val focused = interactionSource.collectIsFocusedAsState().value
colors.textColor(enabled, isError, focused)
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = value,
modifier =
modifier,
/* .defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = TextFieldDefaults.MinHeight,
)*/
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(colors.cursorColor(isError)),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
decorationBox =
@Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value.text,
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp)
)
},
)
}
}
@@ -1,6 +1,6 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
@@ -16,15 +16,17 @@ fun RippledClickContainer(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp),
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.clip(shape)
.clickable(
.combinedClickable(
interactionSource = null,
indication = ripple(),
onClick = onClick
onClick = onClick,
onLongClick = onLongClick
),
contentAlignment = Alignment.Center
) {
@@ -63,7 +63,7 @@ import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
@@ -205,7 +205,7 @@ fun CreateChatScreen(
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
FastIconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.round_arrow_back_24px),
contentDescription = null
@@ -63,4 +63,6 @@ interface MessagesHistoryViewModel {
fun onRegularClicked()
fun onReplyCloseClicked()
fun onRequestReplyToMessage(cmId: Long)
}
@@ -61,7 +61,6 @@ import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -235,22 +234,7 @@ class MessagesHistoryViewModelImpl(
// TODO: 28-Mar-25, Danil Nikolaev: retry sending
}
MessageOption.Reply -> {
inputFieldFocusRequester.setValue { true }
replyToCmId = cmId
screenState.setValue { old ->
val msg = messages.value.find { it.id == messageId }
if (msg == null) {
old
} else {
old.copy(
replyTitle = msg.extractTitle(),
replyText = msg.text
)
}
}
}
MessageOption.Reply -> replyToMessage(cmId)
MessageOption.ForwardHere -> {
@@ -349,14 +333,12 @@ class MessagesHistoryViewModelImpl(
override fun onEmojiButtonLongClicked() {
AppSettings.Features.fastText.takeIf { it.isNotBlank() }?.let { text ->
screenState.setValue { old ->
val newText = "${old.message.text}$text"
old.copy(
message = TextFieldValue(text = newText, selection = TextRange(newText.length))
val newText = "${screenState.value.message.text}$text"
onMessageInputChanged(
TextFieldValue(text = newText, selection = TextRange(newText.length))
)
}
}
}
override fun onActionButtonClicked() {
when (screenState.value.actionMode) {
@@ -448,6 +430,19 @@ class MessagesHistoryViewModelImpl(
}
}
private fun replyToMessage(cmId: Long) {
val messageToReply = messages.value.find { it.cmId == cmId } ?: return
inputFieldFocusRequester.setValue { true }
replyToCmId = cmId
screenState.setValue { old ->
old.copy(
replyTitle = messageToReply.extractTitle(),
replyText = messageToReply.text
)
}
}
private var formatData = VkMessage.FormatData("1", emptyList())
private fun updateStyles() {
@@ -576,6 +571,10 @@ class MessagesHistoryViewModelImpl(
}
}
override fun onRequestReplyToMessage(cmId: Long) {
replyToMessage(cmId)
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
@@ -4,15 +4,18 @@ 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.Box
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -25,18 +28,23 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlin.math.roundToInt
@Composable
fun IncomingMessageBubble(
enableAnimations: Boolean,
modifier: Modifier = Modifier,
message: UiItem.Message,
offsetX: Float = 0f,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
@@ -53,8 +61,19 @@ fun IncomingMessageBubble(
else Modifier
),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Icon(
painter = painterResource(R.drawable.round_reply_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.align(Alignment.CenterEnd)
.offset { IntOffset(24.dpInPx + offsetX.roundToInt(), y = 0) }
)
Row(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.fillMaxWidth(0.85f)
.padding(start = 16.dp),
verticalAlignment = Alignment.Bottom,
@@ -111,8 +130,10 @@ fun IncomingMessageBubble(
onLongClick = currentOnLongClick,
onReplyClick = currentOnReplyClick
)
}
}
Spacer(modifier = Modifier.fillMaxWidth(0.25f))
}
}
}
@@ -2,7 +2,6 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -10,33 +9,36 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imeNestedScroll
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.contextmenu.builder.item
import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.retain.retain
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,6 +53,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
@@ -61,16 +64,16 @@ import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.FastTextField
import dev.meloda.fast.ui.components.RippledClickContainer
import dev.meloda.fast.ui.theme.LocalThemeConfig
@OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun MessagesHistoryInputBar(
fun InputBar(
modifier: Modifier = Modifier,
message: TextFieldValue,
hazeState: HazeState,
enableHaptic: Boolean,
showEmojiButton: Boolean,
showAttachmentButton: Boolean,
actionMode: ActionMode,
@@ -93,6 +96,12 @@ fun MessagesHistoryInputBar(
val context = LocalContext.current
val density = LocalDensity.current
val theme = LocalThemeConfig.current
var localMessage by retain(message) {
mutableStateOf(message)
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) {
@@ -101,23 +110,31 @@ fun MessagesHistoryInputBar(
}
}
val theme = LocalThemeConfig.current
val inputBarCornerRadius =
if (replyTitle == null) (32.dp - if (localMessage.text.lines().size > 1) 8.dp else 0.dp) else 24.dp
val inputBarTopCornerRadius by animateDpAsState(
targetValue = if (replyTitle == null) 24.dp else 0.dp,
targetValue = if (replyTitle == null) inputBarCornerRadius else 0.dp,
label = "inputBarTopCornerRadius"
)
val inputBarShape = RoundedCornerShape(
topStart = inputBarTopCornerRadius,
topEnd = inputBarTopCornerRadius,
bottomStart = inputBarCornerRadius,
bottomEnd = inputBarCornerRadius
)
Column(
modifier = modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(bottom = 8.dp)
.navigationBarsPadding()
.imePadding()
) {
AnimatedVisibility(replyTitle != null) {
ReplyContainer(
modifier = Modifier.padding(horizontal = 8.dp),
title = replyTitle.orEmpty(),
text = replyText.orEmpty(),
onCloseClicked = onReplyCloseClicked,
@@ -127,35 +144,24 @@ fun MessagesHistoryInputBar(
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 60.dp)
.heightIn(min = 48.dp)
.imeNestedScroll(),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(8.dp))
Row(
modifier = Modifier
.clip(
RoundedCornerShape(
topStart = inputBarTopCornerRadius,
topEnd = inputBarTopCornerRadius,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
)
.clip(inputBarShape)
.then(
if (theme.enableBlur) {
Modifier
.hazeEffect(
state = hazeState,
style = HazeMaterials.ultraThin()
)
.border(
1.dp, MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(36.dp)
style = HazeMaterials.thin()
)
} else Modifier
)
.animateContentSize()
.weight(1f)
.background(
if (theme.enableBlur) Color.Transparent
@@ -172,7 +178,9 @@ fun MessagesHistoryInputBar(
if (showEmojiButton) {
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
@@ -196,14 +204,15 @@ fun MessagesHistoryInputBar(
)
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(6.dp))
}
}
TextField(
FastTextField(
modifier = Modifier
.focusRequester(focusRequester)
.weight(1f)
.heightIn(min = 48.dp)
.appendTextContextMenuComponents {
separator()
@@ -245,8 +254,11 @@ fun MessagesHistoryInputBar(
separator()
},
value = message,
onValueChange = onMessageInputChanged,
value = localMessage,
onValueChange = { newValue ->
localMessage = newValue
onMessageInputChanged(newValue)
},
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
@@ -264,10 +276,12 @@ fun MessagesHistoryInputBar(
if (showAttachmentButton) {
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = {
onAttachmentButtonClicked()
if (enableHaptic) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
@@ -281,12 +295,16 @@ fun MessagesHistoryInputBar(
)
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(6.dp))
}
Spacer(modifier = Modifier.width(4.dp))
}
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = {
onActionButtonClicked()
if (AppSettings.General.enableHaptic && actionMode.isRecord()) {
@@ -318,7 +336,7 @@ fun MessagesHistoryInputBar(
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(6.dp))
}
Spacer(modifier = Modifier.width(6.dp))
@@ -328,3 +346,18 @@ fun MessagesHistoryInputBar(
}
}
}
@Preview
@Composable
private fun InputBarPreview() {
InputBar(
message = TextFieldValue("Привет!"),
hazeState = remember { HazeState() },
showEmojiButton = true,
showAttachmentButton = true,
actionMode = ActionMode.SEND,
replyTitle = "Иннокентий Панфилович",
replyText = "Ого, ром!",
inputFieldFocusRequester = false
)
}
@@ -55,7 +55,6 @@ fun MessagesHistoryRoute(
canPaginate = canPaginate,
showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton,
enableHaptic = AppSettings.General.enableHaptic,
inputFieldFocusRequester = inputFieldFocusRequester,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
@@ -79,7 +78,8 @@ fun MessagesHistoryRoute(
onUnderlineRequested = viewModel::onUnderlineClicked,
onLinkRequested = viewModel::onLinkClicked,
onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked
onReplyCloseClicked = viewModel::onReplyCloseClicked,
onRequestReplyToMessage = viewModel::onRequestReplyToMessage,
)
HandleDialogs(
@@ -69,7 +69,6 @@ fun MessagesHistoryScreen(
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false,
enableHaptic: Boolean = false,
inputFieldFocusRequester: Boolean,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
@@ -94,6 +93,7 @@ fun MessagesHistoryScreen(
onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {},
onRequestReplyToMessage: (cmId: Long) -> Unit = {}
) {
val context = LocalContext.current
val view = LocalView.current
@@ -238,11 +238,14 @@ fun MessagesHistoryScreen(
currentOnMessageClicked.invoke(id)
},
onMessageLongClicked = onMessageLongClicked,
onPhotoClicked = onPhotoClicked
onPhotoClicked = onPhotoClicked,
onRequestMessageReply = onRequestReplyToMessage
)
MessagesHistoryInputBar(
modifier = Modifier.align(Alignment.BottomStart),
InputBar(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = 8.dp),
message = screenState.message,
onMessageInputChanged = onMessageInputChanged,
onBoldRequested = onBoldRequested,
@@ -251,7 +254,6 @@ fun MessagesHistoryScreen(
onLinkRequested = onLinkRequested,
onRegularRequested = onRegularRequested,
hazeState = hazeState,
enableHaptic = enableHaptic,
showEmojiButton = showEmojiButton,
showAttachmentButton = showAttachmentButton,
actionMode = screenState.actionMode,
@@ -88,11 +88,10 @@ fun MessagesHistoryTopBarContainer(
if (showPinnedContainer) {
PinnedMessageContainer(
modifier = Modifier,
pinnedMessage = requireNotNull(pinnedMessage),
title = pinnedTitle.orDots(),
summary = pinnedSummary,
canChangePin = showUnpinButton,
onPinnedMessageClicked = onPinnedMessageClicked,
onPinnedMessageClicked = { onPinnedMessageClicked(pinnedMessage?.id ?: -1) },
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
)
HorizontalDivider()
@@ -5,8 +5,10 @@ import android.util.Log
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,16 +24,23 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
@@ -42,6 +51,8 @@ import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -58,12 +69,15 @@ fun MessagesList(
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {},
onPhotoClicked: (images: List<String>, index: Int) -> Unit = { _, _ -> }
onPhotoClicked: (images: List<String>, index: Int) -> Unit = { _, _ -> },
onRequestMessageReply: (cmId: Long) -> Unit = {}
) {
val context = LocalContext.current
val theme = LocalThemeConfig.current
val view = LocalView.current
val scope = rememberCoroutineScope()
val onAttachmentClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
@@ -137,7 +151,7 @@ fun MessagesList(
Spacer(modifier = Modifier.height(48.dp))
}
Spacer(modifier = Modifier.height(messageBarHeight.plus(18.dp)))
Spacer(modifier = Modifier.height(messageBarHeight.plus(12.dp)))
Spacer(
modifier = Modifier
.fillMaxWidth()
@@ -183,6 +197,19 @@ fun MessagesList(
}
)
val offsetX = remember { Animatable(0f) }
val offsetDistinct by snapshotFlow { offsetX.value }
.distinctUntilChanged()
.collectAsStateWithLifecycle(offsetX)
LaunchedEffect(offsetDistinct) {
if (offsetDistinct == -100f && AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
}
Log.d("MessagesList", "offsetDistinct: $offsetDistinct")
}
Surface(
modifier = Modifier
.then(
@@ -199,7 +226,36 @@ fun MessagesList(
onMessageLongClicked(item.id)
},
onClick = { onMessageClicked(item.id) }
),
)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragCancel = {
if (offsetX.value == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
offsetX.animateTo(0f)
}
},
onDragEnd = {
if (offsetX.value == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
offsetX.animateTo(0f)
}
},
onHorizontalDrag = { _, dragAmount ->
scope.launch {
offsetX.snapTo(
(offsetX.value + dragAmount).coerceIn(-100f, 0f)
)
}
}
)
},
color = backgroundColor
) {
if (item.isOut) {
@@ -226,7 +282,8 @@ fun MessagesList(
if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId)
}
}
},
offsetX = offsetX.value
)
} else {
IncomingMessageBubble(
@@ -252,7 +309,8 @@ fun MessagesList(
if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId)
}
}
},
offsetX = offsetX.value
)
}
}
@@ -2,24 +2,34 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlin.math.roundToInt
@Composable
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
enableAnimations: Boolean,
message: UiItem.Message,
offsetX: Float = 0f,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
@@ -38,8 +48,22 @@ fun OutgoingMessageBubble(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd
) {
Icon(
painter = painterResource(R.drawable.round_reply_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.align(Alignment.CenterEnd)
.offset { IntOffset(24.dpInPx + offsetX.roundToInt(), y = 0) }
)
Row(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.padding(end = 16.dp)
.fillMaxWidth(0.85f),
verticalAlignment = Alignment.CenterVertically,
@@ -66,3 +90,4 @@ fun OutgoingMessageBubble(
}
}
}
}
@@ -1,15 +1,20 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -18,49 +23,48 @@ 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.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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
import dev.meloda.fast.ui.components.RippledClickContainer
@Composable
fun PinnedMessageContainer(
modifier: Modifier = Modifier,
pinnedMessage: VkMessage,
title: String,
summary: AnnotatedString?,
canChangePin: Boolean,
onPinnedMessageClicked: (Long) -> Unit = {},
onPinnedMessageClicked: () -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onPinnedMessageClicked(pinnedMessage.id) }
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
.height(42.dp)
.clickable(onClick = onPinnedMessageClicked)
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null,
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,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -69,6 +73,7 @@ fun PinnedMessageContainer(
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -76,18 +81,36 @@ fun PinnedMessageContainer(
}
}
if (canChangePin) {
Spacer(modifier = Modifier.width(16.dp))
IconButton(onClick = onUnpinMessageButtonClicked) {
AnimatedVisibility(canChangePin) {
Row(verticalAlignment = Alignment.CenterVertically) {
RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = onUnpinMessageButtonClicked
) {
Icon(
modifier = Modifier.alpha(0.5f),
painter = painterResource(R.drawable.round_close_24px),
contentDescription = null
contentDescription = null,
modifier = Modifier.alpha(0.5f),
)
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(4.dp))
}
}
}
}
@Preview
@Composable
private fun PinnedMessageContainerPreview() {
Surface {
PinnedMessageContainer(
title = "Иннокентий Панфилович",
summary = buildAnnotatedString { append("Здравствуйте, как Ваше ничего?") },
canChangePin = true,
onPinnedMessageClicked = {},
onUnpinMessageButtonClicked = {}
)
}
}
@@ -3,10 +3,8 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -15,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
@@ -31,25 +30,24 @@ import dev.meloda.fast.ui.components.RippledClickContainer
@Composable
fun ReplyContainer(
onCloseClicked: () -> Unit = {},
title: String,
text: String?,
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
) {
Row(
modifier = modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(
RoundedCornerShape(
val shape = RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
)
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(shape)
.background(backgroundColor)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -99,11 +97,8 @@ fun ReplyContainer(
@Preview
@Composable
private fun ReplyContainerPreview() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
Surface(
modifier = Modifier,
) {
ReplyContainer(
onCloseClicked = {},
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.FastIconButton
import kotlin.collections.forEachIndexed
@Composable
@@ -80,7 +80,7 @@ fun AudioMessage(
}
}
IconButton(
FastIconButton(
onClick = onPlayClick,
modifier = Modifier
.clip(CircleShape)
@@ -29,7 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@@ -101,7 +101,7 @@ fun DynamicPreviewGrid(
)
if (preview.isVideo) {
IconButton(
FastIconButton(
onClick = { currentOnClick(index) },
modifier = Modifier
.size(36.dp)
+1 -1
View File
@@ -7,7 +7,7 @@ kotlin = "2.2.21"
ksp = "2.3.3"
moduleGraph = "2.9.0"
compose-bom = "2025.11.01"
compose-bom = "2025.12.00"
koin = "4.1.1"
accompanist = "0.37.3"