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
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.tween
|
||||||
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.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
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.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.AttachFile
|
||||||
import androidx.compose.material.icons.outlined.Image
|
import androidx.compose.material.icons.outlined.Image
|
||||||
import androidx.compose.material.icons.outlined.SentimentSatisfied
|
import androidx.compose.material.icons.outlined.SentimentSatisfied
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -58,49 +60,48 @@ 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.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.Message
|
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
|
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 ChatInputTextLineHeight = 22.sp
|
||||||
|
|
||||||
private val ChatInputIconSlotSize = 36.dp
|
private val ChatInputIconSlotSize = 36.dp
|
||||||
private val ChatInputIconSlotVerticalInset =
|
private val ChatInputIconSlotVerticalInset =
|
||||||
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f
|
(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
|
@Composable
|
||||||
private fun <T> AnimatedPreviewBar(
|
private fun <T> AnimatedPreviewBar(
|
||||||
state: T?,
|
state: T?,
|
||||||
@@ -225,7 +226,6 @@ private fun AttachmentChip(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInput(
|
fun ChatInput(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -241,7 +241,9 @@ fun ChatInput(
|
|||||||
currentUserId: Int? = null,
|
currentUserId: Int? = null,
|
||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
onReadOnlyMessageClick: () -> Unit = {},
|
onReadOnlyMessageClick: () -> Unit = {},
|
||||||
|
snackbarHostState: SnackbarHostState? = null,
|
||||||
) {
|
) {
|
||||||
|
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||||
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) }
|
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) }
|
||||||
@@ -273,7 +275,6 @@ fun ChatInput(
|
|||||||
if (text.isNotBlank()) {
|
if (text.isNotBlank()) {
|
||||||
typingJob?.cancel()
|
typingJob?.cancel()
|
||||||
typingHandler.sendTyping()
|
typingHandler.sendTyping()
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
|
||||||
typingJob = scope.launch {
|
typingJob = scope.launch {
|
||||||
delay(3000)
|
delay(3000)
|
||||||
typingHandler.stopTyping()
|
typingHandler.stopTyping()
|
||||||
@@ -294,6 +295,7 @@ fun ChatInput(
|
|||||||
val corruptedShort = stringResource(Res.string.message_corrupted_short)
|
val corruptedShort = stringResource(Res.string.message_corrupted_short)
|
||||||
val editingTitle = stringResource(Res.string.message_editing_title)
|
val editingTitle = stringResource(Res.string.message_editing_title)
|
||||||
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
|
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
|
||||||
|
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -308,13 +310,8 @@ fun ChatInput(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.border(
|
|
||||||
Dp.Hairline,
|
|
||||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
|
||||||
pillShape,
|
|
||||||
)
|
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin())
|
.hazeEffect(state = hazeState, style = composerHazeStyle)
|
||||||
.clickable(enabled = true) { onReadOnlyMessageClick() },
|
.clickable(enabled = true) { onReadOnlyMessageClick() },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -328,43 +325,25 @@ fun ChatInput(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val sendTransition = updateTransition(
|
Row(
|
||||||
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(),
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(end = slotPadding),
|
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.animateContentSize()
|
.animateContentSize()
|
||||||
.border(
|
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
|
||||||
Dp.Hairline,
|
|
||||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
|
||||||
pillShape,
|
|
||||||
)
|
|
||||||
.clip(pillShape)
|
.clip(pillShape)
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()),
|
.hazeEffect(state = hazeState, style = composerHazeStyle),
|
||||||
) {
|
) {
|
||||||
AnimatedPreviewBar(replyTo) { reply ->
|
AnimatedPreviewBar(replyTo) { reply ->
|
||||||
val replySubtitle = if (reply.isContentCorrupted) {
|
val replySubtitle = if (reply.isContentCorrupted) {
|
||||||
@@ -425,7 +404,7 @@ fun ChatInput(
|
|||||||
.defaultMinSize(minHeight = ChatInputChromeHeight)
|
.defaultMinSize(minHeight = ChatInputChromeHeight)
|
||||||
.wrapContentHeight()
|
.wrapContentHeight()
|
||||||
.padding(horizontal = 6.dp, vertical = 0.dp),
|
.padding(horizontal = 6.dp, vertical = 0.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -455,7 +434,6 @@ fun ChatInput(
|
|||||||
enabled = !isReadOnly,
|
enabled = !isReadOnly,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.wrapContentHeight()
|
|
||||||
.animateContentSize(),
|
.animateContentSize(),
|
||||||
textStyle = inputTextStyle,
|
textStyle = inputTextStyle,
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
@@ -467,7 +445,7 @@ fun ChatInput(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(
|
.padding(
|
||||||
horizontal = ChatInputLineVerticalPadding / 2,
|
horizontal = ChatInputLineVerticalPadding / 2,
|
||||||
vertical = ChatInputLineVerticalPadding,
|
vertical = ChatInputDecorationVerticalPadding,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
@@ -522,36 +500,88 @@ fun ChatInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed slot next to the pill; width must stay non-zero (see Row + fillMaxWidth pitfall above).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.align(Alignment.BottomEnd),
|
modifier = Modifier
|
||||||
|
.size(ChatInputChromeHeight)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (progress > 0.01f) {
|
AnimatedContent(
|
||||||
val alpha by sendTransition.animateFloat(
|
targetState = canSend,
|
||||||
transitionSpec = { ChatInputSendSpringFloat },
|
transitionSpec = {
|
||||||
label = "chat_input_send_alpha",
|
val exitDuration = 140
|
||||||
) { state -> if (state) 1f else 0f }
|
val enterDuration = 160
|
||||||
val travel = ChatInputChromeHeight * 1.2f
|
val enterDelay = exitDuration // start after outbound scale+fade completes
|
||||||
val offsetX = travel * (1f - progress)
|
val scaleFrom = 0.82f
|
||||||
|
// EnterTogetherWithExit: fading/scaling incoming content while outgoing runs.
|
||||||
FilledIconButton(
|
(
|
||||||
onClick = {
|
fadeIn(
|
||||||
val plaintext = text.trim().ifBlank { "" }
|
animationSpec = tween(enterDuration, delayMillis = enterDelay),
|
||||||
onSend(plaintext, attachments)
|
) + scaleIn(
|
||||||
onTextChange("")
|
animationSpec = tween(enterDuration, delayMillis = enterDelay),
|
||||||
attachments = emptyList()
|
initialScale = scaleFrom,
|
||||||
typingHandler.stopTyping()
|
)
|
||||||
},
|
) 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
|
modifier = Modifier
|
||||||
.size(ChatInputChromeHeight)
|
.fillMaxSize()
|
||||||
.offset(x = offsetX)
|
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
||||||
.alpha(alpha),
|
.hazeEffect(state = hazeState, style = composerHazeStyle),
|
||||||
shape = CircleShape,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (showSend) {
|
||||||
imageVector = Icons.Filled.ArrowUpward,
|
FilledIconButton(
|
||||||
contentDescription = cdSend,
|
onClick = {
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
val plaintext = text.trim().ifBlank { "" }
|
||||||
)
|
onSend(plaintext, attachments)
|
||||||
|
onTextChange("")
|
||||||
|
attachments = emptyList()
|
||||||
|
typingHandler.stopTyping()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowUpward,
|
||||||
|
contentDescription = cdSend,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ru.fromchat.ui.chat
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -55,25 +55,28 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.*
|
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.ProfileCache
|
|
||||||
import ru.fromchat.api.AttachmentUploadJob
|
import ru.fromchat.api.AttachmentUploadJob
|
||||||
import ru.fromchat.api.AttachmentUploadQueue
|
import ru.fromchat.api.AttachmentUploadQueue
|
||||||
import ru.fromchat.api.ConnectionStateStore
|
import ru.fromchat.api.ConnectionStateStore
|
||||||
import ru.fromchat.api.ConnectionStatus
|
import ru.fromchat.api.ConnectionStatus
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UserStatusStore
|
import ru.fromchat.api.UserStatusStore
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.api.WebSocketUpdatesData
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
|
import ru.fromchat.back
|
||||||
import ru.fromchat.calls.CallStore
|
import ru.fromchat.calls.CallStore
|
||||||
|
import ru.fromchat.cd_call
|
||||||
|
import ru.fromchat.chat_group_label
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
|
import ru.fromchat.status_connecting
|
||||||
|
import ru.fromchat.status_updating
|
||||||
import ru.fromchat.ui.HapticFeedbackEvent
|
import ru.fromchat.ui.HapticFeedbackEvent
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.rememberHapticFeedback
|
import ru.fromchat.ui.rememberHapticFeedback
|
||||||
import ru.fromchat.ui.chat.getImageAspectRatio
|
|
||||||
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
|
import ru.fromchat.ui.suspension.SuspendedAccountSupportSheet
|
||||||
import ru.fromchat.utils.formatLastSeen
|
import ru.fromchat.utils.formatLastSeen
|
||||||
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
||||||
@@ -123,6 +126,7 @@ fun ChatScreen(
|
|||||||
val hazeState = rememberHazeState(
|
val hazeState = rememberHazeState(
|
||||||
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
|
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
|
||||||
)
|
)
|
||||||
|
val chatBottomHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
|
|
||||||
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
||||||
val statusMap by UserStatusStore.status.collectAsState()
|
val statusMap by UserStatusStore.status.collectAsState()
|
||||||
@@ -372,6 +376,7 @@ fun ChatScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.windowInsetsPadding(WindowInsets.ime)
|
.windowInsetsPadding(WindowInsets.ime)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||||
progressive = HazeProgressive.verticalGradient(
|
progressive = HazeProgressive.verticalGradient(
|
||||||
startIntensity = 0f,
|
startIntensity = 0f,
|
||||||
@@ -473,7 +478,8 @@ fun ChatScreen(
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -594,7 +600,6 @@ fun ChatScreen(
|
|||||||
callContentDescription = cdCall,
|
callContentDescription = cdCall,
|
||||||
titleChrome = {
|
titleChrome = {
|
||||||
ChatFloatingTitleChrome(
|
ChatFloatingTitleChrome(
|
||||||
hazeState = hazeState,
|
|
||||||
title = panelState.title,
|
title = panelState.title,
|
||||||
titleAvatar = panelState.titleAvatar,
|
titleAvatar = panelState.titleAvatar,
|
||||||
profileUserId = profileUserId,
|
profileUserId = profileUserId,
|
||||||
|
|||||||
@@ -9,60 +9,69 @@ import androidx.compose.animation.slideInVertically
|
|||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
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.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.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.material.icons.filled.Call
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.compose.ui.geometry.Rect
|
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.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
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.HazeState
|
||||||
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.*
|
import ru.fromchat.chat_members_count
|
||||||
import ru.fromchat.ui.ConnectingEllipsis
|
import ru.fromchat.ui.ConnectingEllipsis
|
||||||
import ru.fromchat.ui.scaleOnPress
|
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),
|
* 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.
|
* 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(
|
fun ChatFloatingBackButton(
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val shape = CircleShape
|
IconButton(onClick = onClick) {
|
||||||
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
|
Icon(
|
||||||
Box(
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
modifier = modifier
|
contentDescription = contentDescription
|
||||||
.scaleOnPress(
|
)
|
||||||
scale = 0.88f,
|
|
||||||
onClick = onClick,
|
|
||||||
indication = null,
|
|
||||||
clipShape = shape,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clip(shape)
|
|
||||||
.background(fill),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBackIos,
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(28.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatFloatingTitleChrome(
|
fun ChatFloatingTitleChrome(
|
||||||
hazeState: HazeState,
|
|
||||||
title: String,
|
title: String,
|
||||||
titleAvatar: AvatarInfo?,
|
titleAvatar: AvatarInfo?,
|
||||||
profileUserId: Int?,
|
profileUserId: Int?,
|
||||||
@@ -122,45 +108,29 @@ fun ChatFloatingTitleChrome(
|
|||||||
chatGroupLabel: String,
|
chatGroupLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val pillShape = RoundedCornerShape(percent = 50)
|
|
||||||
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
|
|
||||||
val pillClickable = profileUserId != null && onTitleClick != null
|
val pillClickable = profileUserId != null && onTitleClick != null
|
||||||
Box(
|
|
||||||
modifier = modifier
|
Row(
|
||||||
.wrapContentWidth()
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.then(
|
horizontalArrangement = Arrangement.Center,
|
||||||
if (pillClickable) {
|
modifier = modifier.conditional(
|
||||||
Modifier.scaleOnPress(
|
pillClickable,
|
||||||
scale = 0.96f,
|
`if` = {
|
||||||
onClick = onTitleClick,
|
Modifier.scaleOnPress(
|
||||||
clipShape = pillShape,
|
scale = 0.96f,
|
||||||
)
|
onClick = onTitleClick
|
||||||
} 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,
|
|
||||||
) {
|
|
||||||
when {
|
when {
|
||||||
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
|
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
|
||||||
val avatar = titleAvatar
|
val displayName = titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||||
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
|
|
||||||
?: title.takeIf { it.isNotBlank() }
|
?: title.takeIf { it.isNotBlank() }
|
||||||
?: ""
|
?: ""
|
||||||
with(sharedTransitionScope) {
|
with(sharedTransitionScope) {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = avatar?.profilePictureUrl,
|
profilePictureUrl = titleAvatar?.profilePictureUrl,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sharedElement(
|
.sharedElement(
|
||||||
@@ -172,6 +142,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
!hideTitleBarAvatar -> {
|
!hideTitleBarAvatar -> {
|
||||||
titleAvatar?.let { avatar ->
|
titleAvatar?.let { avatar ->
|
||||||
Avatar(
|
Avatar(
|
||||||
@@ -182,6 +153,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onAvatarSlotBounds != null -> {
|
onAvatarSlotBounds != null -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -201,6 +173,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
titleAvatar?.let {
|
titleAvatar?.let {
|
||||||
Spacer(modifier = Modifier.width(40.dp))
|
Spacer(modifier = Modifier.width(40.dp))
|
||||||
@@ -226,7 +199,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
targetState = subtitleKey,
|
targetState = subtitleKey,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||||
(slideOutVertically { -it / 2 } + fadeOut())
|
(slideOutVertically { -it / 2 } + fadeOut())
|
||||||
},
|
},
|
||||||
label = "chat_subtitle",
|
label = "chat_subtitle",
|
||||||
) { key ->
|
) { key ->
|
||||||
@@ -250,6 +223,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key == "connecting" -> {
|
key == "connecting" -> {
|
||||||
val st = MaterialTheme.typography.bodySmall
|
val st = MaterialTheme.typography.bodySmall
|
||||||
val col = MaterialTheme.colorScheme.onSurfaceVariant
|
val col = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -269,12 +243,14 @@ fun ChatFloatingTitleChrome(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key == "typing" -> {
|
key == "typing" -> {
|
||||||
TypingIndicator(
|
TypingIndicator(
|
||||||
typingUsers = currentTypingUsers.map { it.username },
|
typingUsers = currentTypingUsers.map { it.username },
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
key.startsWith("presence:") -> {
|
key.startsWith("presence:") -> {
|
||||||
val text = key.removePrefix("presence:")
|
val text = key.removePrefix("presence:")
|
||||||
Text(
|
Text(
|
||||||
@@ -284,6 +260,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
key == "group" -> {
|
key == "group" -> {
|
||||||
Text(
|
Text(
|
||||||
text = chatGroupLabel,
|
text = chatGroupLabel,
|
||||||
@@ -292,6 +269,7 @@ fun ChatFloatingTitleChrome(
|
|||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
key.startsWith("members:") -> {
|
key.startsWith("members:") -> {
|
||||||
val n = key.removePrefix("members:").toIntOrNull() ?: 0
|
val n = key.removePrefix("members:").toIntOrNull() ?: 0
|
||||||
Text(
|
Text(
|
||||||
@@ -304,11 +282,10 @@ fun ChatFloatingTitleChrome(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatFloatingHeaderBox(
|
fun ChatFloatingHeaderBox(
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
@@ -320,90 +297,136 @@ fun ChatFloatingHeaderBox(
|
|||||||
titleChrome: @Composable () -> Unit,
|
titleChrome: @Composable () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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 density = LocalDensity.current
|
||||||
val statusBarDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val shape = remember(bottomCornerRadius) { BottomInsetTopBarShape(bottomCornerRadius) }
|
||||||
val blurPlateHeight = statusBarDp + 64.dp + 12.dp
|
// [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(
|
SubcomposeLayout(modifier = modifier.fillMaxWidth()) { constraints ->
|
||||||
modifier = modifier
|
val heightUnbounded = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
|
||||||
.fillMaxWidth()
|
val topBarPlaceable = subcompose("topBar") {
|
||||||
.wrapContentHeight(),
|
TopAppBar(
|
||||||
) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Box(
|
windowInsets = WindowInsets.statusBars,
|
||||||
modifier = Modifier
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
.fillMaxWidth()
|
containerColor = Color.Transparent,
|
||||||
.height(blurPlateHeight)
|
scrolledContainerColor = Color.Transparent,
|
||||||
.align(Alignment.TopCenter)
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
),
|
||||||
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
|
navigationIcon = {
|
||||||
},
|
|
||||||
)
|
|
||||||
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)) {
|
|
||||||
ChatFloatingBackButton(
|
ChatFloatingBackButton(
|
||||||
contentDescription = backContentDescription,
|
contentDescription = backContentDescription,
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.aspectRatio(1f, matchHeightConstraintsFirst = true),
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
title = { titleChrome() },
|
||||||
|
actions = {
|
||||||
|
if (showCallButton) {
|
||||||
|
IconButton(onClick = onCallClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Call,
|
||||||
|
contentDescription = callContentDescription,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.first().measure(heightUnbounded)
|
||||||
|
|
||||||
|
val layoutWidth = topBarPlaceable.width.coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||||
|
val layoutHeight = topBarPlaceable.height + arcExtentPx
|
||||||
|
|
||||||
|
val bgPlaceable = subcompose("background") {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxSize()
|
||||||
.wrapContentHeight(),
|
.clip(shape)
|
||||||
contentAlignment = Alignment.Center,
|
.background(containerColor)
|
||||||
) {
|
.hazeEffect(state = hazeState, style = headerHazeStyle),
|
||||||
titleChrome()
|
)
|
||||||
}
|
}.first().measure(Constraints.fixed(layoutWidth, layoutHeight))
|
||||||
if (showCallButton) {
|
|
||||||
val shape = CircleShape
|
layout(layoutWidth, layoutHeight) {
|
||||||
val outline = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)
|
bgPlaceable.place(0, 0)
|
||||||
val fill = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)
|
topBarPlaceable.place(0, 0)
|
||||||
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,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Call,
|
|
||||||
contentDescription = callContentDescription,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(28.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.width(sideSlot))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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