diff --git a/android/app/src/androidTest/java/com/galo/android/ZoomGestureTest.kt b/android/app/src/androidTest/java/com/galo/android/ZoomGestureTest.kt new file mode 100644 index 0000000..bc1ca63 --- /dev/null +++ b/android/app/src/androidTest/java/com/galo/android/ZoomGestureTest.kt @@ -0,0 +1,67 @@ +package com.galo.android + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.galo.android.ui.theme.GaloTheme +import com.galo.android.ui.viewer.TAG_ZOOMABLE_IMAGE +import com.galo.android.ui.viewer.ZoomableImage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Device check for the photo zoom: a real pinch enlarges the image and a double + * tap returns it to the fitted view (the 200% cap and pan clamp are covered by + * the unit tests for `PhotoZoom`). + */ +@RunWith(AndroidJUnit4::class) +class ZoomGestureTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun pinchZoomsAndDoubleTapResets() { + val zoomed = AtomicBoolean(false) + compose.setContent { + GaloTheme(dynamicColor = false) { + Box(Modifier.fillMaxSize()) { + ZoomableImage( + previewUrl = null, + hiResUrl = null, + contentDescription = null, + naturalWidth = 2000, + naturalHeight = 1000, + onZoomChanged = { zoomed.set(it) }, + ) + } + } + } + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { + pinch( + start0 = center + Offset(-60f, 0f), + end0 = center + Offset(-260f, 0f), + start1 = center + Offset(60f, 0f), + end1 = center + Offset(260f, 0f), + ) + } + compose.waitForIdle() + assertTrue("pinch should zoom the image", zoomed.get()) + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() } + compose.waitForIdle() + assertFalse("double tap should return to the fitted view", zoomed.get()) + } +} diff --git a/android/app/src/main/java/com/galo/android/domain/viewer/PhotoZoom.kt b/android/app/src/main/java/com/galo/android/domain/viewer/PhotoZoom.kt new file mode 100644 index 0000000..202c611 --- /dev/null +++ b/android/app/src/main/java/com/galo/android/domain/viewer/PhotoZoom.kt @@ -0,0 +1,125 @@ +package com.galo.android.domain.viewer + +/** + * Zoom and pan state for a single photo in the full-screen viewer, mirroring the + * web `Viewer.svelte` semantics: [scale] is a multiplier of the natural image + * size, [fitScale] is the largest scale that keeps the whole image inside the + * viewport (never above 1x), and zoom is capped at [MAX_SCALE] (200% of the + * natural size). Pan offsets are clamped so the image never detaches from the + * viewport edges. + * + * Kept free of Android/Compose types so the zoom math is unit-testable. + */ +class PhotoZoom( + naturalWidth: Float = 0f, + naturalHeight: Float = 0f, + viewportWidth: Float = 0f, + viewportHeight: Float = 0f, +) { + + private var naturalWidth = naturalWidth + private var naturalHeight = naturalHeight + private var viewportWidth = viewportWidth + private var viewportHeight = viewportHeight + + /** Current scale as a multiplier of the natural image size. */ + var scale: Float = 1f + private set + + /** Horizontal pan of the image centre from the viewport centre, in pixels. */ + var offsetX: Float = 0f + private set + + /** Vertical pan of the image centre from the viewport centre, in pixels. */ + var offsetY: Float = 0f + private set + + init { + reset() + } + + /** Largest scale that fits the whole image inside the viewport, capped at 1x. */ + val fitScale: Float + get() { + if (naturalWidth <= 0f || naturalHeight <= 0f || viewportWidth <= 0f || viewportHeight <= 0f) { + return 1f + } + return minOf(viewportWidth / naturalWidth, viewportHeight / naturalHeight, 1f) + } + + /** True when the image is drawn larger than the fitted view. */ + val isZoomed: Boolean + get() = scale > fitScale + ZOOMED_EPSILON + + /** Displayed width/height for the current scale. */ + val displayWidth: Float get() = naturalWidth * scale + val displayHeight: Float get() = naturalHeight * scale + + /** `graphicsLayer` scale relative to the fitted image (1.0 == fitted view). */ + val layerScale: Float + get() = if (fitScale > 0f) scale / fitScale else 1f + + fun setViewport(width: Float, height: Float) { + if (width == viewportWidth && height == viewportHeight) return + viewportWidth = width + viewportHeight = height + reset() + } + + fun setNaturalSize(width: Float, height: Float) { + if (width == naturalWidth && height == naturalHeight) return + naturalWidth = width + naturalHeight = height + reset() + } + + fun reset() { + scale = fitScale + offsetX = 0f + offsetY = 0f + } + + /** Scale by [factor] keeping the viewport-relative focus point fixed. */ + fun scaleBy(factor: Float, focusX: Float, focusY: Float) { + scaleTo(scale * factor, focusX, focusY) + } + + /** + * Set [scale] to [target] (clamped to [fitScale]..[MAX_SCALE]) keeping the + * point under [focusX],[focusY] - offsets from the viewport centre - fixed. + */ + fun scaleTo(target: Float, focusX: Float = 0f, focusY: Float = 0f) { + val clamped = target.coerceIn(fitScale, MAX_SCALE) + if (clamped == scale) return + val ratio = clamped / scale + offsetX = focusX - (focusX - offsetX) * ratio + offsetY = focusY - (focusY - offsetY) * ratio + scale = clamped + clampOffsets() + } + + /** Pan the (already zoomed) image by a viewport-relative delta. */ + fun panBy(dx: Float, dy: Float) { + offsetX += dx + offsetY += dy + clampOffsets() + } + + /** Double tap: toggle fitted view <-> 100% natural, anchored at the tap. */ + fun toggleDoubleTap(focusX: Float = 0f, focusY: Float = 0f) { + if (isZoomed) reset() else scaleTo(1f, focusX, focusY) + } + + private fun clampOffsets() { + val maxX = ((displayWidth - viewportWidth) / 2f).coerceAtLeast(0f) + val maxY = ((displayHeight - viewportHeight) / 2f).coerceAtLeast(0f) + offsetX = offsetX.coerceIn(-maxX, maxX) + offsetY = offsetY.coerceIn(-maxY, maxY) + } + + companion object { + /** Upper zoom bound: 200% of the natural image size. */ + const val MAX_SCALE = 2f + private const val ZOOMED_EPSILON = 1e-3f + } +} diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt index f19c341..98f0f8c 100644 --- a/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt @@ -18,11 +18,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -31,7 +32,6 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.galo.android.GaloApplication import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.Photo -import com.galo.android.ui.common.GaloAsyncImage const val TAG_VIEWER_PAGER = "viewer_pager" @@ -84,9 +84,11 @@ fun ViewerContent( initialPage = initialIndex.coerceIn(0, photos.lastIndex), ) { photos.size } val current = photos.getOrNull(pagerState.currentPage) - // A 360 panorama wraps horizontally forever, so on panorama pages the pager - // must not steal horizontal drags: they rotate the view instead. - val pagerScrollEnabled = current?.isPanorama != true + // Reset the zoom when the current photo changes; while an image is zoomed the + // pager must not steal horizontal drags - they pan the image instead. A 360 + // panorama wraps forever, so its pages keep the drag to rotate the view. + var zoomed by remember(current?.id) { mutableStateOf(false) } + val pagerScrollEnabled = current?.isPanorama != true && !zoomed Scaffold( topBar = { @@ -116,14 +118,22 @@ fun ViewerContent( key = { index -> photos.getOrNull(index)?.id ?: index }, ) { page -> val photo = photos.getOrNull(page) ?: return@HorizontalPager - MediaPage(photo) + MediaPage( + photo = photo, + active = pagerState.currentPage == page, + onZoomChanged = { zoomed = it }, + ) } } } } @Composable -private fun MediaPage(photo: Photo) { +private fun MediaPage( + photo: Photo, + active: Boolean, + onZoomChanged: (Boolean) -> Unit, +) { when { photo.kind == MediaKind.VIDEO -> { val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl @@ -144,10 +154,14 @@ private fun MediaPage(photo: Photo) { } else -> { - GaloAsyncImage( - url = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl, + ZoomableImage( + previewUrl = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl, + hiResUrl = photo.originalUrl, contentDescription = photo.originalFilename, - contentScale = ContentScale.Fit, + naturalWidth = photo.width, + naturalHeight = photo.height, + onZoomChanged = onZoomChanged, + active = active, modifier = Modifier.fillMaxSize(), ) } diff --git a/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt b/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt new file mode 100644 index 0000000..0bba99a --- /dev/null +++ b/android/app/src/main/java/com/galo/android/ui/viewer/ZoomableImage.kt @@ -0,0 +1,193 @@ +package com.galo.android.ui.viewer + +import android.content.Context +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntSize +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.galo.android.data.image.ObjectCacheKey +import com.galo.android.domain.viewer.PhotoZoom + +const val TAG_ZOOMABLE_IMAGE = "zoomable_image" + +/** + * Image source shown for the current zoom state: the original when the image is + * zoomed and an original URL is available, otherwise the fitted preview. + */ +internal fun zoomSource(previewUrl: String?, hiResUrl: String?, zoomed: Boolean): String? = + if (zoomed && !hiResUrl.isNullOrBlank()) hiResUrl else previewUrl + +/** + * Coil request for [source], cached by the stable object path. When [source] + * differs from [previewUrl] (i.e. the original is loading on zoom) the preview + * is wired as a placeholder, so the surface never flashes empty/black. + */ +internal fun zoomImageRequest(context: Context, source: String?, previewUrl: String?): ImageRequest { + val previewKey = ObjectCacheKey.keyFor(previewUrl) + return ImageRequest.Builder(context) + .data(source) + .apply { + ObjectCacheKey.keyFor(source)?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + if (source != previewUrl) { + previewKey?.let { placeholderMemoryCacheKey(it) } + } + } + .crossfade(true) + .build() +} + +/** + * Photo that can be zoomed and panned, mirroring the web viewer semantics (see + * `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to + * [hiResUrl] (the original) so details stay sharp. + * + * While zoomed, horizontal drags pan the image instead of paging the album. + * [onZoomChanged] reports the zoom state so the surrounding pager can disable + * itself; [active] resets the zoom when the page leaves focus. + */ +@Composable +fun ZoomableImage( + previewUrl: String?, + hiResUrl: String?, + contentDescription: String?, + naturalWidth: Int, + naturalHeight: Int, + onZoomChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + active: Boolean = true, +) { + val zoom = remember { PhotoZoom() } + var viewport by remember { mutableStateOf(IntSize.Zero) } + var natural by remember { mutableStateOf(IntSize(naturalWidth, naturalHeight)) } + var layerScale by remember { mutableFloatStateOf(1f) } + var panX by remember { mutableFloatStateOf(0f) } + var panY by remember { mutableFloatStateOf(0f) } + var zoomed by remember { mutableStateOf(false) } + + fun sync() { + layerScale = zoom.layerScale + panX = zoom.offsetX + panY = zoom.offsetY + val nowZoomed = zoom.isZoomed + if (nowZoomed != zoomed) { + zoomed = nowZoomed + onZoomChanged(nowZoomed) + } + } + + LaunchedEffect(viewport, natural) { + zoom.setNaturalSize(natural.width.toFloat(), natural.height.toFloat()) + zoom.setViewport(viewport.width.toFloat(), viewport.height.toFloat()) + sync() + } + + // Leaving the page resets the zoom so returning shows the fitted photo. + LaunchedEffect(active) { + if (!active) { + zoom.reset() + sync() + } + } + + val source = zoomSource(previewUrl, hiResUrl, zoomed) + + Box( + modifier = modifier + .onSizeChanged { viewport = it } + .testTag(TAG_ZOOMABLE_IMAGE) + .pointerInput(viewport) { + if (viewport.width <= 0 || viewport.height <= 0) return@pointerInput + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + do { + val event = awaitPointerEvent() + val pressed = event.changes.count { it.pressed } + if (pressed >= 2) { + val zoomChange = event.calculateZoom() + val panChange = event.calculatePan() + if (zoomChange != 1f || panChange != Offset.Zero) { + val centroid = event.calculateCentroid(useCurrent = true) + zoom.scaleBy( + zoomChange, + focusX = centroid.x - viewport.width / 2f, + focusY = centroid.y - viewport.height / 2f, + ) + zoom.panBy(panChange.x, panChange.y) + sync() + event.changes.forEach { it.consume() } + } + } else if (pressed == 1 && zoom.isZoomed) { + val panChange = event.calculatePan() + if (panChange != Offset.Zero) { + zoom.panBy(panChange.x, panChange.y) + sync() + event.changes.forEach { it.consume() } + } + } + } while (event.changes.any { it.pressed }) + } + } + .pointerInput(viewport) { + detectTapGestures( + onDoubleTap = { position -> + zoom.toggleDoubleTap( + focusX = position.x - viewport.width / 2f, + focusY = position.y - viewport.height / 2f, + ) + sync() + }, + ) + }, + ) { + val context = LocalContext.current + val request = remember(source, previewUrl, context) { + zoomImageRequest(context, source, previewUrl) + } + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + onSuccess = { state -> + if (natural.width <= 0 || natural.height <= 0) { + val drawable = state.result.drawable + if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { + natural = IntSize(drawable.intrinsicWidth, drawable.intrinsicHeight) + } + } + }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = layerScale + scaleY = layerScale + translationX = panX + translationY = panY + }, + ) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/viewer/PhotoZoomTest.kt b/android/app/src/test/java/com/galo/android/domain/viewer/PhotoZoomTest.kt new file mode 100644 index 0000000..0a0933f --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/viewer/PhotoZoomTest.kt @@ -0,0 +1,94 @@ +package com.galo.android.domain.viewer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhotoZoomTest { + + // 2000x1000 image in a 1000x1000 viewport -> fitScale = min(0.5, 1, 1) = 0.5. + private fun zoom() = PhotoZoom( + naturalWidth = 2000f, + naturalHeight = 1000f, + viewportWidth = 1000f, + viewportHeight = 1000f, + ) + + private fun imageX(z: PhotoZoom, focusX: Float): Float = (focusX - z.offsetX) / z.scale + + @Test + fun `fit scale fits the image and never upscales`() { + assertEquals(0.5f, zoom().fitScale, 1e-4f) + val small = PhotoZoom(100f, 100f, 1000f, 1000f) + assertEquals(1f, small.fitScale, 1e-4f) + } + + @Test + fun `starts fitted and not zoomed`() { + val z = zoom() + assertEquals(z.fitScale, z.scale, 1e-4f) + assertEquals(1f, z.layerScale, 1e-4f) + assertFalse(z.isZoomed) + } + + @Test + fun `scale is clamped to the fitted view on the low end`() { + val z = zoom() + z.scaleTo(0.1f) + assertEquals(z.fitScale, z.scale, 1e-4f) + } + + @Test + fun `scale is clamped to 200 percent on the high end`() { + val z = zoom() + z.scaleTo(5f) + assertEquals(PhotoZoom.MAX_SCALE, z.scale, 1e-4f) + z.scaleBy(10f, focusX = 0f, focusY = 0f) + assertEquals(PhotoZoom.MAX_SCALE, z.scale, 1e-4f) + } + + @Test + fun `zoom keeps the focus point under the finger`() { + val z = zoom() + val focusX = 200f + val before = imageX(z, focusX) + z.scaleTo(1f, focusX = focusX, focusY = 0f) + assertEquals(1f, z.scale, 1e-4f) + assertEquals(before, imageX(z, focusX), 1e-3f) + } + + @Test + fun `double tap toggles between fitted view and 100 percent`() { + val z = zoom() + z.toggleDoubleTap(focusX = 100f, focusY = 0f) + assertEquals(1f, z.scale, 1e-4f) + assertTrue(z.isZoomed) + + z.toggleDoubleTap() + assertEquals(z.fitScale, z.scale, 1e-4f) + assertEquals(0f, z.offsetX, 1e-4f) + assertEquals(0f, z.offsetY, 1e-4f) + assertFalse(z.isZoomed) + } + + @Test + fun `pan is clamped so the image stays attached to the viewport`() { + val z = zoom() + z.scaleTo(1f) + // displayWidth = 2000 in a 1000 wide viewport -> max offset 500. + z.panBy(10_000f, 10_000f) + assertEquals(500f, z.offsetX, 1e-3f) + assertEquals(0f, z.offsetY, 1e-3f) + z.panBy(-10_000f, -10_000f) + assertEquals(-500f, z.offsetX, 1e-3f) + } + + @Test + fun `pan is a no-op in the fitted view`() { + val z = zoom() + z.panBy(100f, 100f) + assertEquals(0f, z.offsetX, 1e-4f) + assertEquals(0f, z.offsetY, 1e-4f) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt index 1497d5a..6b7e78f 100644 --- a/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ViewerContentTest.kt @@ -1,10 +1,12 @@ package com.galo.android.ui.viewer +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch import androidx.compose.ui.test.swipeLeft import com.galo.android.domain.model.MediaKind import com.galo.android.domain.model.Photo @@ -107,4 +109,34 @@ class ViewerContentTest { // Paging is disabled on panorama pages so the drag can rotate the view. compose.onNodeWithText("pano.jpg").assertIsDisplayed() } + + @Test + fun `horizontal swipe does not page away from a zoomed photo`() { + compose.setContent { + GaloTheme(dynamicColor = false) { + ViewerContent( + photos = listOf(photo("p1", "first.jpg"), photo("p2", "second.jpg")), + initialIndex = 0, + onBack = {}, + ) + } + } + + compose.onNodeWithText("first.jpg").assertIsDisplayed() + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { + pinch( + start0 = center + Offset(-40f, 0f), + end0 = center + Offset(-160f, 0f), + start1 = center + Offset(40f, 0f), + end1 = center + Offset(160f, 0f), + ) + } + compose.waitForIdle() + + compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeLeft() } + compose.waitForIdle() + + // Paging is disabled while an image is zoomed so the drag pans it instead. + compose.onNodeWithText("first.jpg").assertIsDisplayed() + } } diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt new file mode 100644 index 0000000..9e2b160 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomImageRequestTest.kt @@ -0,0 +1,38 @@ +package com.galo.android.ui.viewer + +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ZoomImageRequestTest { + + private val context = ApplicationProvider.getApplicationContext() + private val preview = "https://galo.test/api/objects/preview.jpg?e=1&s=aaaa" + private val original = "https://galo.test/api/objects/original.jpg?e=1&s=bbbb" + + @Test + fun `loading the original keeps the preview as a placeholder`() { + val request = zoomImageRequest(context, original, preview) + + assertEquals("https://galo.test/api/objects/original.jpg", request.memoryCacheKey?.key) + assertNotNull( + "the preview must be a placeholder so zoom does not flash black", + request.placeholderMemoryCacheKey, + ) + assertEquals("https://galo.test/api/objects/preview.jpg", request.placeholderMemoryCacheKey?.key) + } + + @Test + fun `fitted preview has no placeholder`() { + val request = zoomImageRequest(context, preview, preview) + + assertNull(request.placeholderMemoryCacheKey) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ZoomSourceTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomSourceTest.kt new file mode 100644 index 0000000..be7bc9d --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomSourceTest.kt @@ -0,0 +1,37 @@ +package com.galo.android.ui.viewer + +import com.galo.android.data.image.ObjectCacheKey +import org.junit.Assert.assertEquals +import org.junit.Test + +class ZoomSourceTest { + + private val preview = "https://galo.test/api/objects/preview.jpg?e=1&s=aaaa" + private val original = "https://galo.test/api/objects/original.jpg?e=1&s=bbbb" + + @Test + fun `fitted view shows the preview`() { + assertEquals(preview, zoomSource(preview, original, zoomed = false)) + } + + @Test + fun `zoomed view shows the original`() { + assertEquals(original, zoomSource(preview, original, zoomed = true)) + } + + @Test + fun `zoomed view falls back to the preview without an original`() { + assertEquals(preview, zoomSource(preview, null, zoomed = true)) + assertEquals(preview, zoomSource(preview, "", zoomed = true)) + } + + @Test + fun `switching source keeps a signature-independent cache key`() { + // The Coil key drops the rotating signature, so re-serving the same object + // under a new signature reuses the cached bytes. + val signed = ObjectCacheKey.keyFor(original) + val rotated = ObjectCacheKey.keyFor(original.replace("s=bbbb", "s=cccc")) + assertEquals("https://galo.test/api/objects/original.jpg", signed) + assertEquals(signed, rotated) + } +} diff --git a/android/app/src/test/java/com/galo/android/ui/viewer/ZoomableImageTest.kt b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomableImageTest.kt new file mode 100644 index 0000000..13f5c39 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/ui/viewer/ZoomableImageTest.kt @@ -0,0 +1,116 @@ +package com.galo.android.ui.viewer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch +import androidx.compose.ui.unit.dp +import com.galo.android.ui.theme.GaloTheme +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ZoomableImageTest { + + @get:Rule + val compose = createComposeRule() + + private fun setZoomable(onZoomChanged: (Boolean) -> Unit) { + compose.setContent { + GaloTheme(dynamicColor = false) { + Box(Modifier.size(200.dp)) { + ZoomableImage( + previewUrl = "http://localhost/photo.jpg?e=1&s=2", + hiResUrl = "http://localhost/photo.jpg?e=1&s=2&original=1", + contentDescription = null, + naturalWidth = 2000, + naturalHeight = 1000, + onZoomChanged = onZoomChanged, + ) + } + } + } + } + + @Test + fun `pinch zooms the image`() { + var zoomed = false + setZoomable { zoomed = it } + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { + pinch( + start0 = center + Offset(-40f, 0f), + end0 = center + Offset(-160f, 0f), + start1 = center + Offset(40f, 0f), + end1 = center + Offset(160f, 0f), + ) + } + compose.waitForIdle() + + assertTrue("pinch should zoom the image", zoomed) + } + + @Test + fun `double tap toggles between fitted and zoomed`() { + var zoomed = false + setZoomable { zoomed = it } + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() } + compose.waitForIdle() + assertTrue("double tap should zoom in from the fitted view", zoomed) + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { doubleClick() } + compose.waitForIdle() + assertFalse("double tap should return to the fitted view", zoomed) + } + + @Test + fun `leaving the page resets the zoom`() { + var zoomed = false + var active by mutableStateOf(true) + compose.setContent { + GaloTheme(dynamicColor = false) { + Box(Modifier.size(200.dp)) { + ZoomableImage( + previewUrl = "http://localhost/photo.jpg?e=1&s=2", + hiResUrl = "http://localhost/photo.jpg?e=1&s=2&original=1", + contentDescription = null, + naturalWidth = 2000, + naturalHeight = 1000, + onZoomChanged = { zoomed = it }, + active = active, + ) + } + } + } + + compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput { + pinch( + start0 = center + Offset(-40f, 0f), + end0 = center + Offset(-160f, 0f), + start1 = center + Offset(40f, 0f), + end1 = center + Offset(160f, 0f), + ) + } + compose.waitForIdle() + assertTrue(zoomed) + + active = false + compose.waitForIdle() + assertFalse("zoom should reset when the page leaves focus", zoomed) + } +} diff --git a/openspec/changes/add-mobile-photo-zoom/.openspec.yaml b/openspec/changes/add-mobile-photo-zoom/.openspec.yaml new file mode 100644 index 0000000..5fcc9ec --- /dev/null +++ b/openspec/changes/add-mobile-photo-zoom/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-13 diff --git a/openspec/changes/add-mobile-photo-zoom/design.md b/openspec/changes/add-mobile-photo-zoom/design.md new file mode 100644 index 0000000..718795d --- /dev/null +++ b/openspec/changes/add-mobile-photo-zoom/design.md @@ -0,0 +1,74 @@ +# Design + +## Context + +См. `proposal.md` — Why. Текущее устройство просмотра: + +- `ViewerScreen.kt`: `ViewerContent` держит `HorizontalPager`; `pagerScrollEnabled = + current?.isPanorama != true`. `MediaPage` разветвляет видео / панораму / изображение. +- Изображение рисуется `GaloAsyncImage(... ContentScale.Fit)` — статично, без жестов. +- Панорама уже имеет pinch-зум через `PanoramaView` + `PanoramaCamera`. +- Веб-просмотрщик (`frontend/.../Viewer.svelte`) задаёт семантику зума: scale — + множитель к натуральному размеру, предел 200% (`MAX_ZOOM = 2`), минимум — fit + (`fitScale`), при зуме догружается `original_url`, зум сбрасывается при смене фото. + Тач-жестов в вебе нет — жесты проектируем заново. +- Прецедент ручных жестов: `AvatarCropDialog.kt` (`pointerInput { detectDragGestures }`). + +## Goals / Non-Goals + +**Goals:** +- Дать зум обычных изображений в Android-просмотрщике с веб-паритетом по семантике + (предел от натурального размера, догрузка оригинала). +- Сохранить корректную навигацию по альбому (свайп) вне зума. + +**Non-Goals:** +- Зум видео (исключён требованием просмотра медиа). +- Изменения панорамы — её зум остаётся как есть. +- Изменения веб-клиента и сервера. + +## Decisions + +### Ручные жесты вместо библиотеки +Зум реализуется composable-обёрткой над изображением с `Modifier.pointerInput` + +`graphicsLayer` (scale/translation), без сторонних зависимостей. Альтернатива — +библиотека зума; отклонено: в проекте уже принят ручной подход (`AvatarCropDialog`), +а новых зависимостей избегаем. + +### Масштаб как множитель к натуральному размеру (вариант A) +Храним `scale` относительно натурального размера, как в вебе: +`fitScale = min(viewW / natW, viewH / natH, 1)`, допустимый диапазон +`[fitScale, 2.0]` (200% натурального). Натуральные размеры берём из `Photo.width/height` +(уже есть в модели), при отсутствии — из измеренного `intrinsicSize` изображения. +Альтернатива (множитель от fit) отклонена в пользу веб-паритета. + +### Двойной тап -> 100% натурального с якорем в точку тапа +Двойной тап переключает fit <-> 100% натурального размера, центрируя на точке нажатия. +Это тач-замена веб-`Space`, но с якорем в палец вместо центра. + +### Состояние зума живёт в `ViewerContent` +`zoomed` поднимается в `ViewerContent`, а zoomable-обёртка сообщает его вверх через +колбэк. `pagerScrollEnabled = current?.isPanorama != true && !zoomed` — по той же логике, +что уже применяется к панораме. Альтернатива (держать состояние внутри `MediaPage`) не +даёт управлять pager'ом. + +### Догрузка оригинала через Coil по стабильному ключу +При входе в зум источник изображения переключается с `previewUrl` на `originalUrl`; +Coil кэширует по устойчивому ключу объекта (`ObjectCacheKey`), поэтому подмена не ломает +кэш. Пока оригинал грузится, на экране остаётся превью: запрос оригинала получает +`placeholderMemoryCacheKey` превью, чтобы вход в зум не мигал пустым (чёрным) фоном. +Сбрасывается при смене фото. + +### Сброс при смене фото +Состояние зума живёт на страницу pager (ключ — id фото); при переходе на другой элемент +оно пересоздаётся во вписанном виде, что соответствует требованию сброса. + +## Risks / Trade-offs + +- [Свайп pager перехватывает горизонтальный жест увеличенного фото] -> `pagerScrollEnabled` + выключается, пока `zoomed`. +- [`Photo.width/height` может быть 0 до извлечения EXIF] -> fallback на `intrinsicSize` + загруженного изображения; при 0/0 зум временно недоступен. +- [Догрузка оригинала расходует трафик при каждом входе в зум] -> загрузка только по факту + зума и с дисковым кэшем Coil. +- [Точность якоря при двойном тапе и pinch] -> покрываем юнит-тестом на пересчёт + scale/offset. diff --git a/openspec/changes/add-mobile-photo-zoom/proposal.md b/openspec/changes/add-mobile-photo-zoom/proposal.md new file mode 100644 index 0000000..6d1df6d --- /dev/null +++ b/openspec/changes/add-mobile-photo-zoom/proposal.md @@ -0,0 +1,39 @@ +# Proposal + +## Why + +В веб-просмотрщике фото можно приблизить (колесо, `Space`), а в Android-клиенте +изображения в полноэкранном просмотре открываются только во вписанном виде — в 360-панораме +зум есть, в обычных фото нет. Пользователь не может рассмотреть деталь снимка на телефоне. + +## What Changes + +- В полноэкранный просмотр обычных изображений добавляется зум пальцами: + - pinch увеличивает/уменьшает, панорамирование при увеличении, двойной тап + переключает вписанный вид и 100% натурального размера с якорем в точку тапа. +- Предел зума — 200% натурального размера изображения, минимум — вписанный вид + (семантика веб-просмотрщика, адаптированная под тач). +- При входе в зум догружается оригинал (`originalUrl`), другие фото сбрасываются во + вписанный вид при переходе. +- Пока изображение увеличено, горизонтальный свайп не листает альбом — жест отдан + панорамированию (как для панорамы). +- Исключения: видео и 360-панорама не затрагиваются (у панорамы зум уже есть). + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `android-client`: требование «Просмотр медиа на весь экран» расширяется зумом + изображений — pinch, панорамирование, двойной тап, предел, догрузка оригинала, + поведение pager и сброс при смене фото. + +## Impact + +- `android/app/src/main/java/com/galo/android/ui/viewer/ViewerScreen.kt` — обёртка + изображения и проброс состояния зума в `ViewerContent` (блокировка pager). +- Новый zoomable-компонент изображения (ручной `pointerInput` + `graphicsLayer`), + без новых зависимостей. +- Тесты: Robolectric «свайп не листает увеличенное фото»; юнит на предел и якорь. +- Серверная часть и веб-клиент не меняются. diff --git a/openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md b/openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md new file mode 100644 index 0000000..1b65743 --- /dev/null +++ b/openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md @@ -0,0 +1,82 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Просмотр медиа на весь экран + +Полноэкранный просмотр SHALL поддерживать изображение с зумом пальцами, видео с постером +и управлением воспроизведением, а также панораму 360; навигация между элементами альбома +SHALL выполняться свайпом. + +#### Scenario: Свайп между элементами + +- **WHEN** пользователь свайпает в просмотре +- **THEN** показывается следующий или предыдущий элемент альбома без выхода из просмотра + +#### Scenario: Видео + +- **WHEN** открыт видеоэлемент +- **THEN** показывается постер и доступно воспроизведение с перемоткой + +## ADDED Requirements + +### Requirement: Зум изображения жестами + +Полноэкранный просмотр изображения SHALL поддерживать увеличение и уменьшение щипком +(pinch), а при увеличенном изображении — панорамирование перетаскиванием. + +#### Scenario: Щипок увеличивает изображение + +- **WHEN** пользователь разводит пальцы на изображении +- **THEN** изображение увеличивается относительно вписанного вида + +#### Scenario: Панорамирование при увеличении + +- **WHEN** изображение увеличено, и пользователь перетаскивает его пальцем +- **THEN** видимая область изображения смещается, а границы изображения не отрываются от краёв области просмотра + +### Requirement: Двойной тап переключает вписанный вид и 100% + +Двойной тап по изображению SHALL переключать вписанный вид и масштаб 100% натурального +размера с якорем в точку нажатия. + +#### Scenario: Вход в 100% из вписанного вида + +- **WHEN** изображение показано вписанным, и пользователь делает двойной тап +- **THEN** изображение показывается в масштабе 100% натурального размера с центром в точке нажатия + +#### Scenario: Возврат во вписанный вид + +- **WHEN** изображение увеличено, и пользователь делает двойной тап +- **THEN** изображение возвращается во вписанный вид + +### Requirement: Предел зума по натуральному размеру + +Зум изображения SHALL ограничиваться снизу вписанным видом, а сверху — 200% натурального +размера изображения; при входе в зум SHALL догружаться оригинал изображения. + +#### Scenario: Верхний предел + +- **WHEN** пользователь продолжает увеличивать изображение за 200% натурального размера +- **THEN** масштаб останавливается на 200%, изображение не увеличивается дальше + +#### Scenario: Оригинал при увеличении + +- **WHEN** пользователь входит в зум +- **THEN** догружается оригинал изображения и подменяет превью по факту загрузки + +### Requirement: Навигация и сброс при зуме + +Пока изображение увеличено, горизонтальный свайп SHALL NOT листать альбом, а жест SHALL +отдаваться панорамированию; при переходе на другой элемент просмотра зум SHALL сбрасываться +во вписанный вид. + +#### Scenario: Свайп не листает увеличенное изображение + +- **WHEN** изображение увеличено, и пользователь свайпает по нему +- **THEN** альбом не листается, а изображение панорамируется + +#### Scenario: Сброс при смене фото + +- **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению +- **THEN** изображение показывается во вписанном виде diff --git a/openspec/changes/add-mobile-photo-zoom/tasks.md b/openspec/changes/add-mobile-photo-zoom/tasks.md new file mode 100644 index 0000000..7b706f9 --- /dev/null +++ b/openspec/changes/add-mobile-photo-zoom/tasks.md @@ -0,0 +1,26 @@ +# Tasks + +## 1. Zoomable image component + +- [x] 1.1 Implement зум-математику (диапазон `[fitScale, 2.0]`, якорь в точку жеста) отдельным + тестируемым помощником; проверить юнит-тестом клампы предела и пересчёт якоря при double-tap +- [x] 1.2 Реализовать слой жестов (pinch + панорамирование при зуме + double-tap) над изображением + через `pointerInput` + `graphicsLayer`; проверить Compose-тестом (Robolectric) на pinch и double-tap +- [x] 1.3 Переключать источник на `originalUrl` при входе в зум, сохраняя cache key Coil; проверить, + что запрос данных меняется при `zoomed` и не сбрасывает дисковый кэш + +## 2. Viewer integration + +- [x] 2.1 Поднять `zoomed` в `ViewerContent` и учитывать его в `pagerScrollEnabled`; проверить + Robolectric-тестом «свайп не листает увеличенное фото» +- [x] 2.2 Подключить zoomable-обёртку в ветку изображения `MediaPage`; проверить тестом, что при + переходе на другой элемент и обратно изображение показывается во вписанном виде + +## 3. Integration verification + +- [x] 3.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch, + панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты + +## Workflow follow-up + +- Архив change после прохождения ревью проекта.