From 9568d83a087bdaa061efd55f6cc9802ecc260b20 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 14 Mar 2026 01:05:06 +0700 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.1.8:=20fulls?= =?UTF-8?q?creen=20=D1=84=D0=BE=D1=82=D0=BE,=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=B8=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D1=8B=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../rosetta/messenger/data/ReleaseNotes.kt | 22 +- .../messenger/ui/chats/ChatDetailScreen.kt | 109 +++-- .../messenger/ui/chats/GroupInfoScreen.kt | 10 +- .../messenger/ui/chats/GroupSetupScreen.kt | 2 +- .../ui/chats/attach/ChatAttachAlert.kt | 18 +- .../components/MediaPickerBottomSheet.kt | 10 +- .../components/SimpleFullscreenPhotoViewer.kt | 373 +++++++++++++++++- 8 files changed, 495 insertions(+), 53 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 994b4e0..128e30f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.1.7" -val rosettaVersionCode = 19 // Increment on each release +val rosettaVersionName = "1.1.8" +val rosettaVersionCode = 20 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index ef3623f..e476866 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,14 +17,22 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Уведомления - - Исправлена регистрация push-токена после переподключений - - Добавлен fallback для нестандартных payload, чтобы push-уведомления не терялись - - Улучшена отправка push-токена сразу после получения FCM токена + Полноэкранное фото из медиапикера + - Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс + - Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи + - Добавлено закрытие свайпом вверх/вниз с плавной анимацией + - Убраны рывки, мигание и лишнее уменьшение фото при перелистывании - Интерфейс - - Улучшено поведение сворачивания приложения в стиле Telegram - - Стабилизировано отображение нижней системной панели навигации + Редактирование и отправка + - Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера + - Улучшена пересылка фото через optimistic UI: сообщение отображается сразу + - Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options + - Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply + + Группы + - В списках участников групп отображается только статус online/offline + - На экране создания группы у текущего пользователя статус отображается как online + - Поиск участников по username сохранен """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 7760ae6..221a95b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -138,6 +138,28 @@ private data class IncomingRunAvatarUiState( val overlays: List ) +private fun extractCopyableMessageText(message: ChatMessage): String { + val directText = message.text.trim() + if (directText.isNotEmpty()) { + return directText + } + + val forwardedText = + message.forwardedMessages + .mapNotNull { forwarded -> forwarded.text.trim().takeIf { it.isNotEmpty() } } + .joinToString("\n\n") + if (forwardedText.isNotEmpty()) { + return forwardedText + } + + val replyText = message.replyData?.text?.trim().orEmpty() + if (replyText.isNotEmpty()) { + return replyText + } + + return "" +} + @OptIn( ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, @@ -297,6 +319,8 @@ fun ChatDetailScreen( var simplePickerPreviewUri by remember { mutableStateOf(null) } var simplePickerPreviewSourceThumb by remember { mutableStateOf(null) } var simplePickerPreviewCaption by remember { mutableStateOf("") } + var simplePickerPreviewGalleryUris by remember { mutableStateOf>(emptyList()) } + var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) } // 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме if (!view.isInEditMode) { @@ -1205,30 +1229,41 @@ fun ChatDetailScreen( { it.id } ) ) - .joinToString( - "\n\n" - ) { + .mapNotNull { msg -> - val time = - SimpleDateFormat( - "HH:mm", - Locale.getDefault() - ) - .format( - msg.timestamp - ) - "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" + val messageText = + extractCopyableMessageText( + msg + ) + if (messageText.isBlank()) { + null + } else { + val time = + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ) + .format( + msg.timestamp + ) + "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText" + } } - clipboardManager - .setText( - androidx.compose - .ui - .text - .AnnotatedString( - textToCopy - ) - ) + .joinToString( + "\n\n" + ) + if (textToCopy.isNotBlank()) { + clipboardManager + .setText( + androidx.compose + .ui + .text + .AnnotatedString( + textToCopy + ) + ) + } selectedMessages = emptySet() } @@ -2788,7 +2823,7 @@ fun ChatDetailScreen( // 💬 Context menu anchored to this bubble if (showContextMenu && contextMenuMessage?.id == message.id) { val msg = contextMenuMessage!! - MessageContextMenu( + MessageContextMenu( expanded = true, onDismiss = { showContextMenu = false @@ -2797,7 +2832,7 @@ fun ChatDetailScreen( isDarkTheme = isDarkTheme, isPinned = contextMenuIsPinned, isOutgoing = msg.isOutgoing, - hasText = msg.text.isNotBlank(), + hasText = extractCopyableMessageText(msg).isNotBlank(), isSystemAccount = isSystemAccount, onReply = { viewModel.setReplyMessages(listOf(msg)) @@ -2806,7 +2841,7 @@ fun ChatDetailScreen( }, onCopy = { clipboardManager.setText( - androidx.compose.ui.text.AnnotatedString(msg.text) + androidx.compose.ui.text.AnnotatedString(extractCopyableMessageText(msg)) ) showContextMenu = false contextMenuMessage = null @@ -3060,7 +3095,7 @@ fun ChatDetailScreen( viewModel.sendAvatarMessage() }, recipientName = user.title, - onPhotoPreviewRequested = { uri, sourceThumb -> + onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex -> hideInputOverlays() showMediaPicker = false showContextMenu = false @@ -3068,6 +3103,14 @@ fun ChatDetailScreen( simplePickerPreviewSourceThumb = sourceThumb simplePickerPreviewCaption = "" simplePickerPreviewUri = uri + val normalizedGallery = + if (galleryUris.isNotEmpty()) galleryUris else listOf(uri) + simplePickerPreviewGalleryUris = normalizedGallery + simplePickerPreviewInitialIndex = + initialIndex.coerceIn( + 0, + (normalizedGallery.size - 1).coerceAtLeast(0) + ) } ) } else { @@ -3113,7 +3156,7 @@ fun ChatDetailScreen( viewModel.sendAvatarMessage() }, recipientName = user.title, - onPhotoPreviewRequested = { uri, sourceThumb -> + onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex -> hideInputOverlays() showMediaPicker = false showContextMenu = false @@ -3121,6 +3164,14 @@ fun ChatDetailScreen( simplePickerPreviewSourceThumb = sourceThumb simplePickerPreviewCaption = "" simplePickerPreviewUri = uri + val normalizedGallery = + if (galleryUris.isNotEmpty()) galleryUris else listOf(uri) + simplePickerPreviewGalleryUris = normalizedGallery + simplePickerPreviewInitialIndex = + initialIndex.coerceIn( + 0, + (normalizedGallery.size - 1).coerceAtLeast(0) + ) } ) } @@ -3418,6 +3469,8 @@ fun ChatDetailScreen( SimpleFullscreenPhotoOverlay( imageUri = previewUri, sourceThumbnail = simplePickerPreviewSourceThumb, + galleryImageUris = simplePickerPreviewGalleryUris, + initialGalleryIndex = simplePickerPreviewInitialIndex, modifier = Modifier.fillMaxSize().zIndex(100f), showCaptionInput = true, caption = simplePickerPreviewCaption, @@ -3429,12 +3482,16 @@ fun ChatDetailScreen( simplePickerPreviewUri = null simplePickerPreviewSourceThumb = null simplePickerPreviewCaption = "" + simplePickerPreviewGalleryUris = emptyList() + simplePickerPreviewInitialIndex = 0 inputFocusTrigger++ }, onDismiss = { simplePickerPreviewUri = null simplePickerPreviewSourceThumb = null simplePickerPreviewCaption = "" + simplePickerPreviewGalleryUris = emptyList() + simplePickerPreviewInitialIndex = 0 inputFocusTrigger++ } ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index a000379..6597dd2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -737,11 +737,7 @@ fun GroupInfoScreen( info?.title?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() } ?: fallbackName - val subtitle = when { - isOnline -> "online" - info?.username?.isNotBlank() == true -> "@${info.username}" - else -> key.take(18) - } + val subtitle = if (isOnline) "online" else "offline" GroupMemberUi( publicKey = key, title = displayTitle, @@ -761,9 +757,11 @@ fun GroupInfoScreen( if (query.isBlank()) { true } else { + val username = memberInfoByKey[member.publicKey]?.username?.lowercase().orEmpty() member.title.lowercase().contains(query) || member.subtitle.lowercase().contains(query) || - member.publicKey.lowercase().contains(query) + member.publicKey.lowercase().contains(query) || + username.contains(query) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 0e7973e..1ffd84f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -183,7 +183,7 @@ fun GroupSetupScreen( .ifBlank { normalizedUsername } .ifBlank { shortPublicKey(accountPublicKey) } } - val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you" + val selfSubtitle = "online" fun openGroup(dialogPublicKey: String, groupTitle: String) { onGroupOpened( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 6139562..0f5d63b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -155,7 +155,7 @@ fun ChatAttachAlert( currentUserPublicKey: String = "", maxSelection: Int = 10, recipientName: String? = null, - onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null, + onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List, Int) -> Unit)? = null, viewModel: AttachAlertViewModel = viewModel() ) { val context = LocalContext.current @@ -1057,7 +1057,13 @@ fun ChatAttachAlert( if (!item.isVideo) { hideKeyboard() if (onPhotoPreviewRequested != null) { - onPhotoPreviewRequested(item.uri, position) + val photoItems = state.visibleMediaItems.filter { !it.isVideo } + val photoUris = photoItems.map { it.uri } + val currentIndex = + photoItems.indexOfFirst { it.id == item.id } + .takeIf { it >= 0 } + ?: photoUris.indexOf(item.uri).coerceAtLeast(0) + onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex) } else { thumbnailPosition = position viewModel.setEditingItem(item) @@ -1260,9 +1266,17 @@ fun ChatAttachAlert( // ═══════════════════════════════════════════════════════════ state.editingItem?.let { item -> + val galleryPhotoItems = state.visibleMediaItems.filter { !it.isVideo } + val galleryPhotoUris = galleryPhotoItems.map { it.uri } + val initialGalleryIndex = + galleryPhotoItems.indexOfFirst { it.id == item.id } + .takeIf { it >= 0 } + ?: galleryPhotoUris.indexOf(item.uri).coerceAtLeast(0) SimpleFullscreenPhotoViewer( imageUri = item.uri, sourceThumbnail = thumbnailPosition, + galleryImageUris = galleryPhotoUris, + initialGalleryIndex = initialGalleryIndex, onDismiss = { viewModel.setEditingItem(null) thumbnailPosition = null diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 77ccdd8..3212333 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -134,7 +134,7 @@ fun MediaPickerBottomSheet( currentUserPublicKey: String = "", maxSelection: Int = 10, recipientName: String? = null, - onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null + onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List, Int) -> Unit)? = null ) { val context = LocalContext.current val hasNativeNavigationBar = remember(context) { @@ -1043,7 +1043,13 @@ fun MediaPickerBottomSheet( if (!item.isVideo) { hideKeyboard() if (onPhotoPreviewRequested != null) { - onPhotoPreviewRequested(item.uri, position) + val photoItems = visibleMediaItems.filter { !it.isVideo } + val photoUris = photoItems.map { it.uri } + val currentIndex = + photoItems.indexOfFirst { it.id == item.id } + .takeIf { it >= 0 } + ?: photoUris.indexOf(item.uri).coerceAtLeast(0) + onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex) } else { thumbnailPosition = position editingItem = item diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt index 22fbdac..599b2d2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/SimpleFullscreenPhotoViewer.kt @@ -20,11 +20,14 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -51,6 +54,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -67,6 +71,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -79,6 +84,9 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import com.rosetta.messenger.ui.components.AppleEmojiEditTextView @@ -95,6 +103,8 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch private val ViewerExpandEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) +private val ViewerHorizontalSwipeEasing = CubicBezierEasing(0.22f, 1.0f, 0.36f, 1.0f) +private const val ViewerSwipeSettleDurationMs = 220 private data class SimpleViewerTransform( val scaleX: Float, @@ -104,6 +114,11 @@ private data class SimpleViewerTransform( val cornerRadiusDp: Float ) +private enum class SwipeAxis { + HORIZONTAL, + VERTICAL +} + private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { return start + (stop - start) * fraction } @@ -158,6 +173,8 @@ fun SimpleFullscreenPhotoViewer( imageUri: Uri, onDismiss: () -> Unit, sourceThumbnail: ThumbnailPosition? = null, + galleryImageUris: List = emptyList(), + initialGalleryIndex: Int = 0, showCaptionInput: Boolean = false, caption: String = "", onCaptionChange: ((String) -> Unit)? = null, @@ -183,6 +200,8 @@ fun SimpleFullscreenPhotoViewer( imageUri = imageUri, onDismiss = onDismiss, sourceThumbnail = sourceThumbnail, + galleryImageUris = galleryImageUris, + initialGalleryIndex = initialGalleryIndex, showCaptionInput = showCaptionInput, caption = caption, onCaptionChange = onCaptionChange, @@ -197,6 +216,8 @@ fun SimpleFullscreenPhotoOverlay( imageUri: Uri, onDismiss: () -> Unit, sourceThumbnail: ThumbnailPosition? = null, + galleryImageUris: List = emptyList(), + initialGalleryIndex: Int = 0, modifier: Modifier = Modifier, showCaptionInput: Boolean = false, caption: String = "", @@ -208,6 +229,8 @@ fun SimpleFullscreenPhotoOverlay( imageUri = imageUri, onDismiss = onDismiss, sourceThumbnail = sourceThumbnail, + galleryImageUris = galleryImageUris, + initialGalleryIndex = initialGalleryIndex, modifier = modifier, showCaptionInput = showCaptionInput, caption = caption, @@ -222,6 +245,8 @@ private fun SimpleFullscreenPhotoContent( imageUri: Uri, onDismiss: () -> Unit, sourceThumbnail: ThumbnailPosition? = null, + galleryImageUris: List = emptyList(), + initialGalleryIndex: Int = 0, modifier: Modifier = Modifier, showCaptionInput: Boolean = false, caption: String = "", @@ -243,7 +268,28 @@ private fun SimpleFullscreenPhotoContent( var isKeyboardVisible by remember { mutableStateOf(false) } var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } var localCaption by remember(imageUri) { mutableStateOf("") } - var currentImageUri by remember(imageUri) { mutableStateOf(imageUri) } + val normalizedGalleryImageUris = remember(imageUri, galleryImageUris) { + val filtered = galleryImageUris.distinct() + when { + filtered.isEmpty() -> listOf(imageUri) + filtered.contains(imageUri) -> filtered + else -> listOf(imageUri) + filtered + } + } + var currentGalleryIndex by remember(imageUri, normalizedGalleryImageUris, initialGalleryIndex) { + val fallbackIndex = + normalizedGalleryImageUris.indexOf(imageUri).takeIf { it >= 0 } ?: 0 + mutableStateOf(initialGalleryIndex.coerceIn(0, normalizedGalleryImageUris.lastIndex.coerceAtLeast(0)).takeIf { + normalizedGalleryImageUris.getOrNull(it) != null + } ?: fallbackIndex) + } + var currentImageUri by remember(imageUri, normalizedGalleryImageUris) { + mutableStateOf( + normalizedGalleryImageUris.getOrNull(currentGalleryIndex) + ?: normalizedGalleryImageUris.firstOrNull() + ?: imageUri + ) + } var currentTool by remember { mutableStateOf(EditorTool.NONE) } var selectedColor by remember { mutableStateOf(Color.White) } var brushSize by remember { mutableStateOf(12f) } @@ -256,6 +302,13 @@ private fun SimpleFullscreenPhotoContent( var rotationAngle by remember { mutableStateOf(0f) } var isFlippedHorizontally by remember { mutableStateOf(false) } var isFlippedVertically by remember { mutableStateOf(false) } + var swipeAxis by remember { mutableStateOf(null) } + var swipeOffsetX by remember { mutableFloatStateOf(0f) } + var swipeOffsetY by remember { mutableFloatStateOf(0f) } + var isSwipeAnimating by remember { mutableStateOf(false) } + var isSwitchingImage by remember { mutableStateOf(false) } + var editorLoadingUri by remember { mutableStateOf(null) } + var editorCoverUri by remember { mutableStateOf(null) } val progress = remember(imageUri, sourceThumbnail) { Animatable(if (sourceThumbnail != null) 0f else 1f) @@ -293,6 +346,17 @@ private fun SimpleFullscreenPhotoContent( LaunchedEffect(imageUri, sourceThumbnail) { localCaption = caption + val clampedIndex = initialGalleryIndex.coerceIn( + 0, + normalizedGalleryImageUris.lastIndex.coerceAtLeast(0) + ) + currentGalleryIndex = clampedIndex + currentImageUri = + normalizedGalleryImageUris.getOrNull(clampedIndex) + ?: normalizedGalleryImageUris.firstOrNull() + ?: imageUri + editorLoadingUri = null + editorCoverUri = null if (progress.value < 1f) { progress.animateTo( targetValue = 1f, @@ -350,6 +414,18 @@ private fun SimpleFullscreenPhotoContent( } } + fun resetEditorStateForImageSwitch() { + currentTool = EditorTool.NONE + showColorPicker = false + isEraserActive = false + hasDrawingEdits = false + rotationAngle = 0f + isFlippedHorizontally = false + isFlippedVertically = false + photoEditor?.setBrushDrawingMode(false) + photoEditor?.clearAllViews() + } + fun toggleEmojiPicker() { val now = System.currentTimeMillis() if (now - lastToggleTime < toggleCooldownMs) { @@ -380,6 +456,168 @@ private fun SimpleFullscreenPhotoContent( BackHandler { closeViewer() } + val canUseSwipeGestures = + currentTool == EditorTool.NONE && + !isClosing && + !isSaving && + !showColorPicker && + !isKeyboardVisible && + !showEmojiPicker && + !coordinator.isEmojiBoxVisible + val canSwipeHorizontally = canUseSwipeGestures && normalizedGalleryImageUris.size > 1 + + val animatedSwipeOffsetX by animateFloatAsState( + targetValue = swipeOffsetX, + animationSpec = + if (isSwipeAnimating) { + tween(ViewerSwipeSettleDurationMs, easing = ViewerHorizontalSwipeEasing) + } else { + snap() + }, + label = "simple_viewer_swipe_x" + ) + val animatedSwipeOffsetY by animateFloatAsState( + targetValue = swipeOffsetY, + animationSpec = if (isSwipeAnimating) tween(180, easing = ViewerExpandEasing) else snap(), + label = "simple_viewer_swipe_y" + ) + + val isVerticalSwipeActive = swipeAxis == SwipeAxis.VERTICAL || kotlin.math.abs(animatedSwipeOffsetY) > 0.5f + val swipeDismissProgress = + if (isVerticalSwipeActive && screenSize.height > 0) { + (kotlin.math.abs(animatedSwipeOffsetY) / screenSize.height.toFloat()).coerceIn(0f, 1f) + } else { + 0f + } + val swipeScrimAlpha = 1f - swipeDismissProgress * 0.45f + + val swipeGestureModifier = + if (canUseSwipeGestures) { + Modifier.pointerInput( + currentGalleryIndex, + normalizedGalleryImageUris, + canSwipeHorizontally, + screenSize, + isSwitchingImage + ) { + detectDragGestures( + onDragStart = { + if (isSwitchingImage) return@detectDragGestures + swipeAxis = null + isSwipeAnimating = false + }, + onDragCancel = { + swipeAxis = null + isSwipeAnimating = true + swipeOffsetX = 0f + swipeOffsetY = 0f + }, + onDragEnd = { + val width = screenSize.width.toFloat().coerceAtLeast(1f) + val height = screenSize.height.toFloat().coerceAtLeast(1f) + val horizontalThreshold = width * 0.2f + val verticalThreshold = height * 0.12f + + when (swipeAxis) { + SwipeAxis.HORIZONTAL -> { + val targetIndex = + when { + swipeOffsetX <= -horizontalThreshold && + currentGalleryIndex < normalizedGalleryImageUris.lastIndex -> { + currentGalleryIndex + 1 + } + swipeOffsetX >= horizontalThreshold && currentGalleryIndex > 0 -> { + currentGalleryIndex - 1 + } + else -> -1 + } + + if (targetIndex >= 0) { + val targetUri = normalizedGalleryImageUris[targetIndex] + context.imageLoader.enqueue( + ImageRequest.Builder(context) + .data(targetUri) + .crossfade(false) + .build() + ) + val toNext = targetIndex > currentGalleryIndex + isSwitchingImage = true + isSwipeAnimating = true + val exitOffset = if (toNext) -width else width + swipeOffsetX = exitOffset + scope.launch { + delay(ViewerSwipeSettleDurationMs.toLong()) + currentGalleryIndex = targetIndex + currentImageUri = targetUri + editorCoverUri = targetUri + resetEditorStateForImageSwitch() + isSwipeAnimating = false + swipeOffsetX = 0f + delay(90) + isSwitchingImage = false + } + } else { + isSwipeAnimating = true + swipeOffsetX = 0f + } + } + SwipeAxis.VERTICAL -> { + if (kotlin.math.abs(swipeOffsetY) >= verticalThreshold) { + closeViewer() + } else { + isSwipeAnimating = true + swipeOffsetY = 0f + } + } + null -> { + isSwipeAnimating = true + swipeOffsetX = 0f + swipeOffsetY = 0f + } + } + swipeAxis = null + }, + onDrag = { change, dragAmount -> + if (isSwitchingImage) return@detectDragGestures + if (swipeAxis == null) { + val absX = kotlin.math.abs(dragAmount.x) + val absY = kotlin.math.abs(dragAmount.y) + swipeAxis = + if (absX > absY && canSwipeHorizontally) { + SwipeAxis.HORIZONTAL + } else { + SwipeAxis.VERTICAL + } + } + + when (swipeAxis) { + SwipeAxis.HORIZONTAL -> { + val width = screenSize.width.toFloat().coerceAtLeast(1f) + val atFirst = currentGalleryIndex <= 0 + val atLast = currentGalleryIndex >= normalizedGalleryImageUris.lastIndex + var next = swipeOffsetX + dragAmount.x + if ((next > 0f && atFirst) || (next < 0f && atLast)) { + next *= 0.32f + } + swipeOffsetX = next.coerceIn(-width, width) + swipeOffsetY = 0f + change.consume() + } + SwipeAxis.VERTICAL -> { + val height = screenSize.height.toFloat().coerceAtLeast(1f) + swipeOffsetY = (swipeOffsetY + dragAmount.y).coerceIn(-height, height) + swipeOffsetX = 0f + change.consume() + } + null -> Unit + } + } + ) + } + } else { + Modifier + } + val transform by remember(sourceThumbnail, screenSize, progress.value) { derivedStateOf { val p = progress.value @@ -418,19 +656,100 @@ private fun SimpleFullscreenPhotoContent( val tapToDismissModifier = if (!showCaptionInput) { - Modifier.pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) } + Modifier.pointerInput(currentImageUri) { detectTapGestures(onTap = { closeViewer() }) } } else { Modifier } + val horizontalNeighborIndex = + remember(currentGalleryIndex, normalizedGalleryImageUris, animatedSwipeOffsetX) { + when { + animatedSwipeOffsetX < 0f && currentGalleryIndex < normalizedGalleryImageUris.lastIndex -> + currentGalleryIndex + 1 + animatedSwipeOffsetX > 0f && currentGalleryIndex > 0 -> + currentGalleryIndex - 1 + else -> -1 + } + } + val horizontalNeighborUri = + normalizedGalleryImageUris.getOrNull(horizontalNeighborIndex) + val shouldShowHorizontalNeighbor = + horizontalNeighborUri != null && kotlin.math.abs(animatedSwipeOffsetX) > 0.5f + val shouldShowHorizontalCurrentLayer = + canSwipeHorizontally && + (swipeAxis == SwipeAxis.HORIZONTAL || + isSwitchingImage || + kotlin.math.abs(animatedSwipeOffsetX) > 0.5f || + editorCoverUri != null) + val horizontalNeighborTranslationX = + if (animatedSwipeOffsetX < 0f) { + animatedSwipeOffsetX + screenSize.width.toFloat() + } else { + animatedSwipeOffsetX - screenSize.width.toFloat() + } + Box( modifier = modifier.fillMaxSize() .onSizeChanged { screenSize = it } - .background(Color.Black) + .background(Color.Black.copy(alpha = swipeScrimAlpha)) .then(tapToDismissModifier), contentAlignment = Alignment.Center ) { + if (shouldShowHorizontalNeighbor) { + AsyncImage( + model = + ImageRequest.Builder(context) + .data(horizontalNeighborUri) + .crossfade(false) + .build(), + contentDescription = null, + modifier = + Modifier.fillMaxSize() + .graphicsLayer { + scaleX = transform.scaleX + scaleY = transform.scaleY + translationX = transform.translationX + horizontalNeighborTranslationX + translationY = transform.translationY + animatedSwipeOffsetY + } + .then( + if (transform.cornerRadiusDp > 0f) { + Modifier.clip(RoundedCornerShape(transform.cornerRadiusDp.dp)) + } else { + Modifier + } + ), + contentScale = ContentScale.Crop + ) + } + + if (shouldShowHorizontalCurrentLayer) { + AsyncImage( + model = + ImageRequest.Builder(context) + .data(currentImageUri) + .crossfade(false) + .build(), + contentDescription = null, + modifier = + Modifier.fillMaxSize() + .graphicsLayer { + scaleX = transform.scaleX + scaleY = transform.scaleY + translationX = transform.translationX + animatedSwipeOffsetX + translationY = transform.translationY + animatedSwipeOffsetY + } + .then( + if (transform.cornerRadiusDp > 0f) { + Modifier.clip(RoundedCornerShape(transform.cornerRadiusDp.dp)) + } else { + Modifier + } + ), + contentScale = ContentScale.Crop + ) + } + AndroidView( factory = { ctx -> PhotoEditorView(ctx).apply { @@ -442,6 +761,7 @@ private fun SimpleFullscreenPhotoContent( adjustViewBounds = false setPadding(0, 0, 0, 0) setImageURI(currentImageUri) + tag = currentImageUri } photoEditor = PhotoEditor.Builder(ctx, this) .setPinchTextScalable(true) @@ -451,20 +771,59 @@ private fun SimpleFullscreenPhotoContent( }, update = { editorView -> if (editorView.source.tag != currentImageUri) { - editorView.source.setImageURI(currentImageUri) - editorView.source.tag = currentImageUri + if (editorLoadingUri != currentImageUri) { + val requestUri = currentImageUri + editorLoadingUri = requestUri + context.imageLoader.enqueue( + ImageRequest.Builder(context) + .data(requestUri) + .crossfade(false) + .target( + onSuccess = { result -> + if (editorLoadingUri == requestUri) { + editorView.source.setImageDrawable(result) + editorView.source.tag = requestUri + editorLoadingUri = null + if (editorCoverUri == requestUri) { + editorCoverUri = null + } + } + }, + onError = { error -> + if (editorLoadingUri == requestUri) { + if (error != null) { + editorView.source.setImageDrawable(error) + } else { + editorView.source.setImageURI(requestUri) + } + editorView.source.tag = requestUri + editorLoadingUri = null + if (editorCoverUri == requestUri) { + editorCoverUri = null + } + } + } + ) + .build() + ) + } } editorView.source.rotation = rotationAngle editorView.source.scaleX = if (isFlippedHorizontally) -1f else 1f editorView.source.scaleY = if (isFlippedVertically) -1f else 1f + if (editorView.source.tag == currentImageUri && editorCoverUri == currentImageUri) { + editorCoverUri = null + } }, modifier = Modifier.fillMaxSize() + .then(swipeGestureModifier) .graphicsLayer { + alpha = if (shouldShowHorizontalCurrentLayer) 0f else 1f scaleX = transform.scaleX scaleY = transform.scaleY - translationX = transform.translationX - translationY = transform.translationY + translationX = transform.translationX + animatedSwipeOffsetX + translationY = transform.translationY + animatedSwipeOffsetY } .then( if (transform.cornerRadiusDp > 0f) {