Merge commit 'f6df9e2' into develop

This commit is contained in:
iu
2026-10-13 12:35:21 +03:00
14 changed files with 949 additions and 10 deletions
@@ -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())
}
}
@@ -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
}
}
@@ -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(),
)
}
@@ -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
},
)
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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<android.content.Context>()
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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-13
@@ -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.
@@ -0,0 +1,39 @@
# Proposal
## Why
В веб-просмотрщике фото можно приблизить (колесо, `Space`), а в Android-клиенте
изображения в полноэкранном просмотре открываются только во вписанном виде — в 360-панораме
зум есть, в обычных фото нет. Пользователь не может рассмотреть деталь снимка на телефоне.
## What Changes
- В полноэкранный просмотр обычных изображений добавляется зум пальцами:
- pinch увеличивает/уменьшает, панорамирование при увеличении, двойной тап
переключает вписанный вид и 100% натурального размера с якорем в точку тапа.
- Предел зума — 200% натурального размера изображения, минимум — вписанный вид
(семантика веб-просмотрщика, адаптированная под тач).
- При входе в зум догружается оригинал (`originalUrl`), другие фото сбрасываются во
вписанный вид при переходе.
- Пока изображение увеличено, горизонтальный свайп не листает альбом — жест отдан
панорамированию (как для панорамы).
- Исключения: видео и 360-панорама не затрагиваются (у панорамы зум уже есть).
## Capabilities
### New Capabilities
<!-- none -->
### 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 «свайп не листает увеличенное фото»; юнит на предел и якорь.
- Серверная часть и веб-клиент не меняются.
@@ -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** изображение показывается во вписанном виде
@@ -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 после прохождения ревью проекта.