Merge commit '327f820' into develop

This commit is contained in:
iu
2026-10-13 12:41:16 +03:00
8 changed files with 146 additions and 5 deletions
@@ -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),
)
}
}
}
@@ -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
@@ -80,3 +80,18 @@ SHALL выполняться свайпом.
- **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению
- **THEN** изображение показывается во вписанном виде
### Requirement: Индикатор загрузки оригинала
Пока оригинал изображения загружается после входа в зум, приложение SHALL показывать индикатор
загрузки в правом верхнем углу области просмотра.
#### Scenario: Оригинал загружается
- **WHEN** пользователь входит в зум, и оригинал ещё загружается
- **THEN** в правом верхнем углу показывается индикатор загрузки
#### Scenario: Загрузка завершена
- **WHEN** оригинал загружен или недоступен
- **THEN** индикатор загрузки скрыт
@@ -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
+79 -3
View File
@@ -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** индикатор загрузки скрыт