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
@@ -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,97 @@
# 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** изображение показывается во вписанном виде
### Requirement: Индикатор загрузки оригинала
Пока оригинал изображения загружается после входа в зум, приложение SHALL показывать индикатор
загрузки в правом верхнем углу области просмотра.
#### Scenario: Оригинал загружается
- **WHEN** пользователь входит в зум, и оригинал ещё загружается
- **THEN** в правом верхнем углу показывается индикатор загрузки
#### Scenario: Загрузка завершена
- **WHEN** оригинал загружен или недоступен
- **THEN** индикатор загрузки скрыт
@@ -0,0 +1,31 @@
# 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. Loading indicator
- [x] 3.1 Показывать индикатор загрузки в правом верхнем углу, пока грузится оригинал при зуме;
проверить тестом условие показа и что вписанный вид индикатор не показывает
## 4. Integration verification
- [x] 4.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch,
панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты
## Workflow follow-up
- Архив change после прохождения ревью проекта.