# 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.