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