Релиз 1.1.8: fullscreen фото, пересылка и статусы участников групп
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user