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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -16,8 +19,10 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale 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.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.galo.android.data.image.ObjectCacheKey import com.galo.android.data.image.ObjectCacheKey
import com.galo.android.domain.viewer.PhotoZoom import com.galo.android.domain.viewer.PhotoZoom
const val TAG_ZOOMABLE_IMAGE = "zoomable_image" 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 * 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() .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 * Photo that can be zoomed and panned, mirroring the web viewer semantics (see
* `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to * `PhotoZoom`). [previewUrl] is shown fitted; once zoomed the image switches to
@@ -88,6 +107,7 @@ fun ZoomableImage(
var panX by remember { mutableFloatStateOf(0f) } var panX by remember { mutableFloatStateOf(0f) }
var panY by remember { mutableFloatStateOf(0f) } var panY by remember { mutableFloatStateOf(0f) }
var zoomed by remember { mutableStateOf(false) } var zoomed by remember { mutableStateOf(false) }
var originalLoading by remember { mutableStateOf(false) }
fun sync() { fun sync() {
layerScale = zoom.layerScale layerScale = zoom.layerScale
@@ -172,7 +192,9 @@ fun ZoomableImage(
model = request, model = request,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
onLoading = { originalLoading = true },
onSuccess = { state -> onSuccess = { state ->
originalLoading = false
if (natural.width <= 0 || natural.height <= 0) { if (natural.width <= 0 || natural.height <= 0) {
val drawable = state.result.drawable val drawable = state.result.drawable
if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) {
@@ -180,6 +202,7 @@ fun ZoomableImage(
} }
} }
}, },
onError = { originalLoading = false },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
@@ -189,5 +212,16 @@ fun ZoomableImage(
translationY = panY 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 com.galo.android.data.image.ObjectCacheKey
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ZoomSourceTest { class ZoomSourceTest {
@@ -25,6 +27,15 @@ class ZoomSourceTest {
assertEquals(preview, zoomSource(preview, "", zoomed = true)) 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 @Test
fun `switching source keeps a signature-independent cache key`() { fun `switching source keeps a signature-independent cache key`() {
// The Coil key drops the rotating signature, so re-serving the same object // The Coil key drops the rotating signature, so re-serving the same object
@@ -80,3 +80,18 @@ SHALL выполняться свайпом.
- **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению - **WHEN** пользователь переходит на другой элемент и возвращается к увеличенному изображению
- **THEN** изображение показывается во вписанном виде - **THEN** изображение показывается во вписанном виде
### Requirement: Индикатор загрузки оригинала
Пока оригинал изображения загружается после входа в зум, приложение SHALL показывать индикатор
загрузки в правом верхнем углу области просмотра.
#### Scenario: Оригинал загружается
- **WHEN** пользователь входит в зум, и оригинал ещё загружается
- **THEN** в правом верхнем углу показывается индикатор загрузки
#### Scenario: Загрузка завершена
- **WHEN** оригинал загружен или недоступен
- **THEN** индикатор загрузки скрыт
@@ -16,9 +16,14 @@
- [x] 2.2 Подключить zoomable-обёртку в ветку изображения `MediaPage`; проверить тестом, что при - [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%, а также что видео и панорама не затронуты панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты
## Workflow follow-up ## Workflow follow-up
+79 -3
View File
@@ -109,9 +109,9 @@ SHALL подгружать следующую страницу при дости
### Requirement: Просмотр медиа на весь экран ### Requirement: Просмотр медиа на весь экран
Полноэкранный просмотр SHALL поддерживать изображение, видео с постером и управлением Полноэкранный просмотр SHALL поддерживать изображение с зумом пальцами, видео с постером
воспроизведением, а также панораму 360; навигация между элементами альбома SHALL и управлением воспроизведением, а также панораму 360; навигация между элементами альбома
выполняться свайпом. SHALL выполняться свайпом.
#### Scenario: Свайп между элементами #### Scenario: Свайп между элементами
@@ -229,3 +229,79 @@ SHALL подгружать следующую страницу при дости
- **WHEN** сервер возвращает ошибку с `code` и `message` - **WHEN** сервер возвращает ошибку с `code` и `message`
- **THEN** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса - **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** индикатор загрузки скрыт