Релиз 1.1.8: fullscreen фото, пересылка и статусы участников групп

This commit is contained in:
2026-03-14 01:05:06 +07:00
parent 179f65872d
commit 9568d83a08
8 changed files with 495 additions and 53 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.7" val rosettaVersionName = "1.1.8"
val rosettaVersionCode = 19 // Increment on each release val rosettaVersionCode = 20 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -17,14 +17,22 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Уведомления Полноэкранное фото из медиапикера
- Исправлена регистрация push-токена после переподключений - Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс
- Добавлен fallback для нестандартных payload, чтобы push-уведомления не терялись - Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи
- Улучшена отправка push-токена сразу после получения FCM токена - Добавлено закрытие свайпом вверх/вниз с плавной анимацией
- Убраны рывки, мигание и лишнее уменьшение фото при перелистывании
Интерфейс Редактирование и отправка
- Улучшено поведение сворачивания приложения в стиле Telegram - Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера
- Стабилизировано отображение нижней системной панели навигации - Улучшена пересылка фото через optimistic UI: сообщение отображается сразу
- Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options
- Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply
Группы
- В списках участников групп отображается только статус online/offline
- На экране создания группы у текущего пользователя статус отображается как online
- Поиск участников по username сохранен
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -138,6 +138,28 @@ private data class IncomingRunAvatarUiState(
val overlays: List<IncomingRunAvatarOverlay> val overlays: List<IncomingRunAvatarOverlay>
) )
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( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -297,6 +319,8 @@ fun ChatDetailScreen(
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) } var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) } var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
var simplePickerPreviewCaption by remember { mutableStateOf("") } var simplePickerPreviewCaption by remember { mutableStateOf("") }
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме // 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
if (!view.isInEditMode) { if (!view.isInEditMode) {
@@ -1205,30 +1229,41 @@ fun ChatDetailScreen(
{ it.id } { it.id }
) )
) )
.joinToString( .mapNotNull {
"\n\n"
) {
msg msg
-> ->
val time = val messageText =
SimpleDateFormat( extractCopyableMessageText(
"HH:mm", msg
Locale.getDefault() )
) if (messageText.isBlank()) {
.format( null
msg.timestamp } else {
) val time =
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
}
} }
clipboardManager .joinToString(
.setText( "\n\n"
androidx.compose )
.ui if (textToCopy.isNotBlank()) {
.text clipboardManager
.AnnotatedString( .setText(
textToCopy androidx.compose
) .ui
) .text
.AnnotatedString(
textToCopy
)
)
}
selectedMessages = selectedMessages =
emptySet() emptySet()
} }
@@ -2788,7 +2823,7 @@ fun ChatDetailScreen(
// 💬 Context menu anchored to this bubble // 💬 Context menu anchored to this bubble
if (showContextMenu && contextMenuMessage?.id == message.id) { if (showContextMenu && contextMenuMessage?.id == message.id) {
val msg = contextMenuMessage!! val msg = contextMenuMessage!!
MessageContextMenu( MessageContextMenu(
expanded = true, expanded = true,
onDismiss = { onDismiss = {
showContextMenu = false showContextMenu = false
@@ -2797,7 +2832,7 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isPinned = contextMenuIsPinned, isPinned = contextMenuIsPinned,
isOutgoing = msg.isOutgoing, isOutgoing = msg.isOutgoing,
hasText = msg.text.isNotBlank(), hasText = extractCopyableMessageText(msg).isNotBlank(),
isSystemAccount = isSystemAccount, isSystemAccount = isSystemAccount,
onReply = { onReply = {
viewModel.setReplyMessages(listOf(msg)) viewModel.setReplyMessages(listOf(msg))
@@ -2806,7 +2841,7 @@ fun ChatDetailScreen(
}, },
onCopy = { onCopy = {
clipboardManager.setText( clipboardManager.setText(
androidx.compose.ui.text.AnnotatedString(msg.text) androidx.compose.ui.text.AnnotatedString(extractCopyableMessageText(msg))
) )
showContextMenu = false showContextMenu = false
contextMenuMessage = null contextMenuMessage = null
@@ -3060,7 +3095,7 @@ fun ChatDetailScreen(
viewModel.sendAvatarMessage() viewModel.sendAvatarMessage()
}, },
recipientName = user.title, recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb -> onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
hideInputOverlays() hideInputOverlays()
showMediaPicker = false showMediaPicker = false
showContextMenu = false showContextMenu = false
@@ -3068,6 +3103,14 @@ fun ChatDetailScreen(
simplePickerPreviewSourceThumb = sourceThumb simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = "" simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri simplePickerPreviewUri = uri
val normalizedGallery =
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
simplePickerPreviewGalleryUris = normalizedGallery
simplePickerPreviewInitialIndex =
initialIndex.coerceIn(
0,
(normalizedGallery.size - 1).coerceAtLeast(0)
)
} }
) )
} else { } else {
@@ -3113,7 +3156,7 @@ fun ChatDetailScreen(
viewModel.sendAvatarMessage() viewModel.sendAvatarMessage()
}, },
recipientName = user.title, recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb -> onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
hideInputOverlays() hideInputOverlays()
showMediaPicker = false showMediaPicker = false
showContextMenu = false showContextMenu = false
@@ -3121,6 +3164,14 @@ fun ChatDetailScreen(
simplePickerPreviewSourceThumb = sourceThumb simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = "" simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri 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( SimpleFullscreenPhotoOverlay(
imageUri = previewUri, imageUri = previewUri,
sourceThumbnail = simplePickerPreviewSourceThumb, sourceThumbnail = simplePickerPreviewSourceThumb,
galleryImageUris = simplePickerPreviewGalleryUris,
initialGalleryIndex = simplePickerPreviewInitialIndex,
modifier = Modifier.fillMaxSize().zIndex(100f), modifier = Modifier.fillMaxSize().zIndex(100f),
showCaptionInput = true, showCaptionInput = true,
caption = simplePickerPreviewCaption, caption = simplePickerPreviewCaption,
@@ -3429,12 +3482,16 @@ fun ChatDetailScreen(
simplePickerPreviewUri = null simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null simplePickerPreviewSourceThumb = null
simplePickerPreviewCaption = "" simplePickerPreviewCaption = ""
simplePickerPreviewGalleryUris = emptyList()
simplePickerPreviewInitialIndex = 0
inputFocusTrigger++ inputFocusTrigger++
}, },
onDismiss = { onDismiss = {
simplePickerPreviewUri = null simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null simplePickerPreviewSourceThumb = null
simplePickerPreviewCaption = "" simplePickerPreviewCaption = ""
simplePickerPreviewGalleryUris = emptyList()
simplePickerPreviewInitialIndex = 0
inputFocusTrigger++ inputFocusTrigger++
} }
) )

View File

@@ -737,11 +737,7 @@ fun GroupInfoScreen(
info?.title?.takeIf { it.isNotBlank() } info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() }
?: fallbackName ?: fallbackName
val subtitle = when { val subtitle = if (isOnline) "online" else "offline"
isOnline -> "online"
info?.username?.isNotBlank() == true -> "@${info.username}"
else -> key.take(18)
}
GroupMemberUi( GroupMemberUi(
publicKey = key, publicKey = key,
title = displayTitle, title = displayTitle,
@@ -761,9 +757,11 @@ fun GroupInfoScreen(
if (query.isBlank()) { if (query.isBlank()) {
true true
} else { } else {
val username = memberInfoByKey[member.publicKey]?.username?.lowercase().orEmpty()
member.title.lowercase().contains(query) || member.title.lowercase().contains(query) ||
member.subtitle.lowercase().contains(query) || member.subtitle.lowercase().contains(query) ||
member.publicKey.lowercase().contains(query) member.publicKey.lowercase().contains(query) ||
username.contains(query)
} }
} }
} }

View File

@@ -183,7 +183,7 @@ fun GroupSetupScreen(
.ifBlank { normalizedUsername } .ifBlank { normalizedUsername }
.ifBlank { shortPublicKey(accountPublicKey) } .ifBlank { shortPublicKey(accountPublicKey) }
} }
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you" val selfSubtitle = "online"
fun openGroup(dialogPublicKey: String, groupTitle: String) { fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened( onGroupOpened(

View File

@@ -155,7 +155,7 @@ fun ChatAttachAlert(
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
maxSelection: Int = 10, maxSelection: Int = 10,
recipientName: String? = null, recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null, onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null,
viewModel: AttachAlertViewModel = viewModel() viewModel: AttachAlertViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -1057,7 +1057,13 @@ fun ChatAttachAlert(
if (!item.isVideo) { if (!item.isVideo) {
hideKeyboard() hideKeyboard()
if (onPhotoPreviewRequested != null) { 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 { } else {
thumbnailPosition = position thumbnailPosition = position
viewModel.setEditingItem(item) viewModel.setEditingItem(item)
@@ -1260,9 +1266,17 @@ fun ChatAttachAlert(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
state.editingItem?.let { item -> 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( SimpleFullscreenPhotoViewer(
imageUri = item.uri, imageUri = item.uri,
sourceThumbnail = thumbnailPosition, sourceThumbnail = thumbnailPosition,
galleryImageUris = galleryPhotoUris,
initialGalleryIndex = initialGalleryIndex,
onDismiss = { onDismiss = {
viewModel.setEditingItem(null) viewModel.setEditingItem(null)
thumbnailPosition = null thumbnailPosition = null

View File

@@ -134,7 +134,7 @@ fun MediaPickerBottomSheet(
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
maxSelection: Int = 10, maxSelection: Int = 10,
recipientName: String? = null, recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) { val hasNativeNavigationBar = remember(context) {
@@ -1043,7 +1043,13 @@ fun MediaPickerBottomSheet(
if (!item.isVideo) { if (!item.isVideo) {
hideKeyboard() hideKeyboard()
if (onPhotoPreviewRequested != null) { 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 { } else {
thumbnailPosition = position thumbnailPosition = position
editingItem = item editingItem = item

View File

@@ -20,11 +20,14 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing 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.core.tween
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -51,6 +54,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -79,6 +84,9 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat 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.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.components.AppleEmojiEditTextView import com.rosetta.messenger.ui.components.AppleEmojiEditTextView
@@ -95,6 +103,8 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val ViewerExpandEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) 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( private data class SimpleViewerTransform(
val scaleX: Float, val scaleX: Float,
@@ -104,6 +114,11 @@ private data class SimpleViewerTransform(
val cornerRadiusDp: Float val cornerRadiusDp: Float
) )
private enum class SwipeAxis {
HORIZONTAL,
VERTICAL
}
private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
return start + (stop - start) * fraction return start + (stop - start) * fraction
} }
@@ -158,6 +173,8 @@ fun SimpleFullscreenPhotoViewer(
imageUri: Uri, imageUri: Uri,
onDismiss: () -> Unit, onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null, sourceThumbnail: ThumbnailPosition? = null,
galleryImageUris: List<Uri> = emptyList(),
initialGalleryIndex: Int = 0,
showCaptionInput: Boolean = false, showCaptionInput: Boolean = false,
caption: String = "", caption: String = "",
onCaptionChange: ((String) -> Unit)? = null, onCaptionChange: ((String) -> Unit)? = null,
@@ -183,6 +200,8 @@ fun SimpleFullscreenPhotoViewer(
imageUri = imageUri, imageUri = imageUri,
onDismiss = onDismiss, onDismiss = onDismiss,
sourceThumbnail = sourceThumbnail, sourceThumbnail = sourceThumbnail,
galleryImageUris = galleryImageUris,
initialGalleryIndex = initialGalleryIndex,
showCaptionInput = showCaptionInput, showCaptionInput = showCaptionInput,
caption = caption, caption = caption,
onCaptionChange = onCaptionChange, onCaptionChange = onCaptionChange,
@@ -197,6 +216,8 @@ fun SimpleFullscreenPhotoOverlay(
imageUri: Uri, imageUri: Uri,
onDismiss: () -> Unit, onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null, sourceThumbnail: ThumbnailPosition? = null,
galleryImageUris: List<Uri> = emptyList(),
initialGalleryIndex: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showCaptionInput: Boolean = false, showCaptionInput: Boolean = false,
caption: String = "", caption: String = "",
@@ -208,6 +229,8 @@ fun SimpleFullscreenPhotoOverlay(
imageUri = imageUri, imageUri = imageUri,
onDismiss = onDismiss, onDismiss = onDismiss,
sourceThumbnail = sourceThumbnail, sourceThumbnail = sourceThumbnail,
galleryImageUris = galleryImageUris,
initialGalleryIndex = initialGalleryIndex,
modifier = modifier, modifier = modifier,
showCaptionInput = showCaptionInput, showCaptionInput = showCaptionInput,
caption = caption, caption = caption,
@@ -222,6 +245,8 @@ private fun SimpleFullscreenPhotoContent(
imageUri: Uri, imageUri: Uri,
onDismiss: () -> Unit, onDismiss: () -> Unit,
sourceThumbnail: ThumbnailPosition? = null, sourceThumbnail: ThumbnailPosition? = null,
galleryImageUris: List<Uri> = emptyList(),
initialGalleryIndex: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showCaptionInput: Boolean = false, showCaptionInput: Boolean = false,
caption: String = "", caption: String = "",
@@ -243,7 +268,28 @@ private fun SimpleFullscreenPhotoContent(
var isKeyboardVisible by remember { mutableStateOf(false) } var isKeyboardVisible by remember { mutableStateOf(false) }
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
var localCaption by remember(imageUri) { mutableStateOf("") } 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 currentTool by remember { mutableStateOf(EditorTool.NONE) }
var selectedColor by remember { mutableStateOf(Color.White) } var selectedColor by remember { mutableStateOf(Color.White) }
var brushSize by remember { mutableStateOf(12f) } var brushSize by remember { mutableStateOf(12f) }
@@ -256,6 +302,13 @@ private fun SimpleFullscreenPhotoContent(
var rotationAngle by remember { mutableStateOf(0f) } var rotationAngle by remember { mutableStateOf(0f) }
var isFlippedHorizontally by remember { mutableStateOf(false) } var isFlippedHorizontally by remember { mutableStateOf(false) }
var isFlippedVertically by remember { mutableStateOf(false) } var isFlippedVertically by remember { mutableStateOf(false) }
var swipeAxis by remember { mutableStateOf<SwipeAxis?>(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<Uri?>(null) }
var editorCoverUri by remember { mutableStateOf<Uri?>(null) }
val progress = remember(imageUri, sourceThumbnail) { val progress = remember(imageUri, sourceThumbnail) {
Animatable(if (sourceThumbnail != null) 0f else 1f) Animatable(if (sourceThumbnail != null) 0f else 1f)
@@ -293,6 +346,17 @@ private fun SimpleFullscreenPhotoContent(
LaunchedEffect(imageUri, sourceThumbnail) { LaunchedEffect(imageUri, sourceThumbnail) {
localCaption = caption 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) { if (progress.value < 1f) {
progress.animateTo( progress.animateTo(
targetValue = 1f, 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() { fun toggleEmojiPicker() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastToggleTime < toggleCooldownMs) { if (now - lastToggleTime < toggleCooldownMs) {
@@ -380,6 +456,168 @@ private fun SimpleFullscreenPhotoContent(
BackHandler { closeViewer() } 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) { val transform by remember(sourceThumbnail, screenSize, progress.value) {
derivedStateOf { derivedStateOf {
val p = progress.value val p = progress.value
@@ -418,19 +656,100 @@ private fun SimpleFullscreenPhotoContent(
val tapToDismissModifier = val tapToDismissModifier =
if (!showCaptionInput) { if (!showCaptionInput) {
Modifier.pointerInput(imageUri) { detectTapGestures(onTap = { closeViewer() }) } Modifier.pointerInput(currentImageUri) { detectTapGestures(onTap = { closeViewer() }) }
} else { } else {
Modifier 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( Box(
modifier = modifier =
modifier.fillMaxSize() modifier.fillMaxSize()
.onSizeChanged { screenSize = it } .onSizeChanged { screenSize = it }
.background(Color.Black) .background(Color.Black.copy(alpha = swipeScrimAlpha))
.then(tapToDismissModifier), .then(tapToDismissModifier),
contentAlignment = Alignment.Center 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( AndroidView(
factory = { ctx -> factory = { ctx ->
PhotoEditorView(ctx).apply { PhotoEditorView(ctx).apply {
@@ -442,6 +761,7 @@ private fun SimpleFullscreenPhotoContent(
adjustViewBounds = false adjustViewBounds = false
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
setImageURI(currentImageUri) setImageURI(currentImageUri)
tag = currentImageUri
} }
photoEditor = PhotoEditor.Builder(ctx, this) photoEditor = PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true) .setPinchTextScalable(true)
@@ -451,20 +771,59 @@ private fun SimpleFullscreenPhotoContent(
}, },
update = { editorView -> update = { editorView ->
if (editorView.source.tag != currentImageUri) { if (editorView.source.tag != currentImageUri) {
editorView.source.setImageURI(currentImageUri) if (editorLoadingUri != currentImageUri) {
editorView.source.tag = 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.rotation = rotationAngle
editorView.source.scaleX = if (isFlippedHorizontally) -1f else 1f editorView.source.scaleX = if (isFlippedHorizontally) -1f else 1f
editorView.source.scaleY = if (isFlippedVertically) -1f else 1f editorView.source.scaleY = if (isFlippedVertically) -1f else 1f
if (editorView.source.tag == currentImageUri && editorCoverUri == currentImageUri) {
editorCoverUri = null
}
}, },
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.then(swipeGestureModifier)
.graphicsLayer { .graphicsLayer {
alpha = if (shouldShowHorizontalCurrentLayer) 0f else 1f
scaleX = transform.scaleX scaleX = transform.scaleX
scaleY = transform.scaleY scaleY = transform.scaleY
translationX = transform.translationX translationX = transform.translationX + animatedSwipeOffsetX
translationY = transform.translationY translationY = transform.translationY + animatedSwipeOffsetY
} }
.then( .then(
if (transform.cornerRadiusDp > 0f) { if (transform.cornerRadiusDp > 0f) {