split MessagesHistoryScreen.kt in separated composables.

Added "regular" to text field in messages history screen - for clearing formatting
This commit is contained in:
2025-06-23 17:36:02 +03:00
parent 17b5c944ac
commit 9d4e3f50ea
9 changed files with 763 additions and 529 deletions
@@ -269,6 +269,7 @@
<string name="italic">Курсив</string>
<string name="underline">Подчёркнутый</string>
<string name="link">Ссылка</string>
<string name="regular">Обычный</string>
<string name="login_sign_up">Регистрация</string>
<string name="login_forgot_password">Забыли пароль?</string>
</resources>
+1
View File
@@ -345,6 +345,7 @@
<string name="italic">Italic</string>
<string name="underline">Underline</string>
<string name="link">Link</string>
<string name="regular">Regular</string>
<string name="login_sign_up">Sign up</string>
<string name="login_forgot_password">Forgot password?</string>
</resources>
@@ -106,10 +106,7 @@ fun ConversationsScreen(
onErrorViewButtonClicked: () -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
}
val maxLines = if (currentTheme.enableMultiline) 2 else 1
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = screenState.scrollIndex,
@@ -111,6 +111,7 @@ interface MessagesHistoryViewModel {
fun onItalicClicked()
fun onUnderlineClicked()
fun onLinkClicked()
fun onRegularClicked()
}
class MessagesHistoryViewModelImpl(
@@ -571,6 +572,11 @@ class MessagesHistoryViewModelImpl(
}
override fun onRegularClicked() {
formatData = formatData.copy(items = emptyList())
updateStyles()
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
@@ -0,0 +1,340 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
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.imeNestedScroll
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.contextmenu.builder.item
import androidx.compose.foundation.text.contextmenu.modifier.addTextContextMenuComponents
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun MessagesHistoryInputBar(
modifier: Modifier = Modifier,
message: TextFieldValue,
hazeState: HazeState,
showEmojiButton: Boolean,
actionMode: ActionMode,
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {},
onLinkRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {},
onSetMessageBarHeight: (Dp) -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val context = LocalContext.current
val density = LocalDensity.current
val theme = LocalThemeConfig.current
Column(
modifier = modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(bottom = 8.dp)
.navigationBarsPadding()
.imePadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 60.dp)
.imeNestedScroll(),
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)
.background(
if (theme.enableBlur) Color.Transparent
else MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
)
.onGloballyPositioned {
with(density) {
onSetMessageBarHeight(it.size.height.toDp())
}
},
verticalAlignment = Alignment.Bottom
) {
Spacer(modifier = Modifier.width(6.dp))
if (showEmojiButton) {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.LONG_PRESS
)
}
onEmojiButtonLongClicked()
},
modifier = Modifier.rotate(rotation.value)
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_outline_emoji_emotions_24),
contentDescription = "Emoji button",
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
TextField(
modifier = modifier
.weight(1f)
.addTextContextMenuComponents {
separator()
item(
key = "Bold",
label = context.getString(UiR.string.bold)
) {
onBoldRequested()
close()
}
item(
key = "Italic",
label = context.getString(UiR.string.italic)
) {
onItalicRequested()
close()
}
item(
key = "Underline",
label = context.getString(UiR.string.underline)
) {
onUnderlineRequested()
close()
}
item(
key = "Link",
label = context.getString(UiR.string.link)
) {
onLinkRequested()
close()
}
item(
key = "Regular",
label = context.getString(UiR.string.regular)
) {
onRegularRequested()
close()
}
separator()
},
value = message,
onValueChange = onMessageInputChanged,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
placeholder = {
Text(
text = stringResource(id = UiR.string.message_input_hint),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
val scope = rememberCoroutineScope()
val attachmentRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
onAttachmentButtonClicked()
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
attachmentRotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
attachmentRotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
}
) {
Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.rotate(30f + attachmentRotation.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
}
val micRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
if (actionMode == ActionMode.Record) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
micRotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
micRotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
} else {
onActionButtonClicked()
}
},
modifier = Modifier.rotate(micRotation.value)
) {
Icon(
painter = painterResource(
id = when (actionMode) {
ActionMode.Delete -> UiR.drawable.round_delete_outline_24
ActionMode.Edit -> UiR.drawable.ic_round_done_24
ActionMode.Record -> UiR.drawable.ic_round_mic_none_24
ActionMode.Send -> UiR.drawable.round_send_24
}
),
contentDescription = when (actionMode) {
ActionMode.Delete -> "Delete message button"
ActionMode.Edit -> "Edit message button"
ActionMode.Record -> "Record audio message button"
ActionMode.Send -> "Send message button"
},
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
}
Spacer(modifier = Modifier.width(6.dp))
}
Spacer(modifier = Modifier.width(10.dp))
}
}
}
@@ -73,7 +73,9 @@ fun MessagesHistoryRoute(
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked,
onUnderlineRequested = viewModel::onUnderlineClicked
onUnderlineRequested = viewModel::onUnderlineClicked,
onLinkRequested = viewModel::onLinkClicked,
onRegularRequested = viewModel::onRegularClicked
)
HandleDialogs(
@@ -1,62 +1,23 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
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
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
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.addTextContextMenuComponents
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
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -68,42 +29,25 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
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.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
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 dev.meloda.fast.ui.R as UiR
@@ -140,7 +84,9 @@ fun MessagesHistoryScreen(
onDeleteSelectedButtonClicked: () -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
onLinkRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {}
) {
val context = LocalContext.current
val view = LocalView.current
@@ -179,10 +125,6 @@ fun MessagesHistoryScreen(
}
}
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
@@ -207,8 +149,6 @@ fun MessagesHistoryScreen(
mutableStateOf(0.dp)
}
val density = LocalDensity.current
val showReplyAction by remember(selectedMessages) {
derivedStateOf { selectedMessages.size == 1 }
}
@@ -217,223 +157,40 @@ fun MessagesHistoryScreen(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
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 (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth()
.then(
if (screenState.isLoading && messages.isEmpty()) Modifier
else Modifier.clickable {
onTopBarClicked()
}
),
title = {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
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))
}
Text(
text = when {
screenState.isLoading -> stringResource(id = UiR.string.title_loading)
val topBarTitle by remember(screenState, selectedMessages) {
derivedStateOf {
when {
screenState.isLoading -> context.getString(UiR.string.title_loading)
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
}
},
navigationIcon = {
IconButton(
onClick = {
if (selectedMessages.isEmpty()) onBack()
else onClose()
}
) {
Crossfade(targetState = selectedMessages.isEmpty()) { state ->
Icon(
imageVector = if (state) {
Icons.AutoMirrored.Rounded.ArrowBack
} else {
Icons.Rounded.Close
},
contentDescription = if (state) "Close button"
else "Back button"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = {
if (selectedMessages.isNotEmpty()) {
AnimatedVisibility(showReplyAction) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
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 && messages.isNotEmpty() }
}
if (showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
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,
MessagesHistoryTopBarContainer(
hazeState = hazeState,
showReplyAction = showReplyAction,
topBarContainerColor = topBarContainerColor,
topBarContainerColorAlpha = topBarContainerColorAlpha,
isClickable = !(screenState.isLoading && messages.isEmpty()),
isMessagesSelecting = selectedMessages.isNotEmpty(),
isPeerAccount = screenState.conversationId == UserConfig.userId,
avatar = screenState.avatar,
title = topBarTitle,
showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(),
showPinnedContainer = !screenState.isLoading && pinnedMessage != null,
pinnedMessage = pinnedMessage,
pinnedTitle = screenState.pinnedTitle,
pinnedSummary = screenState.pinnedSummary,
showUnpinButton = screenState.conversation.canChangePin,
onTopBarClicked = onTopBarClicked,
onBack = onBack,
onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onRefresh = onRefresh,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
)
HorizontalDivider()
}
}
}
) { padding ->
Box(
@@ -472,254 +229,23 @@ fun MessagesHistoryScreen(
onMessageLongClicked = onMessageLongClicked
)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomStart)
.background(Color.Transparent)
.padding(bottom = 8.dp)
.navigationBarsPadding()
.imePadding()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 60.dp)
.imeNestedScroll(),
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()
MessagesHistoryInputBar(
modifier = Modifier.align(Alignment.BottomStart),
message = screenState.message,
onMessageInputChanged = onMessageInputChanged,
onBoldRequested = onBoldRequested,
onItalicRequested = onItalicRequested,
onUnderlineRequested = onUnderlineRequested,
onLinkRequested = onLinkRequested,
onRegularRequested = onRegularRequested,
hazeState = hazeState,
showEmojiButton = showEmojiButton,
actionMode = screenState.actionMode,
onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked,
onAttachmentButtonClicked = onAttachmentButtonClicked,
onActionButtonClicked = onActionButtonClicked
)
.border(
1.dp, MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(36.dp)
)
} else Modifier
)
.animateContentSize()
.weight(1f)
.background(
if (theme.enableBlur) Color.Transparent
else MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
)
.onGloballyPositioned {
messageBarHeight = with(density) {
it.size.height.toDp()
}
},
verticalAlignment = Alignment.Bottom
) {
Spacer(modifier = Modifier.width(6.dp))
if (showEmojiButton) {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.LONG_PRESS
)
}
onEmojiButtonLongClicked()
},
modifier = Modifier.rotate(rotation.value)
) {
Icon(
painter = painterResource(id = UiR.drawable.ic_outline_emoji_emotions_24),
contentDescription = "Emoji button",
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
TextField(
modifier = Modifier
.weight(1f)
.addTextContextMenuComponents {
separator()
item(
key = "Bold",
label = context.getString(UiR.string.bold)
) {
onBoldRequested()
close()
}
item(
key = "Italic",
label = context.getString(UiR.string.italic)
) {
onItalicRequested()
close()
}
item(
key = "Underline",
label = context.getString(UiR.string.underline)
) {
onUnderlineRequested()
close()
}
item(
key = "Link",
label = context.getString(UiR.string.link)
) {
close()
}
separator()
},
value = screenState.message,
onValueChange = onMessageInputChanged,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
placeholder = {
Text(
text = stringResource(id = UiR.string.message_input_hint),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
val scope = rememberCoroutineScope()
val attachmentRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
onAttachmentButtonClicked()
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
attachmentRotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
attachmentRotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
}
) {
Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.rotate(30f + attachmentRotation.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
}
val micRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
if (screenState.actionMode == ActionMode.Record) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
micRotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
micRotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
} else {
onActionButtonClicked()
}
},
modifier = Modifier.rotate(micRotation.value)
) {
Icon(
painter = painterResource(
id = when (screenState.actionMode) {
ActionMode.Delete -> UiR.drawable.round_delete_outline_24
ActionMode.Edit -> UiR.drawable.ic_round_done_24
ActionMode.Record -> UiR.drawable.ic_round_mic_none_24
ActionMode.Send -> UiR.drawable.round_send_24
}
),
contentDescription = when (screenState.actionMode) {
ActionMode.Delete -> "Delete message button"
ActionMode.Edit -> "Edit message button"
ActionMode.Record -> "Record audio message button"
ActionMode.Send -> "Send message button"
},
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
}
Spacer(modifier = Modifier.width(6.dp))
}
Spacer(modifier = Modifier.width(10.dp))
}
}
when {
screenState.isLoading && messages.values.isEmpty() -> {
@@ -0,0 +1,260 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun MessagesHistoryTopBar(
modifier: Modifier = Modifier,
hazeState: HazeState,
showReplyAction: Boolean,
isClickable: Boolean,
isMessagesSelecting: Boolean,
isPeerAccount: Boolean,
avatar: UiImage,
title: String,
onTopBarClicked: () -> Unit = {},
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
) {
val view = LocalView.current
val theme = LocalThemeConfig.current
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
TopAppBar(
modifier = modifier
.then(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth()
.then(
if (!isClickable) Modifier
else Modifier.clickable {
onTopBarClicked()
}
),
title = {
Row(
// modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
if (!isMessagesSelecting) {
if (isPeerAccount) {
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 {
val actualAvatar = avatar.getImage()
if (actualAvatar is Painter) {
Image(
painter = actualAvatar,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
} else {
AsyncImage(
model = actualAvatar,
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))
}
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
}
},
navigationIcon = {
IconButton(
onClick = {
if (!isMessagesSelecting) onBack()
else onClose()
}
) {
Crossfade(targetState = !isMessagesSelecting) { state ->
Icon(
imageVector = if (state) {
Icons.AutoMirrored.Rounded.ArrowBack
} else {
Icons.Rounded.Close
},
contentDescription = if (state) "Close button"
else "Back button"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = {
if (isMessagesSelecting) {
AnimatedVisibility(showReplyAction) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
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
)
}
)
}
}
}
)
}
@@ -0,0 +1,101 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import dev.chrisbanes.haze.HazeState
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.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.theme.LocalThemeConfig
@OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MessagesHistoryTopBarContainer(
modifier: Modifier = Modifier,
hazeState: HazeState,
showReplyAction: Boolean,
topBarContainerColor: Color,
topBarContainerColorAlpha: Float,
isClickable: Boolean,
isMessagesSelecting: Boolean,
isPeerAccount: Boolean,
avatar: UiImage,
title: String,
showHorizontalProgressBar: Boolean,
showPinnedContainer: Boolean,
pinnedMessage: VkMessage?,
pinnedTitle: String?,
pinnedSummary: AnnotatedString?,
showUnpinButton: Boolean,
onTopBarClicked: () -> Unit = {},
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
val theme = LocalThemeConfig.current
Column(
modifier = modifier
.fillMaxWidth()
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.then(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
) {
MessagesHistoryTopBar(
modifier = modifier,
hazeState = hazeState,
showReplyAction = showReplyAction,
isClickable = isClickable,
isMessagesSelecting = isMessagesSelecting,
isPeerAccount = isPeerAccount,
avatar = avatar,
title = title,
onTopBarClicked = onTopBarClicked,
onBack = onBack,
onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onRefresh = onRefresh
)
if (showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
AnimatedVisibility(!showHorizontalProgressBar) {
HorizontalDivider()
}
if (showPinnedContainer) {
PinnedMessageContainer(
modifier = Modifier,
pinnedMessage = requireNotNull(pinnedMessage),
title = pinnedTitle.orDots(),
summary = pinnedSummary,
canChangePin = showUnpinButton,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
)
HorizontalDivider()
}
}
}