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="italic">Курсив</string>
<string name="underline">Подчёркнутый</string> <string name="underline">Подчёркнутый</string>
<string name="link">Ссылка</string> <string name="link">Ссылка</string>
<string name="regular">Обычный</string>
<string name="login_sign_up">Регистрация</string> <string name="login_sign_up">Регистрация</string>
<string name="login_forgot_password">Забыли пароль?</string> <string name="login_forgot_password">Забыли пароль?</string>
</resources> </resources>
+1
View File
@@ -345,6 +345,7 @@
<string name="italic">Italic</string> <string name="italic">Italic</string>
<string name="underline">Underline</string> <string name="underline">Underline</string>
<string name="link">Link</string> <string name="link">Link</string>
<string name="regular">Regular</string>
<string name="login_sign_up">Sign up</string> <string name="login_sign_up">Sign up</string>
<string name="login_forgot_password">Forgot password?</string> <string name="login_forgot_password">Forgot password?</string>
</resources> </resources>
@@ -106,10 +106,7 @@ fun ConversationsScreen(
onErrorViewButtonClicked: () -> Unit = {} onErrorViewButtonClicked: () -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1
val maxLines by remember(currentTheme) {
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
}
val listState = rememberLazyListState( val listState = rememberLazyListState(
initialFirstVisibleItemIndex = screenState.scrollIndex, initialFirstVisibleItemIndex = screenState.scrollIndex,
@@ -111,6 +111,7 @@ interface MessagesHistoryViewModel {
fun onItalicClicked() fun onItalicClicked()
fun onUnderlineClicked() fun onUnderlineClicked()
fun onLinkClicked() fun onLinkClicked()
fun onRegularClicked()
} }
class MessagesHistoryViewModelImpl( class MessagesHistoryViewModelImpl(
@@ -571,6 +572,11 @@ class MessagesHistoryViewModelImpl(
} }
override fun onRegularClicked() {
formatData = formatData.copy(items = emptyList())
updateStyles()
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message 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, onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked, onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked, onItalicRequested = viewModel::onItalicClicked,
onUnderlineRequested = viewModel::onUnderlineClicked onUnderlineRequested = viewModel::onUnderlineClicked,
onLinkRequested = viewModel::onLinkClicked,
onRegularRequested = viewModel::onRegularClicked
) )
HandleDialogs( HandleDialogs(
@@ -1,62 +1,23 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState 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.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween 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.Box
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.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState 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.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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.MaterialTheme
import androidx.compose.material3.Scaffold 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.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -68,42 +29,25 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.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.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.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.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi 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.data.UserConfig
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.MessagesHistoryScreenState import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@@ -140,7 +84,9 @@ fun MessagesHistoryScreen(
onDeleteSelectedButtonClicked: () -> Unit = {}, onDeleteSelectedButtonClicked: () -> Unit = {},
onBoldRequested: () -> Unit = {}, onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {}, onItalicRequested: () -> Unit = {},
onLinkRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {}, onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
@@ -179,10 +125,6 @@ fun MessagesHistoryScreen(
} }
} }
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
val topBarContainerColorAlpha by animateFloatAsState( val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f, targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha", label = "toolbarColorAlpha",
@@ -207,8 +149,6 @@ fun MessagesHistoryScreen(
mutableStateOf(0.dp) mutableStateOf(0.dp)
} }
val density = LocalDensity.current
val showReplyAction by remember(selectedMessages) { val showReplyAction by remember(selectedMessages) {
derivedStateOf { selectedMessages.size == 1 } derivedStateOf { selectedMessages.size == 1 }
} }
@@ -217,223 +157,40 @@ fun MessagesHistoryScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
topBar = { topBar = {
Column( val topBarTitle by remember(screenState, selectedMessages) {
modifier = Modifier derivedStateOf {
.fillMaxWidth() when {
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha)) screenState.isLoading -> context.getString(UiR.string.title_loading)
.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)
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})" selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title 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( MessagesHistoryTopBarContainer(
modifier = Modifier.defaultMinSize(minWidth = 140.dp), hazeState = hazeState,
expanded = dropDownMenuExpanded, showReplyAction = showReplyAction,
onDismissRequest = { topBarContainerColor = topBarContainerColor,
dropDownMenuExpanded = false topBarContainerColorAlpha = topBarContainerColorAlpha,
}, isClickable = !(screenState.isLoading && messages.isEmpty()),
offset = DpOffset(x = (-4).dp, y = (-60).dp) isMessagesSelecting = selectedMessages.isNotEmpty(),
) { isPeerAccount = screenState.conversationId == UserConfig.userId,
DropdownMenuItem( avatar = screenState.avatar,
onClick = { title = topBarTitle,
onRefresh() showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(),
dropDownMenuExpanded = false showPinnedContainer = !screenState.isLoading && pinnedMessage != null,
}, pinnedMessage = pinnedMessage,
text = { pinnedTitle = screenState.pinnedTitle,
Text(text = stringResource(UiR.string.action_refresh)) pinnedSummary = screenState.pinnedSummary,
}, showUnpinButton = screenState.conversation.canChangePin,
leadingIcon = { onTopBarClicked = onTopBarClicked,
Icon( onBack = onBack,
imageVector = Icons.Rounded.Refresh, onClose = onClose,
contentDescription = null onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
) onRefresh = onRefresh,
}
)
}
}
}
)
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,
onPinnedMessageClicked = onPinnedMessageClicked, onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
) )
HorizontalDivider()
}
}
} }
) { padding -> ) { padding ->
Box( Box(
@@ -472,254 +229,23 @@ fun MessagesHistoryScreen(
onMessageLongClicked = onMessageLongClicked onMessageLongClicked = onMessageLongClicked
) )
Column( MessagesHistoryInputBar(
modifier = Modifier modifier = Modifier.align(Alignment.BottomStart),
.fillMaxWidth() message = screenState.message,
.align(Alignment.BottomStart) onMessageInputChanged = onMessageInputChanged,
.background(Color.Transparent) onBoldRequested = onBoldRequested,
.padding(bottom = 8.dp) onItalicRequested = onItalicRequested,
.navigationBarsPadding() onUnderlineRequested = onUnderlineRequested,
.imePadding() onLinkRequested = onLinkRequested,
) { onRegularRequested = onRegularRequested,
Row( hazeState = hazeState,
modifier = Modifier showEmojiButton = showEmojiButton,
.fillMaxWidth() actionMode = screenState.actionMode,
.defaultMinSize(minHeight = 60.dp) onSetMessageBarHeight = { messageBarHeight = it },
.imeNestedScroll(), onEmojiButtonLongClicked = onEmojiButtonLongClicked,
verticalAlignment = Alignment.CenterVertically onAttachmentButtonClicked = onAttachmentButtonClicked,
) { onActionButtonClicked = onActionButtonClicked
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 {
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 { when {
screenState.isLoading && messages.values.isEmpty() -> { 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()
}
}
}