Fix flickering

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-19 22:06:39 +03:00
Unverified
parent 323ec99c12
commit 900fc3429d
4 changed files with 500 additions and 375 deletions
@@ -1,8 +1,6 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -49,6 +47,8 @@ import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.withSaveLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@@ -79,9 +79,8 @@ fun AttachmentPreview(
fileIndex: Int? = null,
onFileClick: (() -> Unit)? = null,
onImageClick: (() -> Unit)? = null,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onImageBounds: ((Rect) -> Unit)? = null,
isExpanded: Boolean = false,
isAuthor: Boolean = false,
modifier: Modifier = Modifier
) {
@@ -123,18 +122,10 @@ fun AttachmentPreview(
}
isImageWithThumb -> {
var isFullyLoaded by remember { mutableStateOf(false) }
val sharedModifier = if (sharedImageKey != null && sharedTransitionScope != null && animatedVisibilityScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = sharedImageKey),
animatedVisibilityScope = animatedVisibilityScope
)
}
} else Modifier
Box(
modifier = modifier
.then(
if (onImageClick != null && isFullyLoaded) Modifier.clickable(onClick = onImageClick)
if (onImageClick != null && isFullyLoaded && !isExpanded) Modifier.clickable(onClick = onImageClick)
else Modifier
)
.conditional(
@@ -148,23 +139,41 @@ fun AttachmentPreview(
Modifier.size(IMAGE_SIZE)
}
)
.then(sharedModifier)
.clip(RoundedCornerShape(IMAGE_RADIUS)),
.clip(RoundedCornerShape(IMAGE_RADIUS))
.then(
if (onImageBounds != null) {
Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val size = coords.size
onImageBounds(
Rect(
pos.x,
pos.y,
pos.x + size.width.toFloat(),
pos.y + size.height.toFloat()
)
)
}
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
DecryptedImageContent(
messageId = messageId ?: -1,
fileIndex = fileIndex ?: 0,
file = file,
envelope = dmEnvelope,
currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio,
sharedImageKey = sharedImageKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onFullyLoaded = { isFullyLoaded = it }
)
if (!isExpanded) {
DecryptedImageContent(
messageId = messageId ?: -1,
fileIndex = fileIndex ?: 0,
file = file,
envelope = dmEnvelope,
currentUserId = currentUserId,
thumbnailBase64 = fileThumbnail,
aspectRatio = fileAspectRatio,
onFullyLoaded = { isFullyLoaded = it }
)
} else {
Box(modifier = Modifier.fillMaxSize())
}
}
}
isPendingImage -> {
@@ -264,9 +273,6 @@ private fun DecryptedImageContent(
currentUserId: Int?,
thumbnailBase64: String,
aspectRatio: Float?,
sharedImageKey: Any? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onFullyLoaded: (Boolean) -> Unit = {}
) {
var cachedPath by remember(messageId, fileIndex, file.path) {
@@ -3,7 +3,6 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -43,6 +42,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -184,10 +184,9 @@ fun ChatScreen(
)
}
var expandedImage by remember { mutableStateOf<Pair<Message, Int>?>(null) }
BackHandler(enabled = expandedImage != null) {
expandedImage = null
}
var isImageClosing by remember { mutableStateOf(false) }
val imageThumbBounds = remember { mutableStateMapOf<String, Rect>() }
val expandedImageKey = expandedImage?.let { (msg, idx) -> "img_${msg.id}_$idx" }
// Collect WebSocket messages
LaunchedEffect(Unit) {
@@ -301,364 +300,365 @@ fun ChatScreen(
}
}
AnimatedContent(
targetState = expandedImage,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith ExitTransition.None
},
label = "image_fullscreen"
) { expanded ->
if (expanded == null) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(
scale = 0.96f,
onClick = if (profileUserId != null && onTitleClick != null) {
{ onTitleClick() }
} else null
),
verticalAlignment = Alignment.CenterVertically
) {
when {
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
val avatar = panelState.titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
?: "?"
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
displayName = displayName,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope
)
.size(36.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
}
!hideTitleBarAvatar -> {
panelState.titleAvatar?.let { avatar ->
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(
scale = 0.96f,
onClick = if (profileUserId != null && onTitleClick != null) {
{ onTitleClick() }
} else null
),
verticalAlignment = Alignment.CenterVertically
) {
when {
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
val avatar = panelState.titleAvatar
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
?: "?"
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
displayName = displayName,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope
)
.size(36.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
)
}
)
}
Spacer(modifier = Modifier.width(8.dp))
}
!hideTitleBarAvatar -> {
panelState.titleAvatar?.let { avatar ->
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
else -> {
panelState.titleAvatar?.let {
Spacer(modifier = Modifier.width(36.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
onAvatarSlotBounds != null -> {
Box(
modifier = Modifier
.size(36.dp)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
onAvatarSlotBounds(
Rect(
pos.x,
pos.y,
pos.x + sz.width.toFloat(),
pos.y + sz.height.toFloat()
)
)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
else -> {
panelState.titleAvatar?.let {
Spacer(modifier = Modifier.width(36.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
}
Column(Modifier.fillMaxWidth()) {
Text(
text = panelState.title,
style = MaterialTheme.typography.titleLarge
)
Column(Modifier.fillMaxWidth()) {
Text(
text = panelState.title,
style = MaterialTheme.typography.titleLarge
)
AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "typing_status"
) { hasTyping ->
if (hasTyping) {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else if (panelState.profileUserId != null) {
val userStatus = statusMap[panelState.profileUserId]
if (userStatus != null) {
val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen)
if (statusText.isNotEmpty()) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "typing_status"
) { hasTyping ->
if (hasTyping) {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else if (panelState.profileUserId != null) {
val userStatus = statusMap[panelState.profileUserId]
if (userStatus != null) {
val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen)
if (statusText.isNotEmpty()) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
}
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
}
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
actions = {
if (panel.showCallButton()) {
IconButton(onClick = { /* TODO: Handle call */ }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
imageVector = Icons.Default.Call,
contentDescription = "Call"
)
}
},
actions = {
if (panel.showCallButton()) {
IconButton(onClick = { /* TODO: Handle call */ }) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Call"
)
}
}
},
scrollBehavior = scrollBehavior,
modifier = Modifier.hazeEffect(
}
},
scrollBehavior = scrollBehavior,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
},
bottomBar = {
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
},
bottomBar = {
Column( // New Column to hold ChatInput below the LazyColumn
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f
)
}
) {
ChatInput(
text = inputText,
onTextChange = { inputText = it },
onSend = { text, attachments ->
if (editingMessage != null) {
scope.launch {
panel.handleEditMessage(editingMessage!!.id, text)
editingMessage = null
}
} else {
scope.launch {
val replyToId = replyTo?.id
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
content = plaintext.ifBlank { att.filename },
timestamp = Clock.System.now().toString(),
is_read = false,
is_edited = false,
username = "You",
profile_picture = null,
verified = null,
reply_to = replyTo,
client_message_id = null,
reactions = null,
files = null,
pendingFileUri = att.uri,
uploadJobId = jobId,
uploadProgress = 0
)
panel.addMessage(optimisticMessage)
AttachmentUploadQueue.enqueue(
AttachmentUploadJob(
jobId = jobId,
fileUri = att.uri,
filename = att.filename,
recipientId = recipientId,
plaintext = plaintext.ifBlank { att.filename },
replyToId = replyToId
)
)
}
} else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId)
}
replyTo = null
haptic(HapticFeedbackEvent.MessageSent)
}
}
inputText = ""
},
typingHandler = panel.getTypingHandler(),
replyTo = replyTo,
editingMessage = editingMessage,
onClearReply = { replyTo = null },
onClearEdit = {
editingMessage = null
inputText = ""
},
hazeState = hazeState,
recipientId = panel.getRecipientId()
)
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
// Close context menu on outside tap
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
}
) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f
)
}
) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
items(
items = panelState.messages,
key = { it.id }
) { message ->
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
ChatInput(
text = inputText,
onTextChange = { inputText = it },
onSend = { text, attachments ->
if (editingMessage != null) {
scope.launch {
panel.handleEditMessage(editingMessage!!.id, text)
editingMessage = null
}
} else {
scope.launch {
val replyToId = replyTo?.id
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
val optimisticMessage = Message(
id = tempId,
user_id = currentUserId ?: -1,
content = plaintext.ifBlank { att.filename },
timestamp = Clock.System.now().toString(),
is_read = false,
is_edited = false,
username = "You",
profile_picture = null,
verified = null,
reply_to = replyTo,
client_message_id = null,
reactions = null,
files = null,
pendingFileUri = att.uri,
uploadJobId = jobId,
uploadProgress = 0
)
}
) {
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
currentUserId = currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
panel.addMessage(optimisticMessage)
AttachmentUploadQueue.enqueue(
AttachmentUploadJob(
jobId = jobId,
fileUri = att.uri,
filename = att.filename,
recipientId = recipientId,
plaintext = plaintext.ifBlank { att.filename },
replyToId = replyToId
)
)
},
onTapPosition = { offset ->
tapOffset = offset
},
onImageClick = { msg, idx -> expandedImage = msg to idx },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
showUsername = panel.showUsernamesInMessages
)
}
} else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId)
}
replyTo = null
haptic(HapticFeedbackEvent.MessageSent)
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
}
}
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu(
state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message ->
replyTo = message
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
inputText = ""
},
onEdit = { message ->
editingMessage = message
inputText = message.content
replyTo = null
},
onDelete = { message ->
scope.launch {
panel.handleDeleteMessage(message.id)
}
typingHandler = panel.getTypingHandler(),
replyTo = replyTo,
editingMessage = editingMessage,
onClearReply = { replyTo = null },
onClearEdit = {
editingMessage = null
inputText = ""
},
hazeState = hazeState,
recipientId = panel.getRecipientId()
)
}
}
} else {
expanded.let { (msg, idx) ->
ImageFullscreenPreview(
message = msg,
fileIndex = idx,
currentUserId = currentUserId,
onDismiss = { expandedImage = null },
onReply = { m ->
replyTo = m
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
}
}
) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) }
items(
items = panelState.messages,
key = { it.id }
) { message ->
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
)
}
) {
MessageItem(
message = message,
isAuthor = message.user_id == currentUserId,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
)
},
onTapPosition = { offset ->
tapOffset = offset
},
onImageClick = { msg, idx -> expandedImage = msg to idx },
onImageBounds = { key, rect ->
imageThumbBounds[key] = rect
},
expandedImageKey = expandedImageKey,
isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId
)
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) }
}
}
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu(
state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message ->
replyTo = message
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
expandedImage = null
},
onDelete = { m ->
onEdit = { message ->
editingMessage = message
inputText = message.content
replyTo = null
},
onDelete = { message ->
scope.launch {
panel.handleDeleteMessage(m.id)
panel.handleDeleteMessage(message.id)
}
},
onSave = { _, _ -> /* TODO: platform-specific save to gallery */ },
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this@AnimatedContent,
sharedImageKey = "img_${msg.id}_$idx",
modifier = Modifier.fillMaxSize()
)
}
}
expandedImage?.let { (msg, idx) ->
val key = "img_${msg.id}_$idx"
ImageFullscreenPreview(
message = msg,
fileIndex = idx,
currentUserId = currentUserId,
onDismiss = {
isImageClosing = false
expandedImage = null
},
onClosingChange = { isImageClosing = it },
onReply = { m ->
replyTo = m
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
isImageClosing = false
expandedImage = null
},
onDelete = { m ->
scope.launch {
panel.handleDeleteMessage(m.id)
}
},
onSave = { _, _ -> /* TODO: platform-specific save to gallery */ },
sharedTransitionScope = null,
animatedVisibilityScope = null,
sharedImageKey = null,
modifier = Modifier.fillMaxSize(),
thumbnailBounds = imageThumbBounds[key]
)
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -56,10 +57,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
import ru.fromchat.ui.BackHandler
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.time.ExperimentalTime
@@ -67,6 +70,14 @@ import kotlin.time.Instant
private val MENU_BG_ALPHA = 0.5f
private data class InitialTransform(
val scale: Float,
val offsetX: Float,
val offsetY: Float,
val cornerRadius: Float,
val bgAlpha: Float
)
@OptIn(ExperimentalTime::class)
private fun formatDateTime(timestamp: String): String {
return try {
@@ -92,10 +103,12 @@ fun ImageFullscreenPreview(
onReply: (Message) -> Unit,
onDelete: (Message) -> Unit,
onSave: (Message, Int) -> Unit,
onClosingChange: (Boolean) -> Unit = {},
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
sharedImageKey: Any? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
thumbnailBounds: Rect? = null
) {
val file = message.files?.getOrNull(fileIndex) ?: return
val envelope = message.dmEnvelope
@@ -116,11 +129,20 @@ fun ImageFullscreenPreview(
var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) }
var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) }
val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation
val effectiveBackgroundAlpha = if (isInitialOpenState) 0f else backgroundAlpha.value
val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible
BackHandler(enabled = true) {
dismissRequested = true
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.background(Color.Black.copy(alpha = effectiveBackgroundAlpha))
) {
BoxWithConstraints(
modifier = Modifier
@@ -145,16 +167,71 @@ fun ImageFullscreenPreview(
)
}
else -> {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val scaleAnim = remember { Animatable(1f) }
val offsetXAnim = remember { Animatable(0f) }
val offsetYAnim = remember { Animatable(0f) }
val initial = remember(
thumbnailBounds, containerWidth, containerHeight, contentHeightAtScale1
) {
if (thumbnailBounds != null && fileAspectRatio != null) {
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + contentHeightAtScale1 / 2f
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val s = thumbWidth / containerWidth
val o = thumbCenter - fullCenter
InitialTransform(s, o.x, o.y, 8f, 0f)
} else {
InitialTransform(1f, 0f, 0f, 0f, 1f)
}
}
var scale by remember { mutableStateOf(initial.scale) }
var offset by remember { mutableStateOf(Offset(initial.offsetX, initial.offsetY)) }
val scaleAnim = remember { Animatable(initial.scale) }
val offsetXAnim = remember { Animatable(initial.offsetX) }
val offsetYAnim = remember { Animatable(initial.offsetY) }
val cornerRadiusAnim = remember { Animatable(initial.cornerRadius) }
val state = rememberTransformableState { zoomChange, offsetChange, _ ->
scale = (scale * zoomChange).coerceIn(1f, 12f)
offset += offsetChange
}
LaunchedEffect(thumbnailBounds) {
if (thumbnailBounds != null && !hasPlayedOpenAnimation) {
hasPlayedOpenAnimation = true
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + contentHeightAtScale1 / 2f
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val startScale = thumbWidth / containerWidth
val startOffset = thumbCenter - fullCenter
scale = startScale
offset = startOffset
scaleAnim.snapTo(startScale)
offsetXAnim.snapTo(startOffset.x)
offsetYAnim.snapTo(startOffset.y)
cornerRadiusAnim.snapTo(8f)
backgroundAlpha.snapTo(0f)
menusVisible = false
coroutineScope {
launch { scaleAnim.animateTo(1f, tween(250)) }
launch { offsetXAnim.animateTo(0f, tween(250)) }
launch { offsetYAnim.animateTo(0f, tween(250)) }
launch { cornerRadiusAnim.animateTo(0f, tween(250)) }
launch { backgroundAlpha.animateTo(1f, tween(250)) }
}
menusVisible = true
}
}
LaunchedEffect(dismissRequested) {
if (!dismissRequested) return@LaunchedEffect
@@ -162,14 +239,50 @@ fun ImageFullscreenPreview(
offsetXAnim.value != 0f ||
offsetYAnim.value != 0f
if (hasTransform) {
if (thumbnailBounds != null) {
menusVisible = false
val fullTop = (containerHeight - contentHeightAtScale1) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + contentHeightAtScale1 / 2f
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val targetScale = thumbWidth / containerWidth
val targetOffset = thumbCenter - fullCenter
scaleAnim.snapTo(scale)
offsetXAnim.snapTo(offset.x)
offsetYAnim.snapTo(offset.y)
cornerRadiusAnim.snapTo(0f)
coroutineScope {
launch { scaleAnim.animateTo(1f, tween(220)) }
launch { offsetXAnim.animateTo(0f, tween(220)) }
launch { offsetYAnim.animateTo(0f, tween(220)) }
launch { scaleAnim.animateTo(targetScale, tween(250)) }
launch { offsetXAnim.animateTo(targetOffset.x, tween(250)) }
launch { offsetYAnim.animateTo(targetOffset.y, tween(250)) }
launch { cornerRadiusAnim.animateTo(8f, tween(250)) }
launch { backgroundAlpha.animateTo(0f, tween(250)) }
}
} else {
if (hasTransform) {
menusVisible = false
coroutineScope {
launch { scaleAnim.animateTo(1f, tween(220)) }
launch { offsetXAnim.animateTo(0f, tween(220)) }
launch { offsetYAnim.animateTo(0f, tween(220)) }
launch { cornerRadiusAnim.animateTo(0f, tween(220)) }
launch { backgroundAlpha.animateTo(0f, tween(220)) }
}
} else {
backgroundAlpha.animateTo(0f, tween(220))
}
}
onClosingChange(true)
delay(50)
onClosingChange(false)
onDismiss()
}
LaunchedEffect(scale, offset, state.isTransformInProgress) {
@@ -238,6 +351,8 @@ fun ImageFullscreenPreview(
.graphicsLayer {
scaleX = scaleAnim.value
scaleY = scaleAnim.value
shape = androidx.compose.foundation.shape.RoundedCornerShape(cornerRadiusAnim.value.dp)
clip = cornerRadiusAnim.value > 0f
},
contentScale = ContentScale.FillWidth
)
@@ -249,7 +364,7 @@ fun ImageFullscreenPreview(
// Top bar: back, display name + date/time, 3-dot menu
AnimatedVisibility(
visible = menusVisible,
visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
@@ -349,7 +464,7 @@ fun ImageFullscreenPreview(
// Bottom: message text
AnimatedVisibility(
visible = menusVisible && message.content.isNotBlank(),
visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
@@ -2,7 +2,6 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -34,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
@@ -64,11 +64,12 @@ fun MessageItem(
onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {},
onImageClick: ((Message, Int) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
onImageBounds: ((String, Rect) -> Unit)? = null,
modifier: Modifier = Modifier,
showUsername: Boolean = true,
currentUserId: Int? = null
currentUserId: Int? = null,
expandedImageKey: String? = null,
isImageClosing: Boolean = false
) {
AnimatedVisibility(
visible = true,
@@ -241,6 +242,7 @@ fun MessageItem(
}
message.files?.forEachIndexed { index, file ->
val isImage = isImageFilename(file.name)
val imageKey = if (isImage) "img_${message.id}_$index" else null
AttachmentPreview(
file = file,
dmEnvelope = message.dmEnvelope,
@@ -252,11 +254,13 @@ fun MessageItem(
fileSizeBytes = message.fileSizes?.getOrNull(index),
messageId = if (isImage) message.id else null,
fileIndex = if (isImage) index else null,
isAuthor = isAuthor,
onFileClick = null,
onImageClick = if (isImage) { { onImageClick?.invoke(message, index) } } else null,
sharedImageKey = if (isImage && sharedTransitionScope != null && animatedVisibilityScope != null) "img_${message.id}_$index" else null,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onImageBounds = if (isImage && imageKey != null && onImageBounds != null) {
{ rect -> onImageBounds.invoke(imageKey, rect) }
} else null,
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
isAuthor = isAuthor,
modifier = Modifier.padding(
horizontal = if (isImage) 2.dp else 12.dp,
vertical = if (isImage) 2.dp else 4.dp