Релиз 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
// ═══════════════════════════════════════════════════════════
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"

View File

@@ -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 =

View File

@@ -138,6 +138,28 @@ private data class IncomingRunAvatarUiState(
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(
ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -297,6 +319,8 @@ fun ChatDetailScreen(
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
var simplePickerPreviewCaption by remember { mutableStateOf("") }
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(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++
}
)

View File

@@ -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)
}
}
}

View File

@@ -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(

View File

@@ -155,7 +155,7 @@ fun ChatAttachAlert(
currentUserPublicKey: String = "",
maxSelection: Int = 10,
recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, 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

View File

@@ -134,7 +134,7 @@ fun MediaPickerBottomSheet(
currentUserPublicKey: String = "",
maxSelection: Int = 10,
recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, 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

View File

@@ -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<Uri> = 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<Uri> = 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<Uri> = 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<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) {
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) {