diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 860ea20..04bb4b0 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable @@ -247,13 +248,29 @@ private fun QMaxApp(vm: QMaxViewModel) { } state.previewImageUrl?.let { url -> + val previewAttachment = remember( + url, + state.messages, + state.cachedImagePaths, + state.session?.serverUrl + ) { + findPreviewAttachment( + url = url, + messages = state.messages, + cachedImagePaths = state.cachedImagePaths, + session = state.session + ) + } ImagePreview( url = url, session = state.session, + attachment = previewAttachment, position = state.previewImageIndex, total = state.previewImageGallery.size, onPrevious = { vm.moveImagePreview(-1) }, onNext = { vm.moveImagePreview(1) }, + onShare = previewAttachment?.let { attachment -> { vm.shareAttachment(attachment) } }, + onOpen = previewAttachment?.let { attachment -> { vm.openAttachment(attachment) } }, onClose = vm::closeImage ) } @@ -3877,12 +3894,16 @@ private fun avatarColor(title: String): Color { private fun ImagePreview( url: String, session: QMaxSession?, + attachment: AttachmentDto?, position: Int, total: Int, onPrevious: () -> Unit, onNext: () -> Unit, + onShare: (() -> Unit)?, + onOpen: (() -> Unit)?, onClose: () -> Unit ) { + BackHandler(onBack = onClose) val model = when { url.isLocalImagePath() -> File(url.removePrefix("file://")) session != null -> imageRequest(url, session.accessToken) @@ -3890,16 +3911,43 @@ private fun ImagePreview( } var scale by remember(url) { mutableStateOf(1f) } var offset by remember(url) { mutableStateOf(Offset.Zero) } + var horizontalDrag by remember(url) { mutableStateOf(0f) } val transformState = rememberTransformableState { zoomChange, panChange, _ -> val nextScale = (scale * zoomChange).coerceIn(1f, 5f) scale = nextScale offset = if (nextScale == 1f) Offset.Zero else offset + panChange } + fun resetZoom() { + scale = 1f + offset = Offset.Zero + } + fun move(delta: Int) { + resetZoom() + if (delta < 0) onPrevious() else onNext() + } Box( Modifier .fillMaxSize() - .background(Color.Black), + .background(Color.Black) + .pointerInput(url, total, scale) { + detectHorizontalDragGestures( + onDragStart = { horizontalDrag = 0f }, + onHorizontalDrag = { _, dragAmount -> + if (total > 1 && scale <= 1.02f) { + horizontalDrag += dragAmount + } + }, + onDragEnd = { + when { + horizontalDrag > 120f -> move(-1) + horizontalDrag < -120f -> move(1) + } + horizontalDrag = 0f + }, + onDragCancel = { horizontalDrag = 0f } + ) + }, contentAlignment = Alignment.Center ) { if (model != null) { @@ -3930,32 +3978,44 @@ private fun ImagePreview( } ) } - Surface( - color = Color(0x99000000), - shape = CircleShape, + Row( modifier = Modifier - .align(Alignment.TopStart) - .padding(14.dp) + .align(Alignment.TopCenter) + .fillMaxWidth() + .statusBarsPadding() + .background(Color(0x99000000)) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onClose, modifier = Modifier.size(44.dp)) { Icon(Icons.Filled.Close, contentDescription = "Закрыть", tint = Color.White) } - } - if (total > 1) { - Surface( - color = Color(0x99000000), - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 18.dp) - ) { + Column(Modifier.weight(1f)) { Text( - text = "${position + 1}/$total", + text = attachment?.displayFileName() ?: "Фото", color = Color.White, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (total > 1) "${position + 1} из $total" else "Просмотр", + color = Color(0xCCFFFFFF), + style = MaterialTheme.typography.labelMedium ) } + onShare?.let { share -> + IconButton(onClick = share, modifier = Modifier.size(44.dp)) { + Icon(Icons.Filled.Share, contentDescription = "Поделиться", tint = Color.White) + } + } + onOpen?.let { open -> + TextButton(onClick = open) { + Text("Открыть", color = Color.White) + } + } + } + if (total > 1) { Surface( color = Color(0x99000000), shape = CircleShape, @@ -3963,7 +4023,7 @@ private fun ImagePreview( .align(Alignment.CenterStart) .padding(start = 10.dp) ) { - IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) { + IconButton(onClick = { move(-1) }, modifier = Modifier.size(48.dp)) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous photo", tint = Color.White) } } @@ -3974,11 +4034,33 @@ private fun ImagePreview( .align(Alignment.CenterEnd) .padding(end = 10.dp) ) { - IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) { + IconButton(onClick = { move(1) }, modifier = Modifier.size(48.dp)) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next photo", tint = Color.White) } } } + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 18.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface(color = Color(0x99000000), shape = RoundedCornerShape(18.dp)) { + Text( + text = "${(scale * 100).toInt()}%", + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp) + ) + } + if (scale > 1.02f || offset != Offset.Zero) { + TextButton(onClick = { resetZoom() }) { + Text("Сбросить", color = Color.White) + } + } + } } } @@ -4097,6 +4179,33 @@ private fun rememberAttachmentUrl(session: QMaxSession, attachment: AttachmentDt return "${session.serverUrl.trimEnd('/')}${attachment.downloadPath}" } +private fun findPreviewAttachment( + url: String, + messages: List, + cachedImagePaths: Map, + session: QMaxSession? +): AttachmentDto? { + val normalizedUrl = url.removePrefix("file://") + val serverUrl = session?.serverUrl?.trimEnd('/') + return messages + .flatMap { it.attachments } + .firstOrNull { attachment -> + val candidates = buildList { + add(attachment.downloadPath) + if (serverUrl != null) { + add("$serverUrl${attachment.downloadPath}") + } + cachedImagePaths[attachment.id]?.let { cached -> + add(cached) + add("file://$cached") + } + } + candidates.any { candidate -> + candidate == url || candidate.removePrefix("file://") == normalizedUrl + } + } +} + @Composable private fun rememberAuthorizedPlayer(url: String, token: String): ExoPlayer { val context = LocalContext.current