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) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun IconButton( fun FastIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null, 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 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.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
@@ -16,15 +16,17 @@ fun RippledClickContainer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp), shape: Shape = RoundedCornerShape(4.dp),
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.clip(shape) .clip(shape)
.clickable( .combinedClickable(
interactionSource = null, interactionSource = null,
indication = ripple(), indication = ripple(),
onClick = onClick onClick = onClick,
onLongClick = onLongClick
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -63,7 +63,7 @@ import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader 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.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
@@ -205,7 +205,7 @@ fun CreateChatScreen(
) { ) {
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { FastIconButton(onClick = onBack) {
Icon( Icon(
painter = painterResource(R.drawable.round_arrow_back_24px), painter = painterResource(R.drawable.round_arrow_back_24px),
contentDescription = null contentDescription = null
@@ -63,4 +63,6 @@ interface MessagesHistoryViewModel {
fun onRegularClicked() fun onRegularClicked()
fun onReplyCloseClicked() fun onReplyCloseClicked()
fun onRequestReplyToMessage(cmId: Long)
} }
@@ -61,7 +61,6 @@ import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -235,22 +234,7 @@ class MessagesHistoryViewModelImpl(
// TODO: 28-Mar-25, Danil Nikolaev: retry sending // TODO: 28-Mar-25, Danil Nikolaev: retry sending
} }
MessageOption.Reply -> { MessageOption.Reply -> replyToMessage(cmId)
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.ForwardHere -> { MessageOption.ForwardHere -> {
@@ -349,14 +333,12 @@ class MessagesHistoryViewModelImpl(
override fun onEmojiButtonLongClicked() { override fun onEmojiButtonLongClicked() {
AppSettings.Features.fastText.takeIf { it.isNotBlank() }?.let { text -> AppSettings.Features.fastText.takeIf { it.isNotBlank() }?.let { text ->
screenState.setValue { old -> val newText = "${screenState.value.message.text}$text"
val newText = "${old.message.text}$text" onMessageInputChanged(
old.copy( TextFieldValue(text = newText, selection = TextRange(newText.length))
message = TextFieldValue(text = newText, selection = TextRange(newText.length))
) )
} }
} }
}
override fun onActionButtonClicked() { override fun onActionButtonClicked() {
when (screenState.value.actionMode) { 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 var formatData = VkMessage.FormatData("1", emptyList())
private fun updateStyles() { private fun updateStyles() {
@@ -576,6 +571,10 @@ class MessagesHistoryViewModelImpl(
} }
} }
override fun onRequestReplyToMessage(cmId: Long) {
replyToMessage(cmId)
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
@@ -4,15 +4,18 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.imageLoader import coil.imageLoader
import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlin.math.roundToInt
@Composable @Composable
fun IncomingMessageBubble( fun IncomingMessageBubble(
enableAnimations: Boolean, enableAnimations: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: UiItem.Message, message: UiItem.Message,
offsetX: Float = 0f,
onClick: (VkAttachment) -> Unit = {}, onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}, onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {} onReplyClick: () -> Unit = {}
@@ -53,8 +61,19 @@ fun IncomingMessageBubble(
else Modifier 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( Row(
modifier = Modifier modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.fillMaxWidth(0.85f) .fillMaxWidth(0.85f)
.padding(start = 16.dp), .padding(start = 16.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@@ -111,8 +130,10 @@ fun IncomingMessageBubble(
onLongClick = currentOnLongClick, onLongClick = currentOnLongClick,
onReplyClick = currentOnReplyClick onReplyClick = currentOnReplyClick
) )
} }
} }
Spacer(modifier = Modifier.fillMaxWidth(0.25f)) 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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -10,33 +9,36 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imeNestedScroll import androidx.compose.foundation.layout.imeNestedScroll
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.contextmenu.builder.item import androidx.compose.foundation.text.contextmenu.builder.item
import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat 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.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.ui.R 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 import dev.meloda.fast.ui.theme.LocalThemeConfig
@OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun MessagesHistoryInputBar( fun InputBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: TextFieldValue, message: TextFieldValue,
hazeState: HazeState, hazeState: HazeState,
enableHaptic: Boolean,
showEmojiButton: Boolean, showEmojiButton: Boolean,
showAttachmentButton: Boolean, showAttachmentButton: Boolean,
actionMode: ActionMode, actionMode: ActionMode,
@@ -93,6 +96,12 @@ fun MessagesHistoryInputBar(
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val theme = LocalThemeConfig.current
var localMessage by retain(message) {
mutableStateOf(message)
}
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) { 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( val inputBarTopCornerRadius by animateDpAsState(
targetValue = if (replyTitle == null) 24.dp else 0.dp, targetValue = if (replyTitle == null) inputBarCornerRadius else 0.dp,
label = "inputBarTopCornerRadius" label = "inputBarTopCornerRadius"
) )
val inputBarShape = RoundedCornerShape(
topStart = inputBarTopCornerRadius,
topEnd = inputBarTopCornerRadius,
bottomStart = inputBarCornerRadius,
bottomEnd = inputBarCornerRadius
)
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Transparent) .background(Color.Transparent)
.padding(bottom = 8.dp)
.navigationBarsPadding() .navigationBarsPadding()
.imePadding() .imePadding()
) { ) {
AnimatedVisibility(replyTitle != null) { AnimatedVisibility(replyTitle != null) {
ReplyContainer( ReplyContainer(
modifier = Modifier.padding(horizontal = 8.dp),
title = replyTitle.orEmpty(), title = replyTitle.orEmpty(),
text = replyText.orEmpty(), text = replyText.orEmpty(),
onCloseClicked = onReplyCloseClicked, onCloseClicked = onReplyCloseClicked,
@@ -127,35 +144,24 @@ fun MessagesHistoryInputBar(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.defaultMinSize(minHeight = 60.dp) .heightIn(min = 48.dp)
.imeNestedScroll(), .imeNestedScroll(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Row( Row(
modifier = Modifier modifier = Modifier
.clip( .clip(inputBarShape)
RoundedCornerShape(
topStart = inputBarTopCornerRadius,
topEnd = inputBarTopCornerRadius,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
)
.then( .then(
if (theme.enableBlur) { if (theme.enableBlur) {
Modifier Modifier
.hazeEffect( .hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.ultraThin() style = HazeMaterials.thin()
)
.border(
1.dp, MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(36.dp)
) )
} else Modifier } else Modifier
) )
.animateContentSize()
.weight(1f) .weight(1f)
.background( .background(
if (theme.enableBlur) Color.Transparent if (theme.enableBlur) Color.Transparent
@@ -172,7 +178,9 @@ fun MessagesHistoryInputBar(
if (showEmojiButton) { if (showEmojiButton) {
Column(verticalArrangement = Arrangement.Bottom) { Column(verticalArrangement = Arrangement.Bottom) {
IconButton( RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = { onClick = {
if (AppSettings.General.enableHaptic) { if (AppSettings.General.enableHaptic) {
view.performHapticFeedback( view.performHapticFeedback(
@@ -196,14 +204,15 @@ fun MessagesHistoryInputBar(
) )
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(6.dp))
} }
} }
TextField( FastTextField(
modifier = Modifier modifier = Modifier
.focusRequester(focusRequester) .focusRequester(focusRequester)
.weight(1f) .weight(1f)
.heightIn(min = 48.dp)
.appendTextContextMenuComponents { .appendTextContextMenuComponents {
separator() separator()
@@ -245,8 +254,11 @@ fun MessagesHistoryInputBar(
separator() separator()
}, },
value = message, value = localMessage,
onValueChange = onMessageInputChanged, onValueChange = { newValue ->
localMessage = newValue
onMessageInputChanged(newValue)
},
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@@ -264,10 +276,12 @@ fun MessagesHistoryInputBar(
if (showAttachmentButton) { if (showAttachmentButton) {
Column(verticalArrangement = Arrangement.Bottom) { Column(verticalArrangement = Arrangement.Bottom) {
IconButton( RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = { onClick = {
onAttachmentButtonClicked() onAttachmentButtonClicked()
if (enableHaptic) { if (AppSettings.General.enableHaptic) {
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT 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) { Column(verticalArrangement = Arrangement.Bottom) {
IconButton( RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = { onClick = {
onActionButtonClicked() onActionButtonClicked()
if (AppSettings.General.enableHaptic && actionMode.isRecord()) { 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)) 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, canPaginate = canPaginate,
showEmojiButton = AppSettings.General.showEmojiButton, showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton, showAttachmentButton = AppSettings.General.showAttachmentButton,
enableHaptic = AppSettings.General.enableHaptic,
inputFieldFocusRequester = inputFieldFocusRequester, inputFieldFocusRequester = inputFieldFocusRequester,
onBack = onBack, onBack = onBack,
onClose = viewModel::onCloseButtonClicked, onClose = viewModel::onCloseButtonClicked,
@@ -79,7 +78,8 @@ fun MessagesHistoryRoute(
onUnderlineRequested = viewModel::onUnderlineClicked, onUnderlineRequested = viewModel::onUnderlineClicked,
onLinkRequested = viewModel::onLinkClicked, onLinkRequested = viewModel::onLinkClicked,
onRegularRequested = viewModel::onRegularClicked, onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked onReplyCloseClicked = viewModel::onReplyCloseClicked,
onRequestReplyToMessage = viewModel::onRequestReplyToMessage,
) )
HandleDialogs( HandleDialogs(
@@ -69,7 +69,6 @@ fun MessagesHistoryScreen(
canPaginate: Boolean = false, canPaginate: Boolean = false,
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false, showAttachmentButton: Boolean = false,
enableHaptic: Boolean = false,
inputFieldFocusRequester: Boolean, inputFieldFocusRequester: Boolean,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {}, onClose: () -> Unit = {},
@@ -94,6 +93,7 @@ fun MessagesHistoryScreen(
onUnderlineRequested: () -> Unit = {}, onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {}, onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {}, onReplyCloseClicked: () -> Unit = {},
onRequestReplyToMessage: (cmId: Long) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
@@ -238,11 +238,14 @@ fun MessagesHistoryScreen(
currentOnMessageClicked.invoke(id) currentOnMessageClicked.invoke(id)
}, },
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked,
onRequestMessageReply = onRequestReplyToMessage
) )
MessagesHistoryInputBar( InputBar(
modifier = Modifier.align(Alignment.BottomStart), modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = 8.dp),
message = screenState.message, message = screenState.message,
onMessageInputChanged = onMessageInputChanged, onMessageInputChanged = onMessageInputChanged,
onBoldRequested = onBoldRequested, onBoldRequested = onBoldRequested,
@@ -251,7 +254,6 @@ fun MessagesHistoryScreen(
onLinkRequested = onLinkRequested, onLinkRequested = onLinkRequested,
onRegularRequested = onRegularRequested, onRegularRequested = onRegularRequested,
hazeState = hazeState, hazeState = hazeState,
enableHaptic = enableHaptic,
showEmojiButton = showEmojiButton, showEmojiButton = showEmojiButton,
showAttachmentButton = showAttachmentButton, showAttachmentButton = showAttachmentButton,
actionMode = screenState.actionMode, actionMode = screenState.actionMode,
@@ -88,11 +88,10 @@ fun MessagesHistoryTopBarContainer(
if (showPinnedContainer) { if (showPinnedContainer) {
PinnedMessageContainer( PinnedMessageContainer(
modifier = Modifier, modifier = Modifier,
pinnedMessage = requireNotNull(pinnedMessage),
title = pinnedTitle.orDots(), title = pinnedTitle.orDots(),
summary = pinnedSummary, summary = pinnedSummary,
canChangePin = showUnpinButton, canChangePin = showUnpinButton,
onPinnedMessageClicked = onPinnedMessageClicked, onPinnedMessageClicked = { onPinnedMessageClicked(pinnedMessage?.id ?: -1) },
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
) )
HorizontalDivider() HorizontalDivider()
@@ -5,8 +5,10 @@ import android.util.Log
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,16 +24,23 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings 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.model.api.domain.VkPhotoDomain
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -58,12 +69,15 @@ fun MessagesList(
onRequestScrollToCmId: (cmId: Long) -> Unit = {}, onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {}, onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (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 context = LocalContext.current
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val view = LocalView.current val view = LocalView.current
val scope = rememberCoroutineScope()
val onAttachmentClick by rememberUpdatedState( val onAttachmentClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment -> { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) { if (isSelectedAtLeastOne) {
@@ -137,7 +151,7 @@ fun MessagesList(
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
} }
Spacer(modifier = Modifier.height(messageBarHeight.plus(18.dp))) Spacer(modifier = Modifier.height(messageBarHeight.plus(12.dp)))
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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( Surface(
modifier = Modifier modifier = Modifier
.then( .then(
@@ -199,7 +226,36 @@ fun MessagesList(
onMessageLongClicked(item.id) onMessageLongClicked(item.id)
}, },
onClick = { onMessageClicked(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 color = backgroundColor
) { ) {
if (item.isOut) { if (item.isOut) {
@@ -226,7 +282,8 @@ fun MessagesList(
if (item.replyCmId != null) { if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId) onRequestScrollToCmId(item.replyCmId)
} }
} },
offsetX = offsetX.value
) )
} else { } else {
IncomingMessageBubble( IncomingMessageBubble(
@@ -252,7 +309,8 @@ fun MessagesList(
if (item.replyCmId != null) { if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId) onRequestScrollToCmId(item.replyCmId)
} }
} },
offsetX = offsetX.value
) )
} }
} }
@@ -2,24 +2,34 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlin.math.roundToInt
@Composable @Composable
fun OutgoingMessageBubble( fun OutgoingMessageBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enableAnimations: Boolean, enableAnimations: Boolean,
message: UiItem.Message, message: UiItem.Message,
offsetX: Float = 0f,
onClick: (VkAttachment) -> Unit = {}, onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}, onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {} onReplyClick: () -> Unit = {}
@@ -38,8 +48,22 @@ fun OutgoingMessageBubble(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End 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( Row(
modifier = Modifier modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.padding(end = 16.dp) .padding(end = 16.dp)
.fillMaxWidth(0.85f), .fillMaxWidth(0.85f),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -66,3 +90,4 @@ fun OutgoingMessageBubble(
} }
} }
} }
}
@@ -1,15 +1,20 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.draw.rotate
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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 dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.RippledClickContainer
@Composable @Composable
fun PinnedMessageContainer( fun PinnedMessageContainer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
pinnedMessage: VkMessage,
title: String, title: String,
summary: AnnotatedString?, summary: AnnotatedString?,
canChangePin: Boolean, canChangePin: Boolean,
onPinnedMessageClicked: (Long) -> Unit = {}, onPinnedMessageClicked: () -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {} onUnpinMessageButtonClicked: () -> Unit = {}
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp) .height(42.dp)
.clickable { onPinnedMessageClicked(pinnedMessage.id) } .clickable(onClick = onPinnedMessageClicked)
.padding(start = 16.dp), .padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( Icon(
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null,
modifier = Modifier modifier = Modifier
.rotate(45f) .rotate(45f)
.alpha(0.5f), .alpha(0.5f),
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null
) )
Spacer(modifier = Modifier.width(16.dp))
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text( Text(
text = title, text = title,
fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -69,6 +73,7 @@ fun PinnedMessageContainer(
LocalContentAlpha(alpha = ContentAlpha.medium) { LocalContentAlpha(alpha = ContentAlpha.medium) {
Text( Text(
text = summary, text = summary,
style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -76,18 +81,36 @@ fun PinnedMessageContainer(
} }
} }
if (canChangePin) { AnimatedVisibility(canChangePin) {
Spacer(modifier = Modifier.width(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
RippledClickContainer(
IconButton(onClick = onUnpinMessageButtonClicked) { modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = onUnpinMessageButtonClicked
) {
Icon( Icon(
modifier = Modifier.alpha(0.5f),
painter = painterResource(R.drawable.round_close_24px), 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.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -31,25 +30,24 @@ import dev.meloda.fast.ui.components.RippledClickContainer
@Composable @Composable
fun ReplyContainer( fun ReplyContainer(
onCloseClicked: () -> Unit = {},
title: String, title: String,
text: String?, text: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp) backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
) { ) {
Row( val shape = RoundedCornerShape(
modifier = modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(
RoundedCornerShape(
topStart = 24.dp, topStart = 24.dp,
topEnd = 24.dp, topEnd = 24.dp,
bottomStart = 0.dp, bottomStart = 0.dp,
bottomEnd = 0.dp bottomEnd = 0.dp
) )
)
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(shape)
.background(backgroundColor) .background(backgroundColor)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -99,11 +97,8 @@ fun ReplyContainer(
@Preview @Preview
@Composable @Composable
private fun ReplyContainerPreview() { private fun ReplyContainerPreview() {
Box( Surface(
modifier = Modifier modifier = Modifier,
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) { ) {
ReplyContainer( ReplyContainer(
onCloseClicked = {}, onCloseClicked = {},
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times import androidx.compose.ui.unit.times
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.FastIconButton
import kotlin.collections.forEachIndexed import kotlin.collections.forEachIndexed
@Composable @Composable
@@ -80,7 +80,7 @@ fun AudioMessage(
} }
} }
IconButton( FastIconButton(
onClick = onPlayClick, onClick = onPlayClick,
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
@@ -29,7 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.ui.R 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
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@@ -101,7 +101,7 @@ fun DynamicPreviewGrid(
) )
if (preview.isVideo) { if (preview.isVideo) {
IconButton( FastIconButton(
onClick = { currentOnClick(index) }, onClick = { currentOnClick(index) },
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
+1 -1
View File
@@ -7,7 +7,7 @@ kotlin = "2.2.21"
ksp = "2.3.3" ksp = "2.3.3"
moduleGraph = "2.9.0" moduleGraph = "2.9.0"
compose-bom = "2025.11.01" compose-bom = "2025.12.00"
koin = "4.1.1" koin = "4.1.1"
accompanist = "0.37.3" accompanist = "0.37.3"