From beeb4c7c09df2a15bf2e6eead04fa5b7257b3ac7 Mon Sep 17 00:00:00 2001 From: Iu Date: Tue, 13 Oct 2026 12:40:53 +0300 Subject: [PATCH 1/2] feat(viewer): show a loading spinner while the zoomed original loads Show a spinner in the top-right of the photo viewer while the original is being loaded on zoom, so the wait is visible. Only the original triggers it; the fitted preview and a re-shown preview never do. Cover the visibility gate with a unit test. OpenSpec change: add-mobile-photo-zoom --- .../galo/android/ui/viewer/ZoomableImage.kt | 34 +++++++++++++++++++ .../galo/android/ui/viewer/ZoomSourceTest.kt | 11 ++++++ .../specs/android-client/spec.md | 15 ++++++++ .../changes/add-mobile-photo-zoom/tasks.md | 9 +++-- 4 files changed, 67 insertions(+), 2 deletions(-) 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 index 0bba99a..0f2473c 100644 --- 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 @@ -9,6 +9,9 @@ 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.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -16,8 +19,10 @@ import androidx.compose.runtime.mutableFloatStateOf 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.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -25,12 +30,14 @@ 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 androidx.compose.ui.unit.dp 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" +const val TAG_ZOOM_LOADING = "zoom_loading" /** * Image source shown for the current zoom state: the original when the image is @@ -61,6 +68,18 @@ internal fun zoomImageRequest(context: Context, source: String?, previewUrl: Str .build() } +/** + * True while the zoomed-in original is loading, i.e. the corner spinner is shown. + * Requires a source distinct from the fitted preview, so re-loading the preview + * itself (e.g. when no separate original exists) never shows the spinner. + */ +internal fun showZoomLoading( + zoomed: Boolean, + source: String?, + previewUrl: String?, + loading: Boolean, +): Boolean = zoomed && loading && source != null && source != previewUrl + /** * Photo that can be zoomed and panned, mirroring the web viewer semantics (see * `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to @@ -88,6 +107,7 @@ fun ZoomableImage( var panX by remember { mutableFloatStateOf(0f) } var panY by remember { mutableFloatStateOf(0f) } var zoomed by remember { mutableStateOf(false) } + var originalLoading by remember { mutableStateOf(false) } fun sync() { layerScale = zoom.layerScale @@ -172,7 +192,9 @@ fun ZoomableImage( model = request, contentDescription = contentDescription, contentScale = ContentScale.Fit, + onLoading = { originalLoading = true }, onSuccess = { state -> + originalLoading = false if (natural.width <= 0 || natural.height <= 0) { val drawable = state.result.drawable if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { @@ -180,6 +202,7 @@ fun ZoomableImage( } } }, + onError = { originalLoading = false }, modifier = Modifier .fillMaxSize() .graphicsLayer { @@ -189,5 +212,16 @@ fun ZoomableImage( translationY = panY }, ) + if (showZoomLoading(zoomed, source, previewUrl, originalLoading)) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 2.dp, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .size(24.dp) + .testTag(TAG_ZOOM_LOADING), + ) + } } } 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 index be7bc9d..9dbfeed 100644 --- 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 @@ -2,6 +2,8 @@ package com.galo.android.ui.viewer import com.galo.android.data.image.ObjectCacheKey import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ZoomSourceTest { @@ -25,6 +27,15 @@ class ZoomSourceTest { assertEquals(preview, zoomSource(preview, "", zoomed = true)) } + @Test + fun `loading spinner shows only while the zoomed original is loading`() { + assertTrue(showZoomLoading(zoomed = true, source = original, previewUrl = preview, loading = true)) + assertFalse("fitted view", showZoomLoading(false, original, preview, true)) + assertFalse("not loading", showZoomLoading(true, original, preview, false)) + assertFalse("preview is the source", showZoomLoading(true, preview, preview, true)) + assertFalse("no source", showZoomLoading(true, null, preview, true)) + } + @Test fun `switching source keeps a signature-independent cache key`() { // The Coil key drops the rotating signature, so re-serving the same object 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 index 1b65743..2be3433 100644 --- a/openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md +++ b/openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md @@ -80,3 +80,18 @@ SHALL выполняться свайпом. - **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению - **THEN** изображение показывается во вписанном виде + +### Requirement: Индикатор загрузки оригинала + +Пока оригинал изображения загружается после входа в зум, приложение 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 index 7b706f9..6861900 100644 --- a/openspec/changes/add-mobile-photo-zoom/tasks.md +++ b/openspec/changes/add-mobile-photo-zoom/tasks.md @@ -16,9 +16,14 @@ - [x] 2.2 Подключить zoomable-обёртку в ветку изображения `MediaPage`; проверить тестом, что при переходе на другой элемент и обратно изображение показывается во вписанном виде -## 3. Integration verification +## 3. Loading indicator -- [x] 3.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch, +- [x] 3.1 Показывать индикатор загрузки в правом верхнем углу, пока грузится оригинал при зуме; + проверить тестом условие показа и что вписанный вид индикатор не показывает + +## 4. Integration verification + +- [x] 4.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch, панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты ## Workflow follow-up From 327f820c3b035ff7eee4d0e9b8a487ebbd774235 Mon Sep 17 00:00:00 2001 From: Iu Date: Tue, 13 Oct 2026 12:41:04 +0300 Subject: [PATCH 2/2] chore(openspec): archive add-mobile-photo-zoom and sync main specs --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/android-client/spec.md | 0 .../tasks.md | 0 openspec/specs/android-client/spec.md | 82 ++++++++++++++++++- 6 files changed, 79 insertions(+), 3 deletions(-) rename openspec/changes/{add-mobile-photo-zoom => archive/2026-10-13-add-mobile-photo-zoom}/.openspec.yaml (100%) rename openspec/changes/{add-mobile-photo-zoom => archive/2026-10-13-add-mobile-photo-zoom}/design.md (100%) rename openspec/changes/{add-mobile-photo-zoom => archive/2026-10-13-add-mobile-photo-zoom}/proposal.md (100%) rename openspec/changes/{add-mobile-photo-zoom => archive/2026-10-13-add-mobile-photo-zoom}/specs/android-client/spec.md (100%) rename openspec/changes/{add-mobile-photo-zoom => archive/2026-10-13-add-mobile-photo-zoom}/tasks.md (100%) diff --git a/openspec/changes/add-mobile-photo-zoom/.openspec.yaml b/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/.openspec.yaml similarity index 100% rename from openspec/changes/add-mobile-photo-zoom/.openspec.yaml rename to openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/.openspec.yaml diff --git a/openspec/changes/add-mobile-photo-zoom/design.md b/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/design.md similarity index 100% rename from openspec/changes/add-mobile-photo-zoom/design.md rename to openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/design.md diff --git a/openspec/changes/add-mobile-photo-zoom/proposal.md b/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/proposal.md similarity index 100% rename from openspec/changes/add-mobile-photo-zoom/proposal.md rename to openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/proposal.md diff --git a/openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md b/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/specs/android-client/spec.md similarity index 100% rename from openspec/changes/add-mobile-photo-zoom/specs/android-client/spec.md rename to openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/specs/android-client/spec.md diff --git a/openspec/changes/add-mobile-photo-zoom/tasks.md b/openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/tasks.md similarity index 100% rename from openspec/changes/add-mobile-photo-zoom/tasks.md rename to openspec/changes/archive/2026-10-13-add-mobile-photo-zoom/tasks.md diff --git a/openspec/specs/android-client/spec.md b/openspec/specs/android-client/spec.md index 31cf436..a77788d 100644 --- a/openspec/specs/android-client/spec.md +++ b/openspec/specs/android-client/spec.md @@ -109,9 +109,9 @@ SHALL подгружать следующую страницу при дости ### Requirement: Просмотр медиа на весь экран -Полноэкранный просмотр SHALL поддерживать изображение, видео с постером и управлением -воспроизведением, а также панораму 360; навигация между элементами альбома SHALL -выполняться свайпом. +Полноэкранный просмотр SHALL поддерживать изображение с зумом пальцами, видео с постером +и управлением воспроизведением, а также панораму 360; навигация между элементами альбома +SHALL выполняться свайпом. #### Scenario: Свайп между элементами @@ -229,3 +229,79 @@ SHALL подгружать следующую страницу при дости - **WHEN** сервер возвращает ошибку с `code` и `message` - **THEN** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса + +### 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** изображение показывается во вписанном виде + +### Requirement: Индикатор загрузки оригинала + +Пока оригинал изображения загружается после входа в зум, приложение SHALL показывать индикатор +загрузки в правом верхнем углу области просмотра. + +#### Scenario: Оригинал загружается + +- **WHEN** пользователь входит в зум, и оригинал ещё загружается +- **THEN** в правом верхнем углу показывается индикатор загрузки + +#### Scenario: Загрузка завершена + +- **WHEN** оригинал загружен или недоступен +- **THEN** индикатор загрузки скрыт