From 900fc3429d98934c62233c619d724c0abed4c0cf Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 19 Feb 2026 22:06:39 +0300 Subject: [PATCH] Fix flickering Signed-off-by: denis0001-dev --- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 70 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 644 +++++++++--------- .../ui/chat/ImageFullscreenPreview.kt | 141 +++- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 20 +- 4 files changed, 500 insertions(+), 375 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 2235e40..4ea499c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -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) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 7a18752..0a77663 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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?>(null) } - - BackHandler(enabled = expandedImage != null) { - expandedImage = null - } + var isImageClosing by remember { mutableStateOf(false) } + val imageThumbBounds = remember { mutableStateMapOf() } + 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] + ) + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index a526f03..4da27b8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index f4cc8c6..7ec5136 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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