# Design ## Context См. `proposal.md` — Why. Ниже только то, что определяет подход. - Карточка собирается из данных без представления о синхронизации: `AlbumListBuilder.build` получает `List` (серверные), `List` и `List`, а `AlbumsScreen.AlbumCardBody` рисует вторую строку по `StoragePlacement`, выведенному из `SyncMode`. - Очередь загрузки уже привязана к альбому устройства: `UploadJobEntity.deviceAlbumId` и `assetKey` заполняются `enqueueSync`, но доменная модель `UploadJob` эти поля отбрасывает, а `UploadDao` умеет только `observeAll()`. - Индекс провенанса (`device_assets`) хранит строку на каждый ассет, у которого есть серверное фото (`serverPhotoId`), и состояние из `LocalAssetState`; `SyncDao` читает его по одному альбому (`observeAssets`), агрегата «сколько загружено по всем альбомам» нет. - Прогресс пишется пофайлово: `StreamingRequestBody` считает `sent/total`, `UploadWorker` сохраняет `progress` 0..100 на RUNNING-джобе (throttled по изменению процента). - `job.size` для sync-джобов равен `0L` (реальная длина берётся из file descriptor в момент отправки), поэтому байтовое взвешивание по очереди недоступно без изменения схемы. - В приложении нет ни одной бесконечной анимации: `rememberInfiniteTransition` не используется, «уменьшение движения» нигде не обрабатывается. ## Goals / Non-Goals **Goals:** - Различить режимы B и C на карточке, не теряя информацию о локальной копии в C. - Показать ход операции копирования прямо на карточке, включая ожидание и ошибку. - Держать прогресс монотонным и вычислять его из уже существующих данных (без изменения схемы Room). - Сохранить чистую, тестируемую сборку списка. **Non-Goals:** - Прогресс показывается для обоих синхронизируемых режимов (B и C): и копии, и перенос загружают оригиналы, поэтому процент операции не должен зависеть от режима. - Ручные загрузки в серверный альбом (у них `deviceAlbumId = null`, отдельный экран). - Изменение самой синхронизации, её политик или серверной части. - Веб-клиент. ## Decisions **Метка режима вместо вариантов облака.** Третья строка называет режим («Копии»/«Перенос»), а значок карты памяти показывается и в C. Альтернатива — варианты глифа облака («облако↑»/«облако✓») — отклонена: в сетке медиа те же облако/перечёркнутое облако уже означают состояние **элемента**, и третья трактовка глифа запутала бы. Альтернатива «переопределить SD как «есть локальная копия», режимы не различать» отклонена: B и C сливаются, а режим — главное, что различает карточки. **SD в режиме переноса означает «есть локальная копия» (превью), а не «оригинал здесь».** Трактовка расширяется относительно текущей спеки; описана в delta-спеке явно. **Формула процента — гибрид, а не просто счётчик.** `percent = clamp((uploaded + running) / total * 100)`, где `uploaded` — число строк индекса с `serverPhotoId`, `running` — сумма `progress/100` по RUNNING-джобам альбома, `total` — `itemCount` альбома устройства. Формула общая для копий и переноса. Альтернативы: `uploaded/total` замирает на большом видео; среднее по `job.progress` «едет назад», потому что `clearCompleted` удаляет SUCCESS-джобы и знаменатель тает. Гибрид монотонен: при завершении файла его дробь `1.0` уходит из `running`, а `uploaded` увеличивается на единицу. **Процент считается по альбому, а не по текущей очереди.** Знаменатель — полное число элементов альбома, поэтому добавление новых фото корректно понижает процент, а не перезапускает его. Дедуп (`SyncAction.Link`, без загрузки) тоже засчитывается как «дошло» и может дать скачок вверх — это осознанно. **Пульсация включается только при наличии RUNNING-загрузок.** Иначе альбом, ждущий Wi‑Fi (`wifiOnlyUploads` + отсутствие `UNMETERED`), пульсировал бы вечно. Состояния разводятся по статусу: `RUNNING` → процент с пульсацией, `QUEUED` → «в очереди», `QUEUED` при Wi‑Fi-only → «ждёт Wi‑Fi», `ERROR` → акцент ошибки, ничего нет → «Копии». **Данные для списка — агрегатные запросы, а не N подписок.** В `UploadDao` добавляется поток sync-джобов (`deviceAlbumId IS NOT NULL`), в `SyncDao` — `GROUP BY deviceAlbumId` по числу строк с `serverPhotoId`. Оба потока сводятся в `AlbumsViewModel` вместе с существующей `combine`, а в `AlbumListBuilder` попадает готовая карта «deviceAlbumId → статус», поэтому сама сборка остаётся чистой функцией и тестируется без Room. **Новый доменный тип статуса.** `AlbumSyncProgress` (режим + процент/счётчик очереди/ошибка/ ожидание Wi‑Fi) с чистой функцией сборки из (режим, агрегат очереди, число загруженных, `itemCount`). Альтернатива — тащить поля в `AlbumListItem` по отдельности — отклонена: размазывает правило и усложняет тесты. **Пульсация — альфа-анимация текста.** `rememberInfiniteTransition` поверх прозрачности процента; полосы нет. Дешёвая анимация в одной строке, не меняет высоту плитки. **Граничные случаи.** `total == 0` → процент не показывается, состояние падает в очередь/ простой. Неизвестный режим в БД по-прежнему трактуется как `DISABLED` (существующее поведение `SyncMode.from`). ## Risks / Trade-offs - [Процент скачет из-за дедупа] → принято: скачок вверх честно отражает, что файл уже был на сервере; монотонность важнее равномерности. - [`total` из MediaStore и индекс расходятся (reconcile ещё не проходил)] → процент стартует не с нуля и может превысить 100; клампим и опираемся на статус очереди, а не только на число. - [Несколько загрузок параллельно делают `running` многократным] → сумма дробей корректна, но процент может «забегать» вперёд при параллелизме; это допустимо для индикатора хода. - [Первая в приложении бесконечная анимация] → нет обработки «уменьшения движения»; риск для пользователей с motion sensitivity. Митигация: пульсирует только текст и только при активной загрузке; вопрос reduce-motion вынесен в открытые. - [Производительность: агрегаты на каждую эмиссию списка] → запросы покрыты индексами (`upload_jobs.status`, `upload_jobs.albumId`) и `GROUP BY` по `device_assets`; объёмы на устройстве невелики. - [Провал, попавший в `ERROR` после `MAX_ATTEMPTS`, оставляет процент ниже 100] → карточка показывает состояние ошибки вместо тихого плато. ## Migration Plan Схема Room не меняется, миграций нет. Раскатка — обычная сборка APK; откат — релиз без изменения (старые данные совместимы, поле `deviceAlbumId` уже присутствует). ## Open Questions - Нужна ли поддержка «уменьшения движения» (отключение пульсации по системной настройке).