Merge commit 'f6df9e2' into develop
This commit is contained in:
@@ -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,82 @@
|
||||
# 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** изображение показывается во вписанном виде
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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. Integration verification
|
||||
|
||||
- [x] 3.1 Прогнать `./gradlew test` и `./gradlew lintDebug`; на эмуляторе вручную проверить pinch,
|
||||
панорамирование, double-tap, предел 200%, а также что видео и панорама не затронуты
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Архив change после прохождения ревью проекта.
|
||||
Reference in New Issue
Block a user