chore(openspec): archive add-mobile-photo-zoom and sync main specs

This commit is contained in:
iu
2026-10-13 12:41:04 +03:00
parent beeb4c7c09
commit 327f820c3b
6 changed files with 79 additions and 3 deletions
@@ -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.