mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Redesign UI to Google Messages
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -22,8 +21,8 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -41,13 +40,16 @@ import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.outlined.AttachFile
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.SentimentSatisfied
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -58,49 +60,48 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.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 kotlin.time.Clock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.*
|
||||
import ru.fromchat.cd_close
|
||||
import ru.fromchat.cd_emoji
|
||||
import ru.fromchat.cd_pick_file
|
||||
import ru.fromchat.cd_pick_image
|
||||
import ru.fromchat.cd_remove
|
||||
import ru.fromchat.cd_send
|
||||
import ru.fromchat.message_corrupted_short
|
||||
import ru.fromchat.message_editing_title
|
||||
import ru.fromchat.message_placeholder
|
||||
import ru.fromchat.message_replying_to
|
||||
import ru.fromchat.suspend_chat_banner_message
|
||||
import kotlin.time.Clock
|
||||
|
||||
private val ChatInputChromeHeight = 46.dp
|
||||
private val ChatInputChromeHeight = 54.dp
|
||||
private val ChatInputLineVerticalPadding = 12.dp
|
||||
/** Vertical padding inside the text field decoration (keep total row height aligned with chrome). */
|
||||
private val ChatInputDecorationVerticalPadding = 8.dp
|
||||
private val ChatInputTextLineHeight = 22.sp
|
||||
|
||||
private val ChatInputIconSlotSize = 36.dp
|
||||
private val ChatInputIconSlotVerticalInset =
|
||||
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f
|
||||
|
||||
private val ChatInputSendSpringFloat = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
)
|
||||
private val ChatInputSendSpringDp = spring<Dp>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun <T> AnimatedPreviewBar(
|
||||
state: T?,
|
||||
@@ -225,7 +226,6 @@ private fun AttachmentChip(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
text: String,
|
||||
@@ -241,7 +241,9 @@ fun ChatInput(
|
||||
currentUserId: Int? = null,
|
||||
isReadOnly: Boolean = false,
|
||||
onReadOnlyMessageClick: () -> Unit = {},
|
||||
snackbarHostState: SnackbarHostState? = null,
|
||||
) {
|
||||
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) }
|
||||
@@ -273,7 +275,6 @@ fun ChatInput(
|
||||
if (text.isNotBlank()) {
|
||||
typingJob?.cancel()
|
||||
typingHandler.sendTyping()
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
typingJob = scope.launch {
|
||||
delay(3000)
|
||||
typingHandler.stopTyping()
|
||||
@@ -294,6 +295,7 @@ fun ChatInput(
|
||||
val corruptedShort = stringResource(Res.string.message_corrupted_short)
|
||||
val editingTitle = stringResource(Res.string.message_editing_title)
|
||||
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
|
||||
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -308,13 +310,8 @@ fun ChatInput(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
Dp.Hairline,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
pillShape,
|
||||
)
|
||||
.clip(pillShape)
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin())
|
||||
.hazeEffect(state = hazeState, style = composerHazeStyle)
|
||||
.clickable(enabled = true) { onReadOnlyMessageClick() },
|
||||
) {
|
||||
Text(
|
||||
@@ -328,43 +325,25 @@ fun ChatInput(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val sendTransition = updateTransition(
|
||||
targetState = canSend,
|
||||
label = "chat_input_send_transition",
|
||||
)
|
||||
|
||||
val progress by sendTransition.animateFloat(
|
||||
transitionSpec = { ChatInputSendSpringFloat },
|
||||
label = "chat_input_send_progress",
|
||||
) { state -> if (state) 1f else 0f }
|
||||
|
||||
val slotMax = ChatInputChromeHeight + 8.dp
|
||||
val slotPadding by sendTransition.animateDp(
|
||||
transitionSpec = { ChatInputSendSpringDp },
|
||||
label = "chat_input_slot_padding",
|
||||
) { state -> if (state) slotMax else 0.dp }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = slotPadding),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Inner row must not use fillMaxWidth() here: it shares this outer Row with the
|
||||
// send/voice slot. fillMaxWidth() would consume all width and leave the sibling Box at 0dp.
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
.border(
|
||||
Dp.Hairline,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
pillShape,
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
|
||||
.clip(pillShape)
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()),
|
||||
.hazeEffect(state = hazeState, style = composerHazeStyle),
|
||||
) {
|
||||
AnimatedPreviewBar(replyTo) { reply ->
|
||||
val replySubtitle = if (reply.isContentCorrupted) {
|
||||
@@ -425,7 +404,7 @@ fun ChatInput(
|
||||
.defaultMinSize(minHeight = ChatInputChromeHeight)
|
||||
.wrapContentHeight()
|
||||
.padding(horizontal = 6.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -455,7 +434,6 @@ fun ChatInput(
|
||||
enabled = !isReadOnly,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.wrapContentHeight()
|
||||
.animateContentSize(),
|
||||
textStyle = inputTextStyle,
|
||||
singleLine = false,
|
||||
@@ -467,7 +445,7 @@ fun ChatInput(
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = ChatInputLineVerticalPadding / 2,
|
||||
vertical = ChatInputLineVerticalPadding,
|
||||
vertical = ChatInputDecorationVerticalPadding,
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
@@ -522,17 +500,48 @@ fun ChatInput(
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed slot next to the pill; width must stay non-zero (see Row + fillMaxWidth pitfall above).
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
modifier = Modifier
|
||||
.size(ChatInputChromeHeight)
|
||||
.clip(CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (progress > 0.01f) {
|
||||
val alpha by sendTransition.animateFloat(
|
||||
transitionSpec = { ChatInputSendSpringFloat },
|
||||
label = "chat_input_send_alpha",
|
||||
) { state -> if (state) 1f else 0f }
|
||||
val travel = ChatInputChromeHeight * 1.2f
|
||||
val offsetX = travel * (1f - progress)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = canSend,
|
||||
transitionSpec = {
|
||||
val exitDuration = 140
|
||||
val enterDuration = 160
|
||||
val enterDelay = exitDuration // start after outbound scale+fade completes
|
||||
val scaleFrom = 0.82f
|
||||
// EnterTogetherWithExit: fading/scaling incoming content while outgoing runs.
|
||||
(
|
||||
fadeIn(
|
||||
animationSpec = tween(enterDuration, delayMillis = enterDelay),
|
||||
) + scaleIn(
|
||||
animationSpec = tween(enterDuration, delayMillis = enterDelay),
|
||||
initialScale = scaleFrom,
|
||||
)
|
||||
) togetherWith (
|
||||
fadeOut(animationSpec = tween(exitDuration)) +
|
||||
scaleOut(
|
||||
animationSpec = tween(exitDuration),
|
||||
targetScale = scaleFrom,
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
label = "chat_input_send_or_voice",
|
||||
) { showSend ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
||||
.hazeEffect(state = hazeState, style = composerHazeStyle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (showSend) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
val plaintext = text.trim().ifBlank { "" }
|
||||
@@ -541,16 +550,35 @@ fun ChatInput(
|
||||
attachments = emptyList()
|
||||
typingHandler.stopTyping()
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(ChatInputChromeHeight)
|
||||
.offset(x = offsetX)
|
||||
.alpha(alpha),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = cdSend,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
snackbarHostState?.showSnackbar(message = cdVoiceUnavailable)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = CircleShape,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = cdVoiceUnavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -559,4 +587,6 @@ fun ChatInput(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -9,12 +10,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -55,25 +55,28 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.*
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.AttachmentUploadJob
|
||||
import ru.fromchat.api.AttachmentUploadQueue
|
||||
import ru.fromchat.api.ConnectionStateStore
|
||||
import ru.fromchat.api.ConnectionStatus
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.api.WebSocketUpdatesData
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.calls.CallStore
|
||||
import ru.fromchat.cd_call
|
||||
import ru.fromchat.chat_group_label
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.net.NetworkConnectivity
|
||||
import ru.fromchat.status_connecting
|
||||
import ru.fromchat.status_updating
|
||||
import ru.fromchat.ui.HapticFeedbackEvent
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.rememberHapticFeedback
|
||||
import ru.fromchat.ui.chat.getImageAspectRatio
|
||||
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
|
||||
import ru.fromchat.utils.formatLastSeen
|
||||
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
||||
@@ -123,6 +126,7 @@ fun ChatScreen(
|
||||
val hazeState = rememberHazeState(
|
||||
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
|
||||
)
|
||||
val chatBottomHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||
|
||||
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
@@ -372,6 +376,7 @@ fun ChatScreen(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 0f,
|
||||
@@ -473,7 +478,8 @@ fun ChatScreen(
|
||||
) { innerPadding ->
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp
|
||||
val floatingHeaderClearance =
|
||||
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -594,7 +600,6 @@ fun ChatScreen(
|
||||
callContentDescription = cdCall,
|
||||
titleChrome = {
|
||||
ChatFloatingTitleChrome(
|
||||
hazeState = hazeState,
|
||||
title = panelState.title,
|
||||
titleAvatar = panelState.titleAvatar,
|
||||
profileUserId = profileUserId,
|
||||
|
||||
@@ -9,60 +9,69 @@ import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
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.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBackIos
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import com.pr0gramm3r101.utils.conditional
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.*
|
||||
import ru.fromchat.chat_members_count
|
||||
import ru.fromchat.ui.ConnectingEllipsis
|
||||
import ru.fromchat.ui.scaleOnPress
|
||||
|
||||
/**
|
||||
* Bottom-corner arc radius for [ChatFloatingHeaderBox] silhouette.
|
||||
* Matches the inset of the horizontal bottom segment (flat part starts at [this] dx from edges).
|
||||
* The scallop’s vertical depth below the flat bottom is **this** value px; [ChatFloatingHeaderBox]
|
||||
* adds exactly that much height below the measured [TopAppBar] so the arc is not painted over content.
|
||||
*/
|
||||
val ChatFloatingHeaderBottomArcRadius: Dp = 35.dp
|
||||
|
||||
/**
|
||||
* Telegram-style floating chrome: iOS-like frosted pill (Haze "thin" material + translucent fill),
|
||||
* circular back control, and a full-width progressive blur plate behind the header row.
|
||||
@@ -72,40 +81,17 @@ import ru.fromchat.ui.scaleOnPress
|
||||
fun ChatFloatingBackButton(
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = CircleShape
|
||||
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.scaleOnPress(
|
||||
scale = 0.88f,
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
clipShape = shape,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(shape)
|
||||
.background(fill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBackIos,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(28.dp),
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun ChatFloatingTitleChrome(
|
||||
hazeState: HazeState,
|
||||
title: String,
|
||||
titleAvatar: AvatarInfo?,
|
||||
profileUserId: Int?,
|
||||
@@ -122,45 +108,29 @@ fun ChatFloatingTitleChrome(
|
||||
chatGroupLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pillShape = RoundedCornerShape(percent = 50)
|
||||
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
|
||||
val pillClickable = profileUserId != null && onTitleClick != null
|
||||
Box(
|
||||
modifier = modifier
|
||||
.wrapContentWidth()
|
||||
.then(
|
||||
if (pillClickable) {
|
||||
Modifier.scaleOnPress(
|
||||
scale = 0.96f,
|
||||
onClick = onTitleClick,
|
||||
clipShape = pillShape,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.wrapContentHeight()
|
||||
.heightIn(min = 44.dp)
|
||||
.clip(pillShape)
|
||||
.background(fill)
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin())
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = modifier.conditional(
|
||||
pillClickable,
|
||||
`if` = {
|
||||
Modifier.scaleOnPress(
|
||||
scale = 0.96f,
|
||||
onClick = onTitleClick
|
||||
)
|
||||
}
|
||||
),
|
||||
) {
|
||||
when {
|
||||
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
|
||||
val avatar = titleAvatar
|
||||
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
|
||||
val displayName = titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: title.takeIf { it.isNotBlank() }
|
||||
?: ""
|
||||
with(sharedTransitionScope) {
|
||||
Avatar(
|
||||
profilePictureUrl = avatar?.profilePictureUrl,
|
||||
profilePictureUrl = titleAvatar?.profilePictureUrl,
|
||||
displayName = displayName,
|
||||
modifier = Modifier
|
||||
.sharedElement(
|
||||
@@ -172,6 +142,7 @@ fun ChatFloatingTitleChrome(
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
!hideTitleBarAvatar -> {
|
||||
titleAvatar?.let { avatar ->
|
||||
Avatar(
|
||||
@@ -182,6 +153,7 @@ fun ChatFloatingTitleChrome(
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
onAvatarSlotBounds != null -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -201,6 +173,7 @@ fun ChatFloatingTitleChrome(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
else -> {
|
||||
titleAvatar?.let {
|
||||
Spacer(modifier = Modifier.width(40.dp))
|
||||
@@ -250,6 +223,7 @@ fun ChatFloatingTitleChrome(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
key == "connecting" -> {
|
||||
val st = MaterialTheme.typography.bodySmall
|
||||
val col = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -269,12 +243,14 @@ fun ChatFloatingTitleChrome(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
key == "typing" -> {
|
||||
TypingIndicator(
|
||||
typingUsers = currentTypingUsers.map { it.username },
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
key.startsWith("presence:") -> {
|
||||
val text = key.removePrefix("presence:")
|
||||
Text(
|
||||
@@ -284,6 +260,7 @@ fun ChatFloatingTitleChrome(
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
key == "group" -> {
|
||||
Text(
|
||||
text = chatGroupLabel,
|
||||
@@ -292,6 +269,7 @@ fun ChatFloatingTitleChrome(
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
key.startsWith("members:") -> {
|
||||
val n = key.removePrefix("members:").toIntOrNull() ?: 0
|
||||
Text(
|
||||
@@ -306,9 +284,8 @@ fun ChatFloatingTitleChrome(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatFloatingHeaderBox(
|
||||
hazeState: HazeState,
|
||||
@@ -320,90 +297,136 @@ fun ChatFloatingHeaderBox(
|
||||
titleChrome: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sideSlot = 56.dp
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)
|
||||
val headerHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||
val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
|
||||
val density = LocalDensity.current
|
||||
val statusBarDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val blurPlateHeight = statusBarDp + 64.dp + 12.dp
|
||||
val shape = remember(bottomCornerRadius) { BottomInsetTopBarShape(bottomCornerRadius) }
|
||||
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r);
|
||||
// stack measured bar height + that depth so the scallop is never overlapped by TopAppBar children.
|
||||
val arcExtentPx = with(density) { bottomCornerRadius.roundToPx() }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(blurPlateHeight)
|
||||
.align(Alignment.TopCenter)
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.height(IntrinsicSize.Min)
|
||||
.zIndex(1f)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(start = 8.dp, end = 8.dp, top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(sideSlot)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().padding(6.dp)) {
|
||||
SubcomposeLayout(modifier = modifier.fillMaxWidth()) { constraints ->
|
||||
val heightUnbounded = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
|
||||
val topBarPlaceable = subcompose("topBar") {
|
||||
TopAppBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
windowInsets = WindowInsets.statusBars,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
navigationIcon = {
|
||||
ChatFloatingBackButton(
|
||||
contentDescription = backContentDescription,
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.aspectRatio(1f, matchHeightConstraintsFirst = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.wrapContentHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
titleChrome()
|
||||
}
|
||||
},
|
||||
title = { titleChrome() },
|
||||
actions = {
|
||||
if (showCallButton) {
|
||||
val shape = CircleShape
|
||||
val outline = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
|
||||
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(sideSlot)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f, matchHeightConstraintsFirst = true)
|
||||
.clip(shape)
|
||||
.border(Dp.Hairline, outline, shape)
|
||||
.background(fill)
|
||||
.clickable(onClick = onCallClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
IconButton(onClick = onCallClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = callContentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(28.dp),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(sideSlot))
|
||||
},
|
||||
)
|
||||
}.first().measure(heightUnbounded)
|
||||
|
||||
val layoutWidth = topBarPlaceable.width.coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||
val layoutHeight = topBarPlaceable.height + arcExtentPx
|
||||
|
||||
val bgPlaceable = subcompose("background") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(shape)
|
||||
.background(containerColor)
|
||||
.hazeEffect(state = hazeState, style = headerHazeStyle),
|
||||
)
|
||||
}.first().measure(Constraints.fixed(layoutWidth, layoutHeight))
|
||||
|
||||
layout(layoutWidth, layoutHeight) {
|
||||
bgPlaceable.place(0, 0)
|
||||
topBarPlaceable.place(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cubic-fit constant for a quarter circle: `4/3 * tan(pi/8)` (~0.5522847498). */
|
||||
private const val CircularCornerBezierKappa = 0.5522847498f
|
||||
|
||||
/**
|
||||
* Bottom edge: inset horizontal segment (`y = height − r`), then quarter-circle corners with
|
||||
* circle centers **`(r, height)` / `(width − r, height)`** — same as the SVG (`M r y … C …`).
|
||||
*
|
||||
* Compose's [Path.arcTo] uses an ellipse inscribed in `Rect` whose **center is rect center**, so it
|
||||
* cannot express these corners reliably; cubic segments match the arcs instead.
|
||||
*/
|
||||
private class BottomInsetTopBarShape(
|
||||
private val cornerRadius: Dp,
|
||||
) : androidx.compose.ui.graphics.Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val r = with(density) { cornerRadius.toPx() }.coerceIn(0f, minOf(w / 2f, h))
|
||||
val straightY = (h - r).coerceAtLeast(0f)
|
||||
val k = CircularCornerBezierKappa * r
|
||||
|
||||
val path = Path().apply {
|
||||
moveTo(r, straightY)
|
||||
lineTo(w - r, straightY)
|
||||
// Bottom-right: center (w - r, h), arc from (w - r, straightY) to (w, h).
|
||||
cubicTo(
|
||||
x1 = w - r + k,
|
||||
y1 = straightY,
|
||||
x2 = w,
|
||||
y2 = h - k,
|
||||
x3 = w,
|
||||
y3 = h,
|
||||
)
|
||||
lineTo(w, 0f)
|
||||
lineTo(0f, 0f)
|
||||
lineTo(0f, h)
|
||||
// Bottom-left: center (r, h), arc from (0, h) to (r, straightY).
|
||||
cubicTo(
|
||||
x1 = 0f,
|
||||
y1 = h - k,
|
||||
x2 = r - k,
|
||||
y2 = straightY,
|
||||
x3 = r,
|
||||
y3 = straightY,
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [HazeStyle] for chat chrome using only [androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer]
|
||||
* (24dp blur + luminance-scaled tint, slightly denser than old “thin” defaults), without [dev.chrisbanes.haze.materials.HazeMaterials].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
|
||||
val surface = MaterialTheme.colorScheme.surfaceContainer
|
||||
val tintAlpha = if (surface.luminance() >= 0.5f) 0.74f else 0.79f
|
||||
return remember(surface) {
|
||||
HazeStyle(
|
||||
blurRadius = 24.dp,
|
||||
backgroundColor = surface,
|
||||
tint = HazeTint(surface.copy(alpha = tintAlpha)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user